@kernhq/module-tracker 0.10.0 → 0.11.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.
Files changed (85) hide show
  1. package/package.json +16 -3
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/rank.test.ts +78 -0
  57. package/src/client/rank.ts +13 -4
  58. package/src/client/recurrence.test.ts +37 -0
  59. package/src/client/recurrence.ts +89 -0
  60. package/src/client/richtext.ts +155 -0
  61. package/src/client/settings/CycleList.svelte +422 -0
  62. package/src/client/settings/FieldEditor.svelte +324 -0
  63. package/src/client/settings/FieldsSettings.svelte +273 -0
  64. package/src/client/settings/ImportSettings.svelte +410 -0
  65. package/src/client/settings/LayoutEditor.svelte +263 -0
  66. package/src/client/settings/PlanningList.svelte +249 -0
  67. package/src/client/settings/PlanningSettings.svelte +179 -0
  68. package/src/client/settings/ProjectsSettings.svelte +462 -0
  69. package/src/client/settings/RepeatingSettings.svelte +293 -0
  70. package/src/client/settings/TypeEditor.svelte +214 -0
  71. package/src/client/settings/TypesSettings.svelte +384 -0
  72. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  73. package/src/client/settings/mutations.ts +19 -0
  74. package/src/client/time.test.ts +39 -0
  75. package/src/client/time.ts +34 -0
  76. package/src/client/tracker.test.ts +399 -0
  77. package/src/client/views.ts +27 -0
  78. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  79. package/src/client/widgets/CountWidget.svelte +56 -0
  80. package/src/client/widgets/CycleWidget.svelte +85 -0
  81. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  82. package/src/client/widgets/IssuesWidget.svelte +222 -0
  83. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  84. package/src/client/widgets/TimerWidget.svelte +116 -0
  85. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,219 @@
1
+ <script lang="ts">
2
+ import { Button, Icon, IconButton, rankCandidates, Spinner, toast, uploadFile } from '@kernhq/ui'
3
+ import { type MentionCandidate, RichTextEditor } from '@kernhq/ui/editor'
4
+ import { getTrackerCatalogue } from '../context.svelte.js'
5
+ import { t } from '../i18n.js'
6
+ import type { RichDoc } from '../index.js'
7
+ import { isEmptyDoc } from '../richtext.js'
8
+
9
+ /**
10
+ * Writing a comment: text, `@` mentions, and files.
11
+ *
12
+ * The same `@` machinery chat uses (`$lib/mentions`), so a mention here is a real mention node the
13
+ * server turns into a notification, not the characters `@Ada` sitting in a sentence.
14
+ *
15
+ * Files upload as you pick them and are attached to the comment once it exists — a comment id has
16
+ * to exist before an attachment can point at it, so the parent posts the comment and then hands
17
+ * the ids over.
18
+ */
19
+ interface Props {
20
+ initial?: RichDoc | null
21
+ placeholder?: string
22
+ submitLabel?: string
23
+ /** editing an existing comment cannot add files: they attach at creation */
24
+ allowFiles?: boolean
25
+ busy?: boolean
26
+ /** the workspace the files belong to */
27
+ workspaceId: string
28
+ oncancel?: () => void
29
+ onsubmit: (body: RichDoc, fileIds: string[], internal: boolean) => void
30
+ }
31
+ let {
32
+ initial = null,
33
+ placeholder = t('comment_placeholder'),
34
+ submitLabel = t('comment_send'),
35
+ allowFiles = true,
36
+ busy = false,
37
+ workspaceId,
38
+ oncancel,
39
+ onsubmit,
40
+ }: Props = $props()
41
+
42
+ const cat = getTrackerCatalogue()
43
+
44
+ // The starting draft only: later prop changes must not overwrite what is being typed.
45
+ let body = $state<RichDoc | null>(initial ?? null)
46
+ let internal = $state(false)
47
+ let fileInput = $state<HTMLInputElement | null>(null)
48
+
49
+ let uploads = $state<Array<{ id: string; name: string; ratio: number | null; fileId: string | null }>>([])
50
+
51
+ /*
52
+ * Mentions are nodes in the document now, not a list kept beside a string. The editor inserts a
53
+ * real `mention` node when someone is picked, so there is nothing left to reconcile on save and
54
+ * no way for a hand-typed "@Ada" to be mistaken for one.
55
+ */
56
+ const people = $derived(cat.people.map((p) => ({ id: p.id, name: p.name, avatarUrl: p.avatarUrl ?? null })))
57
+ /* `rankCandidates` is the ranking chat uses, and it wants a lowercased query and a `name`; the
58
+ editor speaks `label`. Bridge here rather than forking either. */
59
+ const mentionSource = (query: string): MentionCandidate[] =>
60
+ rankCandidates(people, query.toLowerCase()).map((p) => ({
61
+ id: p.id,
62
+ label: p.name,
63
+ avatarUrl: p.avatarUrl ?? null,
64
+ }))
65
+
66
+ const ready = $derived(uploads.every((u) => u.fileId !== null))
67
+ const empty = $derived(isEmptyDoc(body))
68
+
69
+ async function addFiles(list: FileList | null) {
70
+ if (!list?.length) return
71
+ for (const file of Array.from(list)) {
72
+ const id = crypto.randomUUID()
73
+ uploads = [...uploads, { id, name: file.name, ratio: 0, fileId: null }]
74
+ try {
75
+ const uploaded = await uploadFile({
76
+ workspaceId,
77
+ file,
78
+ name: file.name,
79
+ mimeType: file.type || undefined,
80
+ onProgress: ({ ratio }) => {
81
+ uploads = uploads.map((u) => (u.id === id ? { ...u, ratio } : u))
82
+ },
83
+ })
84
+ uploads = uploads.map((u) => (u.id === id ? { ...u, ratio: 1, fileId: uploaded.id } : u))
85
+ } catch (err) {
86
+ // Drop the row rather than leave a file that looks attached and is not.
87
+ uploads = uploads.filter((u) => u.id !== id)
88
+ toast.error(err instanceof Error ? err.message : t('attachment_failed'))
89
+ }
90
+ }
91
+ }
92
+
93
+ function submit() {
94
+ if (empty || busy || !ready) return
95
+ onsubmit(
96
+ // A state proxy cannot survive `structuredClone` on the way to the API — snapshot it first.
97
+ $state.snapshot(body) as RichDoc,
98
+ uploads.map((u) => u.fileId).filter((id): id is string => Boolean(id)),
99
+ internal,
100
+ )
101
+ body = null
102
+ uploads = []
103
+ internal = false
104
+ }
105
+ </script>
106
+
107
+ <div class="composer">
108
+ <RichTextEditor
109
+ bind:value={body}
110
+ minRows={2}
111
+ {placeholder}
112
+ label={placeholder}
113
+ {mentionSource}
114
+ data-testid="comment-input"
115
+ onsubmit={submit}
116
+ onescape={oncancel}
117
+ />
118
+
119
+ {#if uploads.length}
120
+ <ul class="uploads">
121
+ {#each uploads as file (file.id)}
122
+ <li>
123
+ {#if file.fileId}
124
+ <Icon name="paperclip" size={12} strokeWidth={1.8} />
125
+ {:else}
126
+ <Spinner size={12} />
127
+ {/if}
128
+ <span class="uname">{file.name}</span>
129
+ <IconButton
130
+ icon="x"
131
+ size={22}
132
+ label={t('attachment_remove')}
133
+ onclick={() => (uploads = uploads.filter((u) => u.id !== file.id))}
134
+ />
135
+ </li>
136
+ {/each}
137
+ </ul>
138
+ {/if}
139
+
140
+ <div class="actions">
141
+ {#if allowFiles}
142
+ <input
143
+ bind:this={fileInput}
144
+ type="file"
145
+ multiple
146
+ hidden
147
+ onchange={(e) => {
148
+ void addFiles(e.currentTarget.files)
149
+ e.currentTarget.value = ''
150
+ }}
151
+ />
152
+ <IconButton
153
+ icon="paperclip"
154
+ size={26}
155
+ label={t('attach_file')}
156
+ onclick={() => fileInput?.click()}
157
+ />
158
+ <label class="internal">
159
+ <input type="checkbox" bind:checked={internal} />
160
+ <span title={t('comment_internal_hint')}>{t('comment_internal')}</span>
161
+ </label>
162
+ {/if}
163
+ <span class="hint">{t('comment_hint')}</span>
164
+ {#if oncancel}
165
+ <Button size="sm" variant="ghost" onclick={oncancel}>{t('common.cancel')}</Button>
166
+ {/if}
167
+ <Button size="sm" disabled={empty || busy || !ready} onclick={submit}>{submitLabel}</Button>
168
+ </div>
169
+ </div>
170
+
171
+ <style>
172
+ .composer {
173
+ position: relative;
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: 6px;
177
+ }
178
+ .uploads {
179
+ display: flex;
180
+ flex-wrap: wrap;
181
+ gap: 6px;
182
+ margin: 0;
183
+ padding: 0;
184
+ list-style: none;
185
+ }
186
+ .uploads li {
187
+ display: inline-flex;
188
+ align-items: center;
189
+ gap: 4px;
190
+ padding: 2px 6px;
191
+ border-radius: var(--kern-r-sm);
192
+ background: var(--kern-surface-active);
193
+ font-size: 12px;
194
+ }
195
+ .uname {
196
+ max-width: 160px;
197
+ overflow: hidden;
198
+ text-overflow: ellipsis;
199
+ white-space: nowrap;
200
+ }
201
+ .actions {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 8px;
205
+ }
206
+ .internal {
207
+ display: inline-flex;
208
+ align-items: center;
209
+ gap: 4px;
210
+ font-size: 12px;
211
+ color: var(--kern-ink-350);
212
+ cursor: pointer;
213
+ }
214
+ .hint {
215
+ flex: 1;
216
+ font-size: 11px;
217
+ color: var(--kern-ink-350);
218
+ }
219
+ </style>
@@ -0,0 +1,389 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ DropdownMenu,
5
+ EmojiPicker,
6
+ Icon,
7
+ IconButton,
8
+ type MenuItem,
9
+ QUICK_REACTIONS,
10
+ relativeTime,
11
+ session,
12
+ } from '@kernhq/ui'
13
+ import { getTrackerCatalogue } from '../context.svelte.js'
14
+ import { t } from '../i18n.js'
15
+ import type { Attachment, Comment as IssueComment } from '../index.js'
16
+ import { renderDoc } from '../richtext.js'
17
+ import CommentComposer from './CommentComposer.svelte'
18
+
19
+ /**
20
+ * One comment and its replies.
21
+ *
22
+ * The server has carried threading, mentions, an internal flag, edit and delete since the model
23
+ * existed; the panel used three of the five procedures. Everything a comment can do is reachable
24
+ * here.
25
+ */
26
+ interface Props {
27
+ workspaceId: string
28
+ comment: IssueComment
29
+ replies: IssueComment[]
30
+ attachments: Attachment[]
31
+ canComment: boolean
32
+ onreact: (commentId: string, emoji: string) => void
33
+ onedit: (commentId: string, body: unknown) => void
34
+ ondelete: (commentId: string) => void
35
+ onreply: (parentId: string, body: unknown, fileIds: string[]) => void
36
+ onremoveAttachment: (attachmentId: string) => void
37
+ }
38
+ let {
39
+ workspaceId,
40
+ comment,
41
+ replies,
42
+ attachments,
43
+ canComment,
44
+ onreact,
45
+ onedit,
46
+ ondelete,
47
+ onreply,
48
+ onremoveAttachment,
49
+ }: Props = $props()
50
+
51
+ const cat = getTrackerCatalogue()
52
+
53
+ let editing = $state(false)
54
+ let replying = $state(false)
55
+ let confirmingDelete = $state(false)
56
+ let pickerOpen = $state(false)
57
+ let pickerTrigger = $state<HTMLElement | null>(null)
58
+
59
+ const person = $derived(cat.person(comment.authorId))
60
+ /** Only the author edits or deletes their own comment; anything else is somebody else's words. */
61
+ const mine = $derived(Boolean(session.user && comment.authorId === session.user.id))
62
+
63
+ const menu = $derived<MenuItem[]>([
64
+ { type: 'item', id: 'edit', label: t('common.edit'), icon: 'pencil', onSelect: () => (editing = true) },
65
+ {
66
+ type: 'item',
67
+ id: 'delete',
68
+ label: t('common.delete'),
69
+ icon: 'trash-2',
70
+ danger: true,
71
+ onSelect: () => (confirmingDelete = true),
72
+ },
73
+ ])
74
+
75
+ /** Whether I am one of the people behind this reaction, so it can render as mine. */
76
+ const reacted = (userIds: readonly string[]) => (session.user ? userIds.includes(session.user.id) : false)
77
+ </script>
78
+
79
+ <article class="thread" class:internal={comment.internal}>
80
+ <Avatar name={person?.name} src={person?.avatarUrl} id={person?.id} size={28} />
81
+ <div class="tbody">
82
+ <div class="thead">
83
+ <span class="author">{person?.name ?? ''}</span>
84
+ <time datetime={comment.createdAt}>{relativeTime(comment.createdAt)}</time>
85
+ {#if comment.editedAt}
86
+ <span class="tag">{t('comment_edited')}</span>
87
+ {/if}
88
+ {#if comment.internal}
89
+ <span class="tag internal-tag" title={t('comment_internal_hint')}>
90
+ <Icon name="lock" size={11} strokeWidth={1.8} />
91
+ {t('comment_internal')}
92
+ </span>
93
+ {/if}
94
+ {#if mine}
95
+ <span class="spacer"></span>
96
+ <DropdownMenu items={menu} align="end">
97
+ {#snippet trigger(props)}
98
+ <IconButton {...props} icon="ellipsis" size={26} label={t('comment_actions')} />
99
+ {/snippet}
100
+ </DropdownMenu>
101
+ {/if}
102
+ </div>
103
+
104
+ {#if editing}
105
+ <CommentComposer
106
+ {workspaceId}
107
+ initial={comment.body}
108
+ placeholder={t('comment_edit_placeholder')}
109
+ submitLabel={t('common.save')}
110
+ allowFiles={false}
111
+ oncancel={() => (editing = false)}
112
+ onsubmit={(body) => {
113
+ onedit(comment.id, body)
114
+ editing = false
115
+ }}
116
+ />
117
+ {:else if confirmingDelete}
118
+ <!-- Inline rather than window.confirm: it says what happens and can be dismissed with Escape. -->
119
+ <div class="confirm" role="alertdialog" aria-label={t('comment_delete_title')}>
120
+ <p>{t('comment_delete_body')}</p>
121
+ <div class="row">
122
+ <button type="button" class="danger" onclick={() => ondelete(comment.id)}>
123
+ {t('common.delete')}
124
+ </button>
125
+ <button type="button" onclick={() => (confirmingDelete = false)}>{t('common.cancel')}</button>
126
+ </div>
127
+ </div>
128
+ {:else}
129
+ <div class="kern-prose tprose">{@html renderDoc(comment.body)}</div>
130
+ {/if}
131
+
132
+ {#if attachments.length}
133
+ <ul class="files">
134
+ {#each attachments as file (file.id)}
135
+ <li>
136
+ <Icon name="paperclip" size={12} strokeWidth={1.8} />
137
+ <span class="fname">{file.name}</span>
138
+ {#if mine}
139
+ <IconButton
140
+ icon="x"
141
+ size={22}
142
+ label={t('attachment_remove')}
143
+ onclick={() => onremoveAttachment(file.id)}
144
+ />
145
+ {/if}
146
+ </li>
147
+ {/each}
148
+ </ul>
149
+ {/if}
150
+
151
+ <div class="reactions">
152
+ {#each comment.reactions as reaction (reaction.emoji)}
153
+ <button
154
+ type="button"
155
+ class="reaction"
156
+ class:mine={reacted(reaction.userIds)}
157
+ onclick={() => onreact(comment.id, reaction.emoji)}
158
+ >
159
+ {reaction.emoji}
160
+ {reaction.count}
161
+ </button>
162
+ {/each}
163
+ <div class="picker-anchor">
164
+ <button
165
+ type="button"
166
+ class="reaction add"
167
+ aria-label={t('add_reaction')}
168
+ bind:this={pickerTrigger}
169
+ onclick={() => (pickerOpen = !pickerOpen)}
170
+ >
171
+ <Icon name="smile" size={13} strokeWidth={1.6} />
172
+ </button>
173
+ {#if pickerOpen}
174
+ <EmojiPicker
175
+ trigger={pickerTrigger}
176
+ onclose={() => (pickerOpen = false)}
177
+ onpick={(emoji) => {
178
+ onreact(comment.id, emoji)
179
+ pickerOpen = false
180
+ }}
181
+ />
182
+ {/if}
183
+ </div>
184
+ {#each QUICK_REACTIONS.slice(0, 3) as emoji (emoji)}
185
+ <button
186
+ type="button"
187
+ class="reaction quick"
188
+ onclick={() => onreact(comment.id, emoji)}
189
+ aria-label={emoji}
190
+ >
191
+ {emoji}
192
+ </button>
193
+ {/each}
194
+ {#if canComment}
195
+ <button type="button" class="reply-btn" onclick={() => (replying = !replying)}>
196
+ {t('comment_reply')}
197
+ </button>
198
+ {/if}
199
+ </div>
200
+
201
+ {#if replies.length}
202
+ <ul class="replies">
203
+ {#each replies as reply (reply.id)}
204
+ {@const author = cat.person(reply.authorId)}
205
+ <li>
206
+ <Avatar name={author?.name} src={author?.avatarUrl} id={author?.id} size={22} />
207
+ <div>
208
+ <div class="thead">
209
+ <span class="author">{author?.name ?? ''}</span>
210
+ <time datetime={reply.createdAt}>{relativeTime(reply.createdAt)}</time>
211
+ </div>
212
+ <div class="kern-prose tprose">{@html renderDoc(reply.body)}</div>
213
+ </div>
214
+ </li>
215
+ {/each}
216
+ </ul>
217
+ {/if}
218
+
219
+ {#if replying}
220
+ <CommentComposer
221
+ {workspaceId}
222
+ placeholder={t('comment_reply_placeholder')}
223
+ submitLabel={t('comment_reply')}
224
+ oncancel={() => (replying = false)}
225
+ onsubmit={(body, fileIds) => {
226
+ onreply(comment.id, body, fileIds)
227
+ replying = false
228
+ }}
229
+ />
230
+ {/if}
231
+ </div>
232
+ </article>
233
+
234
+ <style>
235
+ .thread {
236
+ display: grid;
237
+ grid-template-columns: 28px 1fr;
238
+ gap: 10px;
239
+ padding: 10px 0;
240
+ }
241
+ .thread.internal {
242
+ border-inline-start: 2px solid var(--kern-warning);
243
+ padding-inline-start: 10px;
244
+ margin-inline-start: -12px;
245
+ }
246
+ .thead {
247
+ display: flex;
248
+ align-items: center;
249
+ gap: 8px;
250
+ font-size: 12px;
251
+ color: var(--kern-ink-350);
252
+ }
253
+ .author {
254
+ font-weight: 600;
255
+ color: var(--kern-ink-900);
256
+ }
257
+ .spacer {
258
+ flex: 1;
259
+ }
260
+ .tag {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 3px;
264
+ font-size: 11px;
265
+ }
266
+ .internal-tag {
267
+ color: var(--kern-warning);
268
+ }
269
+ /* Size only — everything else now comes from `@kernhq/ui/styles/prose.css`, the same sheet the
270
+ editor's writing surface uses, so a comment reads exactly as it was written. */
271
+ .tprose {
272
+ font-size: 13px;
273
+ margin-top: 3px;
274
+ }
275
+ .files {
276
+ display: flex;
277
+ flex-wrap: wrap;
278
+ gap: 6px;
279
+ margin: 6px 0 0;
280
+ padding: 0;
281
+ list-style: none;
282
+ }
283
+ .files li {
284
+ display: inline-flex;
285
+ align-items: center;
286
+ gap: 4px;
287
+ padding: 2px 6px;
288
+ border-radius: var(--kern-r-sm);
289
+ background: var(--kern-surface-active);
290
+ font-size: 12px;
291
+ }
292
+ .fname {
293
+ max-width: 180px;
294
+ overflow: hidden;
295
+ text-overflow: ellipsis;
296
+ white-space: nowrap;
297
+ }
298
+ .reactions {
299
+ display: flex;
300
+ align-items: center;
301
+ flex-wrap: wrap;
302
+ gap: 4px;
303
+ margin-top: 6px;
304
+ }
305
+ .picker-anchor {
306
+ position: relative;
307
+ }
308
+ .reaction {
309
+ display: inline-flex;
310
+ align-items: center;
311
+ gap: 4px;
312
+ padding: 1px 6px;
313
+ border: 1px solid var(--kern-border);
314
+ border-radius: 999px;
315
+ background: none;
316
+ color: inherit;
317
+ font: inherit;
318
+ font-size: 12px;
319
+ cursor: pointer;
320
+ }
321
+ .reaction.mine {
322
+ border-color: var(--kern-accent);
323
+ background: var(--kern-info-tint);
324
+ }
325
+ .reaction.quick,
326
+ .reaction.add {
327
+ border-color: transparent;
328
+ opacity: 0;
329
+ }
330
+ .thread:hover .reaction.quick,
331
+ .thread:hover .reaction.add,
332
+ .reaction.add:focus-visible,
333
+ .reaction.quick:focus-visible {
334
+ opacity: 1;
335
+ }
336
+ .reply-btn {
337
+ border: 0;
338
+ background: none;
339
+ color: var(--kern-ink-350);
340
+ font: inherit;
341
+ font-size: 12px;
342
+ cursor: pointer;
343
+ padding: 1px 4px;
344
+ }
345
+ .reply-btn:hover {
346
+ color: var(--kern-ink-900);
347
+ }
348
+ .replies {
349
+ list-style: none;
350
+ margin: 8px 0 0;
351
+ padding: 0 0 0 4px;
352
+ border-inline-start: 1px solid var(--kern-border);
353
+ }
354
+ .replies li {
355
+ display: grid;
356
+ grid-template-columns: 22px 1fr;
357
+ gap: 8px;
358
+ padding: 6px 0 6px 8px;
359
+ }
360
+ .confirm {
361
+ margin-top: 6px;
362
+ padding: 8px 10px;
363
+ border: 1px solid var(--kern-border);
364
+ border-radius: var(--kern-r-sm);
365
+ background: var(--kern-shell);
366
+ font-size: 13px;
367
+ }
368
+ .confirm p {
369
+ margin: 0 0 8px;
370
+ }
371
+ .confirm .row {
372
+ display: flex;
373
+ gap: 8px;
374
+ }
375
+ .confirm button {
376
+ padding: 3px 10px;
377
+ border: 1px solid var(--kern-border);
378
+ border-radius: var(--kern-r-sm);
379
+ background: var(--kern-surface);
380
+ color: inherit;
381
+ font: inherit;
382
+ font-size: 12px;
383
+ cursor: pointer;
384
+ }
385
+ .confirm .danger {
386
+ border-color: var(--kern-danger);
387
+ color: var(--kern-danger);
388
+ }
389
+ </style>