@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,1337 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Button,
5
+ DropdownMenu,
6
+ formatBytes,
7
+ Icon,
8
+ IconButton,
9
+ type MenuItem,
10
+ relativeTime,
11
+ Sheet,
12
+ Spinner,
13
+ session,
14
+ toast,
15
+ uploadFile,
16
+ } from '@kernhq/ui'
17
+ import { RichTextEditor } from '@kernhq/ui/editor'
18
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
19
+ import { getTrackerApi } from '../api-instance.js'
20
+ import { getTrackerCatalogue } from '../context.svelte.js'
21
+ import { t } from '../i18n.js'
22
+ import type {
23
+ AvailableTransition,
24
+ Issue,
25
+ Comment as IssueComment,
26
+ Priority,
27
+ ResolvedField,
28
+ RichDoc,
29
+ } from '../index.js'
30
+ import { PRIORITY_GROUP_ORDER } from '../index.js'
31
+ import { estimateLabel, estimateUnitOf, priorityLabel } from '../labels.js'
32
+ import { canTracker } from '../permissions.js'
33
+ import { trackerKeys } from '../query.js'
34
+ import { renderDoc } from '../richtext.js'
35
+ import CommentComposer from './CommentComposer.svelte'
36
+ import CommentThread from './CommentThread.svelte'
37
+ import CustomField from './CustomField.svelte'
38
+ import DueDate from './DueDate.svelte'
39
+ import IssueApprovals from './IssueApprovals.svelte'
40
+ import IssueConnections from './IssueConnections.svelte'
41
+ import IssueTime from './IssueTime.svelte'
42
+ import PriorityGlyph from './PriorityGlyph.svelte'
43
+ import StatusIcon from './StatusIcon.svelte'
44
+
45
+ /**
46
+ * The issue detail panel (DESIGN.md 3.13).
47
+ *
48
+ * A 440px panel over the content column rather than a page of its own: you keep your place in the
49
+ * list, and closing it puts you back exactly where you were. Every property is editable in place,
50
+ * and each edit is its own small mutation, so a failed request only rolls back the field it touched.
51
+ */
52
+ interface Props {
53
+ workspaceId: string
54
+ /** the issue key from the URL, so a panel can be linked to and reopened after a reload */
55
+ issueKey: string | null
56
+ onclose: () => void
57
+ /** follow a connection to another issue — the page owns the URL, not this panel */
58
+ onopenissue: (key: string) => void
59
+ }
60
+ let { workspaceId, issueKey, onclose, onopenissue }: Props = $props()
61
+
62
+ /** The built-in properties, in the order the panel showed them before layouts existed. */
63
+ const FALLBACK_SIDEBAR: ResolvedField[] = [
64
+ 'status',
65
+ 'assignees',
66
+ 'priority',
67
+ 'dueDate',
68
+ 'labels',
69
+ 'cycle',
70
+ 'estimate',
71
+ 'project',
72
+ ].map((fieldId, order) => ({
73
+ fieldId,
74
+ kind: 'system' as const,
75
+ label: fieldId,
76
+ section: 'sidebar' as const,
77
+ order,
78
+ required: false,
79
+ pinned: fieldId === 'status',
80
+ showInCards: false,
81
+ field: null,
82
+ }))
83
+
84
+ const api = getTrackerApi()
85
+ const cat = getTrackerCatalogue()
86
+
87
+ let issueFileInput = $state<HTMLInputElement | null>(null)
88
+
89
+ /** Uploads first, then attaches: an attachment points at a file that already exists. */
90
+ async function uploadIssueFiles(list: FileList | null) {
91
+ if (!list?.length) return
92
+ const ids: string[] = []
93
+ for (const file of Array.from(list)) {
94
+ try {
95
+ const uploaded = await uploadFile({
96
+ workspaceId,
97
+ file,
98
+ name: file.name,
99
+ mimeType: file.type || undefined,
100
+ attachedTo: { module: 'tracker', type: 'issue', id: issue?.id as string },
101
+ })
102
+ ids.push(uploaded.id)
103
+ } catch (err) {
104
+ toast.error(err instanceof Error ? err.message : t('attachment_failed'))
105
+ }
106
+ }
107
+ if (ids.length) addIssueFiles.mutate(ids)
108
+ }
109
+ const queryClient = useQueryClient()
110
+
111
+ let editingDescription = $state(false)
112
+ /** Named on screen before it happens, never behind `window.confirm`. */
113
+ let confirmingDelete = $state(false)
114
+ let descriptionDraft = $state<RichDoc | null>(null)
115
+
116
+ const issueQuery = createQuery(() => ({
117
+ queryKey: trackerKeys.issue(workspaceId, issueKey ?? ''),
118
+ queryFn: () => api.issues.getByKey({ workspaceId, key: issueKey as string }),
119
+ enabled: Boolean(issueKey),
120
+ }))
121
+ const issue = $derived(issueQuery.data ?? null)
122
+ const issueId = $derived(issue?.id ?? null)
123
+
124
+ /**
125
+ * What this issue's type says to show, in what order. The panel used to render eight fixed
126
+ * properties; it now renders whatever the type's layout resolves to, with the built-in controls
127
+ * keyed by field id so nothing about them changed.
128
+ */
129
+ const layoutQuery = createQuery(() => ({
130
+ queryKey: trackerKeys.layout(workspaceId, issue?.typeId ?? '', issue?.projectId ?? null),
131
+ queryFn: () =>
132
+ api.types.layout({
133
+ workspaceId,
134
+ id: issue?.typeId as string,
135
+ projectId: issue?.projectId ?? null,
136
+ }),
137
+ enabled: Boolean(issue?.typeId),
138
+ }))
139
+ /**
140
+ * Until the layout arrives, show the built-in properties in their default order rather than an
141
+ * empty panel — the fields are already in `issue`, so a spinner here would be a blank column over
142
+ * data we have.
143
+ */
144
+ const sidebarFields = $derived(layoutQuery.data?.sidebar ?? FALLBACK_SIDEBAR)
145
+ /**
146
+ * Custom fields the type puts in the main column. `title` and `description` are system fields with
147
+ * their own places, so only custom ones need rendering here — without this, moving a field to Main
148
+ * in settings would make it disappear from the issue entirely.
149
+ */
150
+ const mainFields = $derived((layoutQuery.data?.main ?? []).filter((f) => f.kind === 'custom' && f.field))
151
+
152
+ const commentsQuery = createQuery(() => ({
153
+ queryKey: trackerKeys.comments(workspaceId, issueId ?? ''),
154
+ queryFn: () => api.issues.comments.list({ workspaceId, issueId: issueId as string, limit: 50 }),
155
+ enabled: Boolean(issueId),
156
+ }))
157
+
158
+ const historyQuery = createQuery(() => ({
159
+ queryKey: trackerKeys.history(workspaceId, issueId ?? ''),
160
+ queryFn: () => api.issues.history({ workspaceId, issueId: issueId as string, limit: 50 }),
161
+ enabled: Boolean(issueId),
162
+ }))
163
+
164
+ const transitionsQuery = createQuery(() => ({
165
+ queryKey: trackerKeys.transitions(workspaceId, issueId ?? ''),
166
+ queryFn: () => api.issues.transitions.available({ workspaceId, issueId: issueId as string }),
167
+ enabled: Boolean(issueId),
168
+ }))
169
+
170
+ const project = $derived(cat.project(issue?.projectId))
171
+ const status = $derived(cat.status(issue?.statusId))
172
+ const cycle = $derived(cat.cycle(issue?.cycleId))
173
+ const watching = $derived(Boolean(issue && session.user && issue.watcherIds.includes(session.user.id)))
174
+
175
+ // the server enforces these too; here they decide whether a control is offered at all
176
+ const canTriage = $derived(canTracker('triageManage'))
177
+ const canEdit = $derived(canTracker('edit'))
178
+ const canTransition = $derived(canTracker('transition'))
179
+ const canComment = $derived(canTracker('comment'))
180
+
181
+ function invalidate() {
182
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
183
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'transition'] })
184
+ }
185
+
186
+ const patch = createMutation(() => ({
187
+ mutationFn: (input: Record<string, unknown>) =>
188
+ api.issues.update({ workspaceId, issueId: issue?.id as string, patch: input as never }),
189
+ onSuccess: invalidate,
190
+ onError: (error: Error) => toast.error(error.message),
191
+ }))
192
+
193
+ const transition = createMutation(() => ({
194
+ mutationFn: (transitionId: string) =>
195
+ api.issues.transitions.apply({ workspaceId, issueId: issue?.id as string, transitionId }),
196
+ onSuccess: (result) => {
197
+ invalidate()
198
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.history(workspaceId, issue?.id ?? '') })
199
+ // A transition that needs sign-off does not move the issue; it returns the approval it is
200
+ // waiting on. Without refetching approvals — and saying so — the click looks like it did
201
+ // nothing at all.
202
+ if (result.approval) {
203
+ void queryClient.invalidateQueries({
204
+ queryKey: trackerKeys.approvals(workspaceId, issue?.id ?? ''),
205
+ })
206
+ toast.info(t('approval_requested_toast'))
207
+ }
208
+ },
209
+ onError: (error: Error) => toast.error(error.message),
210
+ }))
211
+
212
+ const comment = createMutation(() => ({
213
+ mutationFn: async (input: { body: RichDoc; fileIds: string[]; internal: boolean; parentId?: string }) => {
214
+ const created = await api.issues.comments.create({
215
+ workspaceId,
216
+ issueId: issue?.id as string,
217
+ body: input.body,
218
+ internal: input.internal,
219
+ ...(input.parentId ? { parentId: input.parentId } : {}),
220
+ })
221
+ // The comment has to exist before a file can point at it, so the upload happens in the
222
+ // composer and the attachment is claimed here.
223
+ if (input.fileIds.length)
224
+ await api.issues.attachments.add({
225
+ workspaceId,
226
+ issueId: issue?.id as string,
227
+ fileIds: input.fileIds,
228
+ commentId: created.id,
229
+ })
230
+ return created
231
+ },
232
+ onSuccess: () => {
233
+ refreshComments()
234
+ invalidate()
235
+ },
236
+ onError: (error: Error) => toast.error(error.message),
237
+ }))
238
+
239
+ const attachmentsQuery = createQuery(() => ({
240
+ queryKey: trackerKeys.attachments(workspaceId, issueId ?? ''),
241
+ queryFn: () => api.issues.attachments.list({ workspaceId, issueId: issueId as string }),
242
+ enabled: Boolean(issueId),
243
+ }))
244
+ const attachments = $derived(attachmentsQuery.data ?? [])
245
+ /** Files that arrived with a comment, so each comment can show its own. */
246
+ const attachmentsByComment = $derived.by(() => {
247
+ const byComment = new Map<string, typeof attachments>()
248
+ for (const file of attachments) {
249
+ if (!file.commentId) continue
250
+ byComment.set(file.commentId, [...(byComment.get(file.commentId) ?? []), file])
251
+ }
252
+ return byComment
253
+ })
254
+ const issueAttachments = $derived(attachments.filter((f) => !f.commentId))
255
+
256
+ const refreshComments = () => {
257
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.comments(workspaceId, issue?.id ?? '') })
258
+ void queryClient.invalidateQueries({
259
+ queryKey: trackerKeys.attachments(workspaceId, issue?.id ?? ''),
260
+ })
261
+ }
262
+
263
+ const editComment = createMutation(() => ({
264
+ mutationFn: (input: { commentId: string; body: RichDoc }) =>
265
+ api.issues.comments.update({ workspaceId, ...input }),
266
+ onSuccess: refreshComments,
267
+ onError: (error: Error) => toast.error(error.message),
268
+ }))
269
+
270
+ const deleteComment = createMutation(() => ({
271
+ mutationFn: (commentId: string) => api.issues.comments.delete({ workspaceId, commentId }),
272
+ onSuccess: refreshComments,
273
+ onError: (error: Error) => toast.error(error.message),
274
+ }))
275
+
276
+ /**
277
+ * Triage: what to do with something that arrived from outside.
278
+ *
279
+ * The three actions have existed on the server since intake did and nothing offered them, so an
280
+ * issue raised by email or through the public form sat in triage with no way out but editing its
281
+ * status by hand — which skips the workflow's initial status and the decline comment entirely.
282
+ */
283
+ const triage = createMutation(() => ({
284
+ mutationFn: (input: { action: 'accept' | 'decline' | 'snooze'; until?: string }) => {
285
+ const issueId = issue?.id as string
286
+ if (input.action === 'accept') return api.triage.accept({ workspaceId, issueId })
287
+ if (input.action === 'decline') return api.triage.decline({ workspaceId, issueId })
288
+ return api.triage.snooze({ workspaceId, issueId, until: input.until as string })
289
+ },
290
+ onSuccess: () => {
291
+ invalidate()
292
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.history(workspaceId, issue?.id ?? '') })
293
+ },
294
+ onError: (error: Error) => toast.error(error.message),
295
+ }))
296
+
297
+ /** A week is the interval somebody means by "not now"; anything finer belongs on a date picker. */
298
+ const nextWeek = () => new Date(Date.now() + 7 * 86_400_000).toISOString()
299
+
300
+ /**
301
+ * What happens to an issue as a whole: move it, archive it, delete it.
302
+ *
303
+ * All three had a server and no control, so an issue raised in the wrong project stayed there and
304
+ * a mistake could never be taken back. Archiving is the ordinary one — it leaves the issue and its
305
+ * history intact and off every list — so it is the one offered first; deleting is behind a
306
+ * confirmation that says what goes with it.
307
+ */
308
+ const moveIssue = createMutation(() => ({
309
+ mutationFn: (projectId: string) =>
310
+ api.issues.move({ workspaceId, issueId: issue?.id as string, projectId }),
311
+ onSuccess: (moved: Issue) => {
312
+ invalidate()
313
+ // The key changed with the project, so the panel is now pointed at a key that no longer
314
+ // exists. Following it is the only thing that leaves the screen showing the same issue.
315
+ toast.success(t('issue_moved', { key: moved.key }))
316
+ onopenissue(moved.key)
317
+ },
318
+ onError: (error: Error) => toast.error(error.message),
319
+ }))
320
+
321
+ const archiveIssue = createMutation(() => ({
322
+ mutationFn: (archived: boolean) =>
323
+ api.issues.archive({ workspaceId, issueId: issue?.id as string, archived }),
324
+ onSuccess: () => {
325
+ invalidate()
326
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue', workspaceId] })
327
+ },
328
+ onError: (error: Error) => toast.error(error.message),
329
+ }))
330
+
331
+ const deleteIssue = createMutation(() => ({
332
+ mutationFn: () => api.issues.delete({ workspaceId, issueId: issue?.id as string }),
333
+ onSuccess: () => {
334
+ confirmingDelete = false
335
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue', workspaceId] })
336
+ toast.success(t('issue_deleted'))
337
+ onclose()
338
+ },
339
+ onError: (error: Error) => toast.error(error.message),
340
+ }))
341
+
342
+ const issueMenu = $derived<MenuItem[]>([
343
+ {
344
+ id: 'move',
345
+ label: t('issue_move'),
346
+ icon: 'arrow-right',
347
+ disabled: !canEdit,
348
+ children: cat.projects
349
+ .filter((p) => p.id !== issue?.projectId)
350
+ .map((p) => ({
351
+ id: p.id,
352
+ label: `${p.key} · ${p.name}`,
353
+ onSelect: () => moveIssue.mutate(p.id),
354
+ })),
355
+ },
356
+ {
357
+ id: 'archive',
358
+ label: issue?.archivedAt ? t('issue_unarchive') : t('common.archive'),
359
+ icon: 'archive',
360
+ disabled: !canEdit,
361
+ onSelect: () => archiveIssue.mutate(!issue?.archivedAt),
362
+ },
363
+ { type: 'separator' },
364
+ {
365
+ id: 'delete',
366
+ label: t('common.delete'),
367
+ icon: 'trash-2',
368
+ danger: true,
369
+ disabled: !canEdit,
370
+ onSelect: () => (confirmingDelete = true),
371
+ },
372
+ ])
373
+
374
+ const removeAttachment = createMutation(() => ({
375
+ mutationFn: (attachmentId: string) => api.issues.attachments.remove({ workspaceId, attachmentId }),
376
+ onSuccess: refreshComments,
377
+ onError: (error: Error) => toast.error(error.message),
378
+ }))
379
+
380
+ const addIssueFiles = createMutation(() => ({
381
+ mutationFn: (fileIds: string[]) =>
382
+ api.issues.attachments.add({ workspaceId, issueId: issue?.id as string, fileIds }),
383
+ onSuccess: refreshComments,
384
+ onError: (error: Error) => toast.error(error.message),
385
+ }))
386
+
387
+ const react = createMutation(() => ({
388
+ mutationFn: (input: { commentId: string; emoji: string }) =>
389
+ api.issues.comments.react({ workspaceId, ...input }),
390
+ onSuccess: () =>
391
+ queryClient.invalidateQueries({ queryKey: trackerKeys.comments(workspaceId, issue?.id ?? '') }),
392
+ }))
393
+
394
+ const watch = createMutation(() => ({
395
+ mutationFn: (next: boolean) =>
396
+ next
397
+ ? api.issues.watchers.add({ workspaceId, issueId: issue?.id as string })
398
+ : api.issues.watchers.remove({
399
+ workspaceId,
400
+ issueId: issue?.id as string,
401
+ userId: session.user?.id as string,
402
+ }),
403
+ onSuccess: invalidate,
404
+ }))
405
+
406
+ const statusMenu = $derived<MenuItem[]>(
407
+ (transitionsQuery.data ?? [])
408
+ .filter((tr: AvailableTransition) => !tr.hidden)
409
+ .map((tr: AvailableTransition) => ({
410
+ id: tr.id,
411
+ label: tr.toStatus.name,
412
+ disabled: !tr.allowed,
413
+ hint: tr.allowed ? undefined : (tr.reasons[0]?.message ?? undefined),
414
+ onSelect: () => transition.mutate(tr.id),
415
+ })),
416
+ )
417
+
418
+ const priorityMenu = $derived<MenuItem[]>(
419
+ PRIORITY_GROUP_ORDER.map((p: Priority) => ({
420
+ id: p,
421
+ label: priorityLabel(p),
422
+ icon: issue?.priority === p ? 'check' : undefined,
423
+ onSelect: () => patch.mutate({ priority: p }),
424
+ })),
425
+ )
426
+
427
+ const assigneeMenu = $derived<MenuItem[]>(
428
+ cat.people.map((person) => ({
429
+ type: 'checkbox' as const,
430
+ id: person.id,
431
+ label: person.name,
432
+ // a face is recognised faster than a name, and this menu is how work is handed over
433
+ avatar: { id: person.id, name: person.name, src: person.avatarUrl },
434
+ checked: issue?.assigneeIds.some((id) => id === person.id) ?? false,
435
+ onCheckedChange: (on: boolean) =>
436
+ patch.mutate(on ? { assigneeAdd: [person.id] } : { assigneeRemove: [person.id] }),
437
+ })),
438
+ )
439
+
440
+ const labelMenu = $derived<MenuItem[]>(
441
+ cat.labels.map((label) => ({
442
+ type: 'checkbox' as const,
443
+ id: label.id,
444
+ label: label.name,
445
+ checked: issue?.labelIds.includes(label.id) ?? false,
446
+ onCheckedChange: (on: boolean) =>
447
+ patch.mutate(on ? { labelAdd: [label.id] } : { labelRemove: [label.id] }),
448
+ })),
449
+ )
450
+
451
+ const cycleMenu = $derived<MenuItem[]>([
452
+ { id: 'none', label: t('no_cycle'), onSelect: () => patch.mutate({ cycleId: null }) },
453
+ { type: 'separator' as const },
454
+ ...cat.cycles
455
+ .filter((c) => c.status !== 'completed')
456
+ .map((c) => ({
457
+ id: c.id,
458
+ label: c.name,
459
+ icon: issue?.cycleId === c.id ? 'check' : undefined,
460
+ onSelect: () => patch.mutate({ cycleId: c.id }),
461
+ })),
462
+ ])
463
+
464
+ /**
465
+ * What this project estimates in.
466
+ *
467
+ * A project chooses points or hours — or neither — and the issue keeps the unit it was raised
468
+ * under. The panel said "pts" whatever the project had chosen, and offered to estimate work in a
469
+ * project whose setting is "do not estimate".
470
+ */
471
+ const estimateUnit = $derived(estimateUnitOf(project ?? null))
472
+
473
+ const estimateMenu = $derived<MenuItem[]>([
474
+ { id: 'none', label: t('no_estimate'), onSelect: () => patch.mutate({ estimate: null }) },
475
+ { type: 'separator' as const },
476
+ ...(project?.settings.estimateScale ?? [1, 2, 3, 5, 8]).map((value) => ({
477
+ id: String(value),
478
+ label: estimateLabel(value, estimateUnit),
479
+ icon: issue?.estimate === value ? 'check' : undefined,
480
+ onSelect: () => patch.mutate({ estimate: value }),
481
+ })),
482
+ ])
483
+
484
+ /** Comments and history interleaved, oldest first, the way the design shows the activity feed. */
485
+ /** Replies, grouped under the comment they answer — the feed shows only top-level comments. */
486
+ const repliesOf = $derived.by(() => {
487
+ const byParent = new Map<string, IssueComment[]>()
488
+ for (const c of commentsQuery.data?.items ?? []) {
489
+ if (!c.parentId) continue
490
+ byParent.set(c.parentId, [...(byParent.get(c.parentId) ?? []), c])
491
+ }
492
+ return byParent
493
+ })
494
+
495
+ const activity = $derived.by(() => {
496
+ const all = commentsQuery.data?.items ?? []
497
+ const comments = all
498
+ .filter((c) => !c.parentId)
499
+ .map((c) => ({ kind: 'comment' as const, at: c.createdAt, comment: c }))
500
+ const events = (historyQuery.data?.items ?? []).map((h) => ({
501
+ kind: 'event' as const,
502
+ at: h.occurredAt,
503
+ event: h,
504
+ }))
505
+ return [...comments, ...events].sort((a, b) => a.at.localeCompare(b.at))
506
+ })
507
+
508
+ function startEditingDescription(current: Issue) {
509
+ descriptionDraft = current.description ?? null
510
+ editingDescription = true
511
+ }
512
+
513
+ function saveDescription() {
514
+ /*
515
+ * `$state.snapshot` because the editor's document is a Svelte state proxy, and a proxy cannot be
516
+ * passed to `structuredClone` — which is what the API layer does on the way out. Without it the
517
+ * save threw "could not be cloned" and silently dropped the edit.
518
+ */
519
+ patch.mutate({ description: $state.snapshot(descriptionDraft) as RichDoc | null })
520
+ editingDescription = false
521
+ }
522
+
523
+ function describeEvent(action: string, changes: Array<{ field: string; to: unknown }>): string {
524
+ if (action === 'created') return t('event_created')
525
+ const change = changes[0]
526
+ if (action === 'status_changed' && change) {
527
+ return t('event_status', { status: cat.status(String(change.to))?.name ?? String(change.to) })
528
+ }
529
+ return t('event_updated', { field: change?.field ?? '' })
530
+ }
531
+ </script>
532
+
533
+ <Sheet
534
+ open={issueKey !== null}
535
+ inline
536
+ width={440}
537
+ onOpenChange={(open) => {
538
+ if (!open) onclose()
539
+ }}
540
+ >
541
+ {#snippet header()}
542
+ {#if issue}
543
+ <StatusIcon
544
+ category={issue.statusCategory}
545
+ statusId={issue.statusId}
546
+ name={status?.name}
547
+ size={16}
548
+ />
549
+ <span class="hkey">{issue.key}</span>
550
+ <span class="hstatus">{status?.name ?? ''}</span>
551
+ {/if}
552
+ {/snippet}
553
+
554
+ <!-- The menu belongs beside the close button, not beside the title: a spacer of our own here
555
+ and `Sheet`'s own one split the row and stranded it mid-header. -->
556
+ {#snippet actions()}
557
+ {#if issue}
558
+ <DropdownMenu items={issueMenu} align="end">
559
+ {#snippet trigger(props)}
560
+ <button
561
+ {...props}
562
+ type="button"
563
+ class="hmore"
564
+ aria-label={t('issue_actions')}
565
+ data-testid="issue-actions"
566
+ >
567
+ <Icon name="ellipsis" size={15} strokeWidth={1.8} />
568
+ </button>
569
+ {/snippet}
570
+ </DropdownMenu>
571
+ {/if}
572
+ {/snippet}
573
+
574
+ {#if !issue}
575
+ <div class="loading"><Spinner /></div>
576
+ {:else}
577
+ {#if confirmingDelete}
578
+ <div class="danger" role="alertdialog" aria-label={t('issue_delete_body')}>
579
+ <p>{t('issue_delete_body')}</p>
580
+ <div class="drow">
581
+ <Button
582
+ size="sm"
583
+ variant="danger"
584
+ loading={deleteIssue.isPending}
585
+ onclick={() => deleteIssue.mutate()}
586
+ data-testid="issue-delete-confirm"
587
+ >
588
+ {t('common.delete')}
589
+ </Button>
590
+ <Button size="sm" variant="ghost" onclick={() => (confirmingDelete = false)}>
591
+ {t('common.cancel')}
592
+ </Button>
593
+ </div>
594
+ </div>
595
+ {/if}
596
+
597
+ <h1 class="title">{issue.title}</h1>
598
+
599
+ {#snippet row_status()}
600
+ <dt>{t('detail_status')}</dt>
601
+ <dd>
602
+ <DropdownMenu items={canTransition ? statusMenu : []} align="start">
603
+ {#snippet trigger(props)}
604
+ <button
605
+ {...props}
606
+ type="button"
607
+ class="val"
608
+ class:static={!canTransition}
609
+ disabled={!canTransition}
610
+ title={canTransition ? undefined : t('no_permission')}
611
+ data-testid="status-picker"
612
+ >
613
+ <StatusIcon
614
+ category={issue.statusCategory}
615
+ statusId={issue.statusId}
616
+ name={status?.name}
617
+ size={15}
618
+ />
619
+ {status?.name ?? issue.statusId}
620
+ </button>
621
+ {/snippet}
622
+ </DropdownMenu>
623
+ </dd>
624
+
625
+ {/snippet}
626
+
627
+ {#snippet row_assignees()}
628
+ <dt>{t('detail_assignee')}</dt>
629
+ <dd>
630
+ <DropdownMenu items={canEdit ? assigneeMenu : []} align="start">
631
+ {#snippet trigger(props)}
632
+ <button
633
+ {...props}
634
+ type="button"
635
+ class="val"
636
+ class:static={!canEdit}
637
+ disabled={!canEdit}
638
+ title={canEdit ? undefined : t('no_permission')}>
639
+ {#if issue.assigneeIds.length === 0}
640
+ <span class="muted">{t('unassigned')}</span>
641
+ {:else}
642
+ {#each issue.assigneeIds as id (id)}
643
+ {@const person = cat.person(id)}
644
+ <Avatar name={person?.name} src={person?.avatarUrl} {id} size={22} />
645
+ {/each}
646
+ <span>{cat.person(issue.assigneeIds[0])?.name ?? ''}</span>
647
+ {#if issue.assigneeIds.length > 1}
648
+ <span class="muted">+{issue.assigneeIds.length - 1}</span>
649
+ {/if}
650
+ {/if}
651
+ </button>
652
+ {/snippet}
653
+ </DropdownMenu>
654
+ </dd>
655
+
656
+ {/snippet}
657
+
658
+ {#snippet row_priority()}
659
+ <dt>{t('detail_priority')}</dt>
660
+ <dd>
661
+ <DropdownMenu items={canEdit ? priorityMenu : []} align="start">
662
+ {#snippet trigger(props)}
663
+ <button
664
+ {...props}
665
+ type="button"
666
+ class="val"
667
+ class:static={!canEdit}
668
+ disabled={!canEdit}
669
+ title={canEdit ? undefined : t('no_permission')}>
670
+ <PriorityGlyph priority={issue.priority} size={15} />
671
+ {priorityLabel(issue.priority)}
672
+ </button>
673
+ {/snippet}
674
+ </DropdownMenu>
675
+ </dd>
676
+
677
+ {/snippet}
678
+
679
+ {#snippet row_dueDate()}
680
+ <dt>{t('detail_due')}</dt>
681
+ <dd>
682
+ <label class="val date" class:static={!canEdit}>
683
+ {#if issue.dueDate}
684
+ <DueDate date={issue.dueDate} />
685
+ {:else}
686
+ <span class="muted">{t('no_due')}</span>
687
+ {/if}
688
+ <input
689
+ type="date"
690
+ value={issue.dueDate ?? ''}
691
+ aria-label={t('detail_due')}
692
+ disabled={!canEdit}
693
+ onchange={(e) => patch.mutate({ dueDate: e.currentTarget.value || null })}
694
+ />
695
+ </label>
696
+ </dd>
697
+
698
+ {/snippet}
699
+
700
+ {#snippet row_labels()}
701
+ <dt>{t('detail_labels')}</dt>
702
+ <dd>
703
+ <DropdownMenu items={labelMenu} align="start">
704
+ {#snippet trigger(props)}
705
+ <button {...props} type="button" class="val wrap">
706
+ {#if issue.labelIds.length === 0}
707
+ <span class="muted">{t('no_label')}</span>
708
+ {:else}
709
+ {#each issue.labelIds as id (id)}
710
+ {@const label = cat.label(id)}
711
+ <span class="lbl">
712
+ <span class="swatch" style:background={label?.color ?? 'var(--kern-ink-330)'}></span>
713
+ {label?.name ?? ''}
714
+ </span>
715
+ {/each}
716
+ {/if}
717
+ </button>
718
+ {/snippet}
719
+ </DropdownMenu>
720
+ </dd>
721
+
722
+ {/snippet}
723
+
724
+ {#snippet row_cycle()}
725
+ <dt>{t('detail_cycle')}</dt>
726
+ <dd>
727
+ <DropdownMenu items={canEdit ? cycleMenu : []} align="start">
728
+ {#snippet trigger(props)}
729
+ <button
730
+ {...props}
731
+ type="button"
732
+ class="val"
733
+ class:static={!canEdit}
734
+ disabled={!canEdit}
735
+ title={canEdit ? undefined : t('no_permission')}>
736
+ {#if cycle}{cycle.name}{:else}<span class="muted">{t('no_cycle')}</span>{/if}
737
+ </button>
738
+ {/snippet}
739
+ </DropdownMenu>
740
+ </dd>
741
+
742
+ {/snippet}
743
+
744
+ {#snippet row_estimate()}
745
+ <dt>{t('detail_estimate')}</dt>
746
+ <dd>
747
+ <DropdownMenu items={canEdit ? estimateMenu : []} align="start">
748
+ {#snippet trigger(props)}
749
+ <button
750
+ {...props}
751
+ type="button"
752
+ class="val"
753
+ class:static={!canEdit}
754
+ disabled={!canEdit}
755
+ title={canEdit ? undefined : t('no_permission')}>
756
+ {#if issue.estimate !== null}
757
+ {estimateLabel(issue.estimate, issue.estimateUnit)}
758
+ {:else}
759
+ <span class="muted">{t('no_estimate')}</span>
760
+ {/if}
761
+ </button>
762
+ {/snippet}
763
+ </DropdownMenu>
764
+ </dd>
765
+
766
+ {/snippet}
767
+
768
+ {#snippet row_project()}
769
+ <dt>{t('detail_project')}</dt>
770
+ <dd><span class="val static">{project?.name ?? ''}</span></dd>
771
+ {/snippet}
772
+
773
+ <dl class="props">
774
+ <!-- Dispatched by field id: the layout decides which rows appear and in what order, and each
775
+ built-in control is the same one the panel always had. -->
776
+ {#each sidebarFields as f (f.fieldId)}
777
+ {#if f.fieldId === 'status'}
778
+ {@render row_status()}
779
+ {:else if f.fieldId === 'assignees'}
780
+ {@render row_assignees()}
781
+ {:else if f.fieldId === 'priority'}
782
+ {@render row_priority()}
783
+ {:else if f.fieldId === 'dueDate'}
784
+ {@render row_dueDate()}
785
+ {:else if f.fieldId === 'labels'}
786
+ {@render row_labels()}
787
+ {:else if f.fieldId === 'cycle'}
788
+ {@render row_cycle()}
789
+ {:else if f.fieldId === 'estimate'}
790
+ <!-- A project that estimates in nothing is not offered an estimate to set. -->
791
+ {#if estimateUnit !== 'none' || issue.estimate !== null}
792
+ {@render row_estimate()}
793
+ {/if}
794
+ {:else if f.fieldId === 'project'}
795
+ {@render row_project()}
796
+ {:else if f.kind === 'custom' && f.field}
797
+ <dt>{f.label}</dt>
798
+ <dd>
799
+ <CustomField
800
+ field={f.field}
801
+ value={issue.custom?.[f.field.key] ?? null}
802
+ editable={canEdit}
803
+ required={f.required}
804
+ {workspaceId}
805
+ issueId={issue.id}
806
+ onchange={(value) => patch.mutate({ custom: { [f.field!.key]: value } })}
807
+ />
808
+ </dd>
809
+ {/if}
810
+ {/each}
811
+ </dl>
812
+
813
+ <IssueTime {workspaceId} issueId={issue.id} canLog={canEdit} />
814
+
815
+ <IssueApprovals
816
+ {workspaceId}
817
+ issueId={issue.id}
818
+ oninvalidate={() => {
819
+ invalidate()
820
+ void queryClient.invalidateQueries({
821
+ queryKey: trackerKeys.transitions(workspaceId, issue.id),
822
+ })
823
+ }}
824
+ />
825
+
826
+ <IssueConnections
827
+ {workspaceId}
828
+ {issue}
829
+ {canEdit}
830
+ onopen={onopenissue}
831
+ />
832
+
833
+ {#if issueAttachments.length || canEdit}
834
+ <section class="attachments">
835
+ <div class="ahead">
836
+ <span class="kern-sublabel">{t('attachments')}</span>
837
+ {#if canEdit}
838
+ <input
839
+ bind:this={issueFileInput}
840
+ type="file"
841
+ multiple
842
+ hidden
843
+ onchange={(e) => {
844
+ void uploadIssueFiles(e.currentTarget.files)
845
+ e.currentTarget.value = ''
846
+ }}
847
+ />
848
+ <IconButton
849
+ icon="paperclip"
850
+ size={26}
851
+ label={t('attach_file')}
852
+ onclick={() => issueFileInput?.click()}
853
+ />
854
+ {/if}
855
+ </div>
856
+ {#if issueAttachments.length}
857
+ <ul class="afiles">
858
+ {#each issueAttachments as file (file.id)}
859
+ <li>
860
+ <Icon name="paperclip" size={13} strokeWidth={1.8} />
861
+ <span class="aname" title={file.name}>{file.name}</span>
862
+ <span class="asize">{formatBytes(file.size)}</span>
863
+ {#if canEdit}
864
+ <IconButton
865
+ icon="x"
866
+ size={22}
867
+ label={t('attachment_remove')}
868
+ onclick={() => removeAttachment.mutate(file.id)}
869
+ />
870
+ {/if}
871
+ </li>
872
+ {/each}
873
+ </ul>
874
+ {:else}
875
+ <p class="empty">{t('attachments_empty')}</p>
876
+ {/if}
877
+ </section>
878
+ {/if}
879
+
880
+ {#if issue.triage && canTriage}
881
+ <section class="triage" data-testid="triage">
882
+ <div class="thead">
883
+ <Icon name="inbox" size={14} strokeWidth={1.8} />
884
+ <span>{t('triage_waiting')}</span>
885
+ </div>
886
+ <p class="tnote">{t('triage_hint')}</p>
887
+ <div class="row">
888
+ <Button
889
+ size="sm"
890
+ disabled={triage.isPending}
891
+ onclick={() => triage.mutate({ action: 'accept' })}
892
+ data-testid="triage-accept"
893
+ >
894
+ {t('triage_accept')}
895
+ </Button>
896
+ <Button
897
+ size="sm"
898
+ variant="ghost"
899
+ disabled={triage.isPending}
900
+ onclick={() => triage.mutate({ action: 'snooze', until: nextWeek() })}
901
+ >
902
+ {t('triage_snooze')}
903
+ </Button>
904
+ <Button
905
+ size="sm"
906
+ variant="ghost"
907
+ disabled={triage.isPending}
908
+ onclick={() => triage.mutate({ action: 'decline' })}
909
+ data-testid="triage-decline"
910
+ >
911
+ {t('triage_decline')}
912
+ </Button>
913
+ </div>
914
+ </section>
915
+ {/if}
916
+
917
+ {#if mainFields.length}
918
+ <dl class="props main" data-testid="main-fields">
919
+ {#each mainFields as f (f.fieldId)}
920
+ <dt>{f.label}</dt>
921
+ <dd>
922
+ <CustomField
923
+ field={f.field!}
924
+ value={issue.custom?.[f.field!.key] ?? null}
925
+ editable={canEdit}
926
+ required={f.required}
927
+ {workspaceId}
928
+ issueId={issue.id}
929
+ onchange={(value) => patch.mutate({ custom: { [f.field!.key]: value } })}
930
+ />
931
+ </dd>
932
+ {/each}
933
+ </dl>
934
+ {/if}
935
+
936
+ <section class="desc">
937
+ {#if editingDescription}
938
+ <RichTextEditor
939
+ bind:value={descriptionDraft}
940
+ minRows={6}
941
+ autofocus
942
+ label={t('detail_description')}
943
+ placeholder={t('no_description')}
944
+ onsubmit={saveDescription}
945
+ onescape={() => (editingDescription = false)}
946
+ />
947
+ <div class="row">
948
+ <Button size="sm" onclick={saveDescription}>{t('common.save')}</Button>
949
+ <Button size="sm" variant="ghost" onclick={() => (editingDescription = false)}>
950
+ {t('common.cancel')}
951
+ </Button>
952
+ </div>
953
+ {:else}
954
+ <button
955
+ type="button"
956
+ class="kern-prose prose"
957
+ disabled={!canEdit}
958
+ onclick={() => startEditingDescription(issue)}
959
+ >
960
+ {#if issue.description}
961
+ <!-- rendered from stored ProseMirror JSON; see richtext.ts for the allowed nodes -->
962
+ {@html renderDoc(issue.description)}
963
+ {:else}
964
+ <span class="muted">{t('no_description')}</span>
965
+ {/if}
966
+ </button>
967
+ {/if}
968
+ </section>
969
+
970
+ <section class="activity">
971
+ <div class="arow">
972
+ <span class="kern-sublabel">{t('activity')}</span>
973
+ <span class="sp"></span>
974
+ <button type="button" class="watch" onclick={() => watch.mutate(!watching)}>
975
+ <Icon name={watching ? 'eye' : 'eye-off'} size={13} strokeWidth={1.6} />
976
+ {watching ? t('watching', { count: issue.watcherIds.length }) : t('watch')}
977
+ </button>
978
+ </div>
979
+
980
+ {#each activity as entry (entry.kind === 'comment' ? entry.comment.id : entry.event.id)}
981
+ {#if entry.kind === 'comment'}
982
+ <CommentThread
983
+ {workspaceId}
984
+ comment={entry.comment}
985
+ replies={repliesOf.get(entry.comment.id) ?? []}
986
+ attachments={attachmentsByComment.get(entry.comment.id) ?? []}
987
+ {canComment}
988
+ onreact={(commentId, emoji) => react.mutate({ commentId, emoji })}
989
+ onedit={(commentId, body) => editComment.mutate({ commentId, body: body as RichDoc })}
990
+ ondelete={(commentId) => deleteComment.mutate(commentId)}
991
+ onreply={(parentId, body, fileIds) =>
992
+ comment.mutate({ body: body as RichDoc, fileIds, internal: false, parentId })}
993
+ onremoveAttachment={(attachmentId) => removeAttachment.mutate(attachmentId)}
994
+ />
995
+ {:else}
996
+ {@const person = cat.person(entry.event.actorId)}
997
+ <div class="event">
998
+ <span class="ename">{person?.name ?? t('common.system')}</span>
999
+ {describeEvent(entry.event.action, entry.event.changes)}
1000
+ <time datetime={entry.event.occurredAt}>{relativeTime(entry.event.occurredAt)}</time>
1001
+ </div>
1002
+ {/if}
1003
+ {/each}
1004
+ </section>
1005
+ {/if}
1006
+
1007
+ {#snippet footer()}
1008
+ {#if canComment}
1009
+ <CommentComposer
1010
+ {workspaceId}
1011
+ busy={comment.isPending}
1012
+ onsubmit={(body, fileIds, internal) => comment.mutate({ body, fileIds, internal })}
1013
+ />
1014
+ {:else}
1015
+ <p class="no-comment">{t('no_permission')}</p>
1016
+ {/if}
1017
+ {/snippet}
1018
+ </Sheet>
1019
+
1020
+ <style>
1021
+ .triage {
1022
+ margin-top: 14px;
1023
+ padding: 10px 12px;
1024
+ border: 1px solid var(--kern-warning);
1025
+ border-radius: var(--kern-r-sm);
1026
+ background: var(--kern-warning-tint, var(--kern-shell));
1027
+ }
1028
+ .thead {
1029
+ display: flex;
1030
+ align-items: center;
1031
+ gap: 6px;
1032
+ font-size: 13px;
1033
+ font-weight: 600;
1034
+ }
1035
+ .tnote {
1036
+ margin: 3px 0 8px;
1037
+ font-size: 12px;
1038
+ color: var(--kern-ink-450, var(--kern-ink-400));
1039
+ }
1040
+ .triage .row {
1041
+ display: flex;
1042
+ gap: 6px;
1043
+ }
1044
+ .attachments {
1045
+ margin-top: 16px;
1046
+ }
1047
+ .ahead {
1048
+ display: flex;
1049
+ align-items: center;
1050
+ justify-content: space-between;
1051
+ }
1052
+ .afiles {
1053
+ list-style: none;
1054
+ margin: 6px 0 0;
1055
+ padding: 0;
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ gap: 4px;
1059
+ }
1060
+ .afiles li {
1061
+ display: flex;
1062
+ align-items: center;
1063
+ gap: 6px;
1064
+ font-size: 13px;
1065
+ }
1066
+ .aname {
1067
+ flex: 1;
1068
+ overflow: hidden;
1069
+ text-overflow: ellipsis;
1070
+ white-space: nowrap;
1071
+ }
1072
+ .asize {
1073
+ color: var(--kern-ink-350);
1074
+ font-size: 12px;
1075
+ }
1076
+ .empty,
1077
+ .no-comment {
1078
+ margin: 6px 0 0;
1079
+ font-size: 13px;
1080
+ color: var(--kern-ink-350);
1081
+ }
1082
+ .loading {
1083
+ display: grid;
1084
+ place-items: center;
1085
+ padding: 40px;
1086
+ }
1087
+ .hkey {
1088
+ font-family: var(--kern-font-mono);
1089
+ font-size: 12px;
1090
+ letter-spacing: -0.01em;
1091
+ color: var(--kern-ink-300);
1092
+ }
1093
+ .hstatus {
1094
+ font-size: 13px;
1095
+ color: var(--kern-ink-550);
1096
+ }
1097
+ .hmore {
1098
+ display: grid;
1099
+ place-items: center;
1100
+ width: 24px;
1101
+ height: 24px;
1102
+ border: 0;
1103
+ border-radius: var(--kern-r-sm);
1104
+ background: none;
1105
+ color: var(--kern-ink-500);
1106
+ cursor: pointer;
1107
+ }
1108
+ .hmore:hover {
1109
+ background: var(--kern-surface-hover);
1110
+ }
1111
+ .danger {
1112
+ margin: 0 0 10px;
1113
+ padding: 10px 12px;
1114
+ border: 1px solid var(--kern-danger);
1115
+ border-radius: var(--kern-r-sm);
1116
+ font-size: 12.5px;
1117
+ }
1118
+ .danger p {
1119
+ margin: 0 0 8px;
1120
+ }
1121
+ .drow {
1122
+ display: flex;
1123
+ gap: 8px;
1124
+ }
1125
+ .title {
1126
+ margin: 0;
1127
+ font-size: 19px;
1128
+ font-weight: 600;
1129
+ line-height: 1.32;
1130
+ letter-spacing: -0.02em;
1131
+ color: var(--kern-ink-900);
1132
+ text-wrap: pretty;
1133
+ }
1134
+ .props.main {
1135
+ margin-top: 14px;
1136
+ }
1137
+ .props {
1138
+ display: grid;
1139
+ grid-template-columns: 84px 1fr;
1140
+ align-items: center;
1141
+ gap: 10px 12px;
1142
+ margin: 18px 0 0;
1143
+ font-size: 13px;
1144
+ }
1145
+ .props dt {
1146
+ color: var(--kern-ink-350);
1147
+ }
1148
+ .props dd {
1149
+ margin: 0;
1150
+ min-width: 0;
1151
+ }
1152
+ .val {
1153
+ display: inline-flex;
1154
+ align-items: center;
1155
+ gap: 8px;
1156
+ min-height: 26px;
1157
+ max-width: 100%;
1158
+ padding: 2px 6px;
1159
+ margin-inline-start: -6px;
1160
+ border-radius: var(--kern-r-md);
1161
+ color: var(--kern-ink-700);
1162
+ text-align: start;
1163
+ }
1164
+ .val:not(.static):hover {
1165
+ background: var(--kern-surface-hover);
1166
+ }
1167
+ .val.static,
1168
+ .val:disabled {
1169
+ cursor: default;
1170
+ }
1171
+ .prose:disabled {
1172
+ cursor: default;
1173
+ }
1174
+ .prose:disabled:hover {
1175
+ background: transparent;
1176
+ }
1177
+ .val.wrap {
1178
+ flex-wrap: wrap;
1179
+ }
1180
+ .val.date {
1181
+ position: relative;
1182
+ cursor: pointer;
1183
+ }
1184
+ .val.date input {
1185
+ position: absolute;
1186
+ inset: 0;
1187
+ opacity: 0;
1188
+ cursor: pointer;
1189
+ width: 100%;
1190
+ }
1191
+ .muted {
1192
+ color: var(--kern-ink-350);
1193
+ }
1194
+ .lbl {
1195
+ display: inline-flex;
1196
+ align-items: center;
1197
+ gap: 5px;
1198
+ font-size: 12px;
1199
+ padding: 3px 9px;
1200
+ border-radius: var(--kern-r-md);
1201
+ background: var(--kern-surface-chip);
1202
+ color: var(--kern-ink-520);
1203
+ }
1204
+ .swatch {
1205
+ width: 8px;
1206
+ height: 8px;
1207
+ border-radius: var(--kern-r-full);
1208
+ }
1209
+ .desc {
1210
+ margin-top: 18px;
1211
+ padding-top: 18px;
1212
+ border-top: 1px solid var(--kern-border-hairline);
1213
+ }
1214
+ /* The read view of the description. Everything typographic now comes from
1215
+ `@kernhq/ui/styles/prose.css` — the same sheet the editor wears — so switching between reading
1216
+ and editing does not move a single line. What is left here is the button it happens to be. */
1217
+ .prose {
1218
+ display: block;
1219
+ width: 100%;
1220
+ text-align: start;
1221
+ font-size: 14px;
1222
+ border-radius: var(--kern-r-lg);
1223
+ }
1224
+ .prose:hover {
1225
+ background: var(--kern-surface-hover);
1226
+ }
1227
+ .row {
1228
+ display: flex;
1229
+ gap: 8px;
1230
+ margin-top: 8px;
1231
+ }
1232
+ .activity {
1233
+ margin-top: 22px;
1234
+ padding-top: 16px;
1235
+ border-top: 1px solid var(--kern-border-hairline);
1236
+ }
1237
+ .arow {
1238
+ display: flex;
1239
+ align-items: center;
1240
+ gap: 10px;
1241
+ margin-bottom: 14px;
1242
+ }
1243
+ .sp {
1244
+ flex: 1;
1245
+ }
1246
+ .watch {
1247
+ display: inline-flex;
1248
+ align-items: center;
1249
+ gap: 6px;
1250
+ font-size: 12.5px;
1251
+ color: var(--kern-ink-400);
1252
+ padding: 3px 7px;
1253
+ border-radius: var(--kern-r-md);
1254
+ }
1255
+ .watch:hover {
1256
+ background: var(--kern-surface-hover);
1257
+ color: var(--kern-ink-700);
1258
+ }
1259
+ .thread {
1260
+ display: grid;
1261
+ grid-template-columns: 28px 1fr;
1262
+ gap: 10px;
1263
+ margin-bottom: 16px;
1264
+ }
1265
+ .thead {
1266
+ display: flex;
1267
+ align-items: baseline;
1268
+ gap: 9px;
1269
+ }
1270
+ .author {
1271
+ font-size: 13.5px;
1272
+ font-weight: 500;
1273
+ color: var(--kern-ink-900);
1274
+ }
1275
+ .thead time,
1276
+ .event time {
1277
+ font-size: 12px;
1278
+ color: var(--kern-ink-280);
1279
+ }
1280
+ .tprose {
1281
+ font-size: 13.5px;
1282
+ margin-top: 3px;
1283
+ }
1284
+ .reactions {
1285
+ display: flex;
1286
+ gap: 4px;
1287
+ margin-top: 6px;
1288
+ }
1289
+ .reaction {
1290
+ display: inline-flex;
1291
+ align-items: center;
1292
+ gap: 4px;
1293
+ height: 22px;
1294
+ padding: 0 7px;
1295
+ border-radius: var(--kern-r-md);
1296
+ background: var(--kern-surface-chip);
1297
+ color: var(--kern-ink-520);
1298
+ font-size: 12px;
1299
+ }
1300
+ .reaction.mine {
1301
+ background: var(--kern-accent-tint);
1302
+ color: var(--kern-accent-deep);
1303
+ }
1304
+ .reaction.add {
1305
+ background: transparent;
1306
+ color: var(--kern-ink-350);
1307
+ }
1308
+ .reaction.add:hover {
1309
+ background: var(--kern-surface-hover);
1310
+ }
1311
+ .event {
1312
+ display: flex;
1313
+ align-items: baseline;
1314
+ flex-wrap: wrap;
1315
+ gap: 6px;
1316
+ margin: 0 0 14px 38px;
1317
+ font-size: 12.5px;
1318
+ color: var(--kern-ink-450);
1319
+ }
1320
+ :global([dir='rtl']) .event {
1321
+ margin: 0 38px 14px 0;
1322
+ }
1323
+ .ename {
1324
+ font-weight: 500;
1325
+ color: var(--kern-ink-700);
1326
+ }
1327
+ .reply input {
1328
+ width: 100%;
1329
+ height: 36px;
1330
+ border: 1px solid var(--kern-border-strong);
1331
+ border-radius: var(--kern-r-lg);
1332
+ background: var(--kern-surface-raised);
1333
+ padding: 0 12px;
1334
+ font-size: 13.5px;
1335
+ color: var(--kern-ink-800);
1336
+ }
1337
+ </style>