@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,155 @@
1
+ import type { RichDoc } from './index.js'
2
+
3
+ /**
4
+ * Rendering and editing for the ProseMirror documents that descriptions and comments are stored as.
5
+ *
6
+ * The tracker stores rich text as Tiptap JSON so it can round-trip through the API, search and
7
+ * email without becoming HTML soup. Rendering it here rather than trusting a server-side string
8
+ * keeps the rule simple: only the node and mark types below ever reach the DOM, and every piece of
9
+ * text is escaped, so a pasted `<script>` is text and nothing else.
10
+ *
11
+ * The editor is a plain textarea for now; the surrounding format is already the real one, so
12
+ * swapping in a Tiptap surface later changes the input and nothing that reads it.
13
+ */
14
+
15
+ const ESCAPES: Record<string, string> = {
16
+ '&': '&amp;',
17
+ '<': '&lt;',
18
+ '>': '&gt;',
19
+ '"': '&quot;',
20
+ "'": '&#39;',
21
+ }
22
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ESCAPES[c] as string)
23
+
24
+ /** Only links that navigate somewhere safe survive. */
25
+ function safeHref(href: unknown): string | null {
26
+ if (typeof href !== 'string') return null
27
+ const trimmed = href.trim()
28
+ return /^(https?:|mailto:|\/)/i.test(trimmed) ? trimmed : null
29
+ }
30
+
31
+ interface Node {
32
+ type?: string
33
+ text?: string
34
+ content?: Node[]
35
+ attrs?: Record<string, unknown>
36
+ marks?: Array<{ type?: string; attrs?: Record<string, unknown> }>
37
+ }
38
+
39
+ function renderMarks(html: string, marks: Node['marks']): string {
40
+ let out = html
41
+ for (const mark of marks ?? []) {
42
+ switch (mark.type) {
43
+ case 'bold':
44
+ case 'strong':
45
+ out = `<strong>${out}</strong>`
46
+ break
47
+ case 'italic':
48
+ case 'em':
49
+ out = `<em>${out}</em>`
50
+ break
51
+ case 'code':
52
+ out = `<code>${out}</code>`
53
+ break
54
+ case 'strike':
55
+ out = `<s>${out}</s>`
56
+ break
57
+ case 'link': {
58
+ const href = safeHref(mark.attrs?.href)
59
+ if (href) out = `<a href="${escapeHtml(href)}" rel="noreferrer noopener" target="_blank">${out}</a>`
60
+ break
61
+ }
62
+ default:
63
+ break
64
+ }
65
+ }
66
+ return out
67
+ }
68
+
69
+ function renderNode(node: Node): string {
70
+ if (node.type === 'text') return renderMarks(escapeHtml(node.text ?? ''), node.marks)
71
+ if (node.type === 'mention') {
72
+ // A mention is an inline leaf: it has no children, so the default branch below would render
73
+ // nothing and the mention would simply disappear from the comment.
74
+ const label = String(node.attrs?.label ?? '')
75
+ const id = String(node.attrs?.id ?? '')
76
+ return `<span class="kern-mention" data-user-id="${escapeHtml(id)}">@${escapeHtml(label)}</span>`
77
+ }
78
+ const children = (node.content ?? []).map(renderNode).join('')
79
+ switch (node.type) {
80
+ case 'paragraph':
81
+ return children ? `<p>${children}</p>` : '<p><br></p>'
82
+ case 'heading': {
83
+ const level = Math.min(4, Math.max(2, Number(node.attrs?.level ?? 2)))
84
+ return `<h${level}>${children}</h${level}>`
85
+ }
86
+ case 'bulletList':
87
+ return `<ul>${children}</ul>`
88
+ case 'orderedList':
89
+ return `<ol>${children}</ol>`
90
+ case 'listItem':
91
+ return `<li>${children}</li>`
92
+ case 'blockquote':
93
+ return `<blockquote>${children}</blockquote>`
94
+ case 'codeBlock':
95
+ return `<pre><code>${children}</code></pre>`
96
+ case 'hardBreak':
97
+ return '<br>'
98
+ case 'horizontalRule':
99
+ return '<hr>'
100
+ default:
101
+ return children
102
+ }
103
+ }
104
+
105
+ /** Sanitised HTML for a stored document. Returns an empty string for an empty or missing document. */
106
+ export function renderDoc(doc: RichDoc | null | undefined): string {
107
+ if (!doc) return ''
108
+ return (doc.content ?? []).map((n) => renderNode(n as Node)).join('')
109
+ }
110
+
111
+ /** Plain text, for previews, search and the textarea editor. */
112
+ export function textFromDoc(doc: RichDoc | null | undefined): string {
113
+ if (!doc) return ''
114
+ const walk = (node: Node): string => {
115
+ if (typeof node.text === 'string') return node.text
116
+ // A mention becomes the literal that produced it, so editing a comment round-trips instead of
117
+ // quietly deleting whoever was mentioned.
118
+ if (node.type === 'mention') return `@${String(node.attrs?.label ?? '')}`
119
+ return (node.content ?? []).map(walk).join('')
120
+ }
121
+ return (doc.content ?? [])
122
+ .map((n) => walk(n as Node))
123
+ .join('\n\n')
124
+ .trim()
125
+ }
126
+
127
+ /** Wrap typed text back into a document: blank lines separate paragraphs, single ones break. */
128
+ export function docFromText(text: string): RichDoc | null {
129
+ const trimmed = text.trim()
130
+ if (!trimmed) return null
131
+ const paragraphs = trimmed.split(/\n{2,}/)
132
+ return {
133
+ type: 'doc',
134
+ content: paragraphs.map((para) => ({
135
+ type: 'paragraph',
136
+ content: para
137
+ .split('\n')
138
+ .flatMap((line, i) =>
139
+ i === 0 ? [{ type: 'text', text: line }] : [{ type: 'hardBreak' }, { type: 'text', text: line }],
140
+ )
141
+ .filter((n) => n.type !== 'text' || (n as { text?: string }).text),
142
+ })),
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Whether a document has nothing in it.
148
+ *
149
+ * The editor stores an empty document as `null`, but a document that once had text and was cleared
150
+ * arrives as a doc holding one empty paragraph — perfectly truthy, and the reason a "Send" button
151
+ * that only checked for `null` stayed enabled on an empty comment.
152
+ */
153
+ export function isEmptyDoc(doc: RichDoc | null | undefined): boolean {
154
+ return !doc || textFromDoc(doc).trim() === ''
155
+ }
@@ -0,0 +1,422 @@
1
+ <script lang="ts">
2
+ import { Badge, Button, IconButton, Input, messageLocale, Select, Spinner } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import type { Cycle } from '../index.js'
5
+
6
+ /**
7
+ * A project's cycles — the sprint, whatever a team calls it.
8
+ *
9
+ * Not a `PlanningList` because a cycle is not a name: it is a name and a window, and the window is
10
+ * the whole point. A cycle also has a life — upcoming, active, completed — and the two moves that
11
+ * advance it are the reason the screen exists at all.
12
+ *
13
+ * Completing asks where unfinished work goes rather than deciding quietly. Leaving a cycle is the
14
+ * one moment a team looks at what did not get done, and moving it somewhere they did not choose is
15
+ * how work disappears.
16
+ */
17
+ interface Props {
18
+ cycles: Cycle[]
19
+ loading?: boolean
20
+ editable: boolean
21
+ busy?: boolean
22
+ oncreate: (input: { name: string; startAt: string; endAt: string }) => void
23
+ onupdate: (input: { id: string; name: string; startAt: string; endAt: string }) => void
24
+ onstart: (id: string) => void
25
+ oncomplete: (input: { id: string; rollToCycleId: string | null }) => void
26
+ onremove: (id: string) => void
27
+ }
28
+ let {
29
+ cycles,
30
+ loading = false,
31
+ editable,
32
+ busy = false,
33
+ oncreate,
34
+ onupdate,
35
+ onstart,
36
+ oncomplete,
37
+ onremove,
38
+ }: Props = $props()
39
+
40
+ let name = $state('')
41
+ let startAt = $state('')
42
+ let endAt = $state('')
43
+ let confirmingId = $state<string | null>(null)
44
+ /**
45
+ * A cycle whose window is being corrected.
46
+ *
47
+ * `cycles.update` existed and nothing called it, so a sprint entered with the wrong end date could
48
+ * only be deleted — which takes its issues out of it — and made again.
49
+ */
50
+ let editingId = $state<string | null>(null)
51
+ let editName = $state('')
52
+ let editStart = $state('')
53
+ let editEnd = $state('')
54
+
55
+ /** `<input type="date">` wants `YYYY-MM-DD` in local time; an ISO timestamp's UTC date can be the
56
+ * day before. */
57
+ const dateValue = (iso: string) => {
58
+ const d = new Date(iso)
59
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
60
+ }
61
+
62
+ const beginEdit = (c: Cycle) => {
63
+ editingId = c.id
64
+ editName = c.name
65
+ editStart = dateValue(c.startAt)
66
+ editEnd = dateValue(c.endAt)
67
+ completingId = null
68
+ confirmingId = null
69
+ }
70
+ const editInvalid = $derived(Boolean(editStart && editEnd && editEnd < editStart))
71
+ const commitEdit = () => {
72
+ if (!editingId || !editName.trim() || !editStart || !editEnd || editInvalid) return
73
+ onupdate({
74
+ id: editingId,
75
+ name: editName.trim(),
76
+ startAt: new Date(editStart).toISOString(),
77
+ endAt: new Date(editEnd).toISOString(),
78
+ })
79
+ editingId = null
80
+ }
81
+ /** The cycle being completed, and where its leftovers are headed. */
82
+ let completingId = $state<string | null>(null)
83
+ let rollTo = $state('')
84
+
85
+ const dates = $derived(
86
+ new Intl.DateTimeFormat(messageLocale(), { day: 'numeric', month: 'short', year: 'numeric' }),
87
+ )
88
+ /** `formatRange`, not two dates joined by a dash: in an RTL locale a hand-built range reads
89
+ * backwards, and the shared parts of the two dates are collapsed for free. */
90
+ const range = (c: Cycle) => dates.formatRange(new Date(c.startAt), new Date(c.endAt))
91
+
92
+ /** An end before its start is the one mistake this form can make; it says so instead of failing. */
93
+ const invalidRange = $derived(Boolean(startAt && endAt && endAt < startAt))
94
+ const canAdd = $derived(Boolean(name.trim() && startAt && endAt) && !invalidRange && !busy)
95
+
96
+ const submit = () => {
97
+ if (!canAdd) return
98
+ oncreate({
99
+ name: name.trim(),
100
+ startAt: new Date(startAt).toISOString(),
101
+ endAt: new Date(endAt).toISOString(),
102
+ })
103
+ name = ''
104
+ startAt = ''
105
+ endAt = ''
106
+ }
107
+
108
+ /** Where leftovers could go: an upcoming cycle of the same project, never the one being closed. */
109
+ const rollTargets = $derived(
110
+ cycles
111
+ .filter((c) => c.status === 'upcoming' && c.id !== completingId)
112
+ .map((c) => ({ value: c.id, label: c.name })),
113
+ )
114
+
115
+ const beginComplete = (c: Cycle) => {
116
+ completingId = c.id
117
+ rollTo = ''
118
+ }
119
+
120
+ const statusLabel = (status: Cycle['status']) =>
121
+ status === 'active'
122
+ ? t('cycle_active')
123
+ : status === 'completed'
124
+ ? t('cycle_completed')
125
+ : t('cycle_upcoming')
126
+ </script>
127
+
128
+ <section class="clist" data-testid="planning-cycles">
129
+ <header>
130
+ <h3>{t('planning_cycles')}</h3>
131
+ <p>{t('planning_cycles_hint')}</p>
132
+ </header>
133
+
134
+ {#if loading}
135
+ <div class="state"><Spinner /></div>
136
+ {:else}
137
+ <ul>
138
+ {#each cycles as cycle (cycle.id)}
139
+ <!-- Two lines, not one: a cycle carries a number, a name, a state, a window, its progress
140
+ and what it inherited, and on one line the name is what gets squeezed out. The name is
141
+ the thing being looked for. -->
142
+ <li class="cycle" data-testid="cycle-row">
143
+ <span class="num">{cycle.number}</span>
144
+ <span class="body">
145
+ <span class="top">
146
+ <span class="name" data-item={cycle.name}>{cycle.name}</span>
147
+ <Badge tone={cycle.status === 'completed' ? 'done' : cycle.status}>
148
+ {statusLabel(cycle.status)}
149
+ </Badge>
150
+ </span>
151
+ <span class="meta">
152
+ <span>{range(cycle)}</span>
153
+ <span>{t('planning_done_of', { done: cycle.stats.done, total: cycle.stats.total })}</span>
154
+ {#if cycle.carryOverCount > 0}
155
+ <!-- Where a cycle's work came from is worth as much as where it went: a cycle that
156
+ is mostly last cycle's leftovers is the thing a team needs to see. -->
157
+ <span>{t('cycle_carried', { count: cycle.carryOverCount })}</span>
158
+ {/if}
159
+ </span>
160
+ </span>
161
+ {#if editable && cycle.status !== 'completed'}
162
+ <Button size="sm" variant="ghost" onclick={() => beginEdit(cycle)} data-testid="cycle-edit">
163
+ {t('common.edit')}
164
+ </Button>
165
+ {/if}
166
+ {#if editable && cycle.status === 'upcoming'}
167
+ <Button size="sm" variant="ghost" onclick={() => onstart(cycle.id)} data-testid="cycle-start">
168
+ {t('cycle_start')}
169
+ </Button>
170
+ {/if}
171
+ {#if editable && cycle.status === 'active'}
172
+ <Button
173
+ size="sm"
174
+ variant="ghost"
175
+ onclick={() => beginComplete(cycle)}
176
+ data-testid="cycle-complete"
177
+ >
178
+ {t('cycle_complete')}
179
+ </Button>
180
+ {/if}
181
+ {#if editable}
182
+ <IconButton
183
+ icon="x"
184
+ size={22}
185
+ label={t('planning_remove', { name: cycle.name })}
186
+ onclick={() => (confirmingId = cycle.id)}
187
+ />
188
+ {/if}
189
+ </li>
190
+
191
+ {#if editingId === cycle.id}
192
+ <li class="panel" data-testid="cycle-edit-panel">
193
+ <form class="add" onsubmit={(e) => { e.preventDefault(); commitEdit() }}>
194
+ <span class="wide">
195
+ <Input bind:value={editName} data-testid="cycle-edit-name" />
196
+ </span>
197
+ <input
198
+ class="date"
199
+ type="date"
200
+ bind:value={editStart}
201
+ aria-label={t('cycle_starts')}
202
+ data-testid="cycle-edit-start"
203
+ />
204
+ <input
205
+ class="date"
206
+ type="date"
207
+ bind:value={editEnd}
208
+ aria-label={t('cycle_ends')}
209
+ data-testid="cycle-edit-end"
210
+ />
211
+ <Button
212
+ size="sm"
213
+ disabled={!editName.trim() || !editStart || !editEnd || editInvalid}
214
+ onclick={commitEdit}
215
+ data-testid="cycle-edit-save"
216
+ >
217
+ {t('common.save')}
218
+ </Button>
219
+ </form>
220
+ {#if editInvalid}
221
+ <p class="warn">{t('cycle_range_error')}</p>
222
+ {/if}
223
+ <div class="row">
224
+ <Button size="sm" variant="ghost" onclick={() => (editingId = null)}>{t('common.cancel')}</Button>
225
+ </div>
226
+ </li>
227
+ {/if}
228
+
229
+ {#if completingId === cycle.id}
230
+ <li class="panel" data-testid="cycle-complete-panel">
231
+ <p>{t('cycle_complete_body', { name: cycle.name })}</p>
232
+ <div class="row">
233
+ <span data-testid="cycle-roll">
234
+ <Select
235
+ value={rollTo}
236
+ options={[{ value: '', label: t('cycle_roll_backlog') }, ...rollTargets]}
237
+ onValueChange={(v: string) => (rollTo = v)}
238
+ />
239
+ </span>
240
+ <Button
241
+ size="sm"
242
+ onclick={() => {
243
+ oncomplete({ id: cycle.id, rollToCycleId: rollTo || null })
244
+ completingId = null
245
+ }}
246
+ data-testid="cycle-complete-confirm"
247
+ >
248
+ {t('cycle_complete')}
249
+ </Button>
250
+ <Button size="sm" variant="ghost" onclick={() => (completingId = null)}>{t('common.cancel')}</Button>
251
+ </div>
252
+ </li>
253
+ {/if}
254
+
255
+ {#if confirmingId === cycle.id}
256
+ <li class="panel danger" role="alertdialog">
257
+ <span>{t('planning_remove_body', { name: cycle.name })}</span>
258
+ <div class="row">
259
+ <Button
260
+ size="sm"
261
+ variant="danger"
262
+ onclick={() => {
263
+ onremove(cycle.id)
264
+ confirmingId = null
265
+ }}
266
+ >
267
+ {t('common.delete')}
268
+ </Button>
269
+ <Button size="sm" variant="ghost" onclick={() => (confirmingId = null)}>{t('common.cancel')}</Button>
270
+ </div>
271
+ </li>
272
+ {/if}
273
+ {:else}
274
+ <li class="empty-row">{t('planning_cycles_empty')}</li>
275
+ {/each}
276
+ </ul>
277
+
278
+ {#if editable}
279
+ <!-- The name spans the row and the two dates sit side by side, because a start and an end
280
+ that land on different lines stop reading as one window. -->
281
+ <form class="add" onsubmit={(e) => { e.preventDefault(); submit() }}>
282
+ <span class="wide">
283
+ <Input bind:value={name} placeholder={t('planning_cycle_add')} data-testid="cycle-name" />
284
+ </span>
285
+ <input
286
+ class="date"
287
+ type="date"
288
+ bind:value={startAt}
289
+ aria-label={t('cycle_starts')}
290
+ data-testid="cycle-start-date"
291
+ />
292
+ <input
293
+ class="date"
294
+ type="date"
295
+ bind:value={endAt}
296
+ aria-label={t('cycle_ends')}
297
+ data-testid="cycle-end-date"
298
+ />
299
+ <Button size="sm" disabled={!canAdd} onclick={submit} data-testid="cycle-add">{t('common.add')}</Button>
300
+ </form>
301
+ {#if invalidRange}
302
+ <p class="warn" data-testid="cycle-range-error">{t('cycle_range_error')}</p>
303
+ {/if}
304
+ {/if}
305
+ {/if}
306
+ </section>
307
+
308
+ <style>
309
+ .clist {
310
+ padding: 14px 0;
311
+ border-bottom: 1px solid var(--kern-border-hairline);
312
+ }
313
+ h3 {
314
+ margin: 0;
315
+ font-size: 13px;
316
+ font-weight: 600;
317
+ }
318
+ header p {
319
+ margin: 2px 0 0;
320
+ font-size: 12px;
321
+ color: var(--kern-ink-400);
322
+ }
323
+ ul {
324
+ list-style: none;
325
+ margin: 8px 0 0;
326
+ padding: 0;
327
+ }
328
+ li {
329
+ display: flex;
330
+ align-items: center;
331
+ gap: 8px;
332
+ padding: 4px 0;
333
+ }
334
+ .cycle {
335
+ align-items: flex-start;
336
+ padding: 7px 0;
337
+ }
338
+ .num {
339
+ min-width: 20px;
340
+ padding-top: 2px;
341
+ font-size: 12px;
342
+ font-variant-numeric: tabular-nums;
343
+ color: var(--kern-ink-400);
344
+ }
345
+ .body {
346
+ flex: 1;
347
+ min-width: 0;
348
+ }
349
+ .top {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: 8px;
353
+ }
354
+ .name {
355
+ min-width: 0;
356
+ font-size: 13px;
357
+ overflow: hidden;
358
+ text-overflow: ellipsis;
359
+ white-space: nowrap;
360
+ }
361
+ .meta {
362
+ display: flex;
363
+ flex-wrap: wrap;
364
+ gap: 4px 10px;
365
+ margin-top: 2px;
366
+ font-size: 12px;
367
+ color: var(--kern-ink-400);
368
+ }
369
+ .panel {
370
+ display: block;
371
+ padding: 8px 0 10px;
372
+ font-size: 12.5px;
373
+ }
374
+ .panel p {
375
+ margin: 0 0 6px;
376
+ }
377
+ .panel.danger {
378
+ color: var(--kern-danger);
379
+ }
380
+ .row {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 8px;
384
+ flex-wrap: wrap;
385
+ }
386
+ .empty-row {
387
+ font-size: 13px;
388
+ color: var(--kern-ink-400);
389
+ }
390
+ .warn {
391
+ margin: 6px 0 0;
392
+ font-size: 12px;
393
+ color: var(--kern-danger);
394
+ }
395
+ .add {
396
+ display: grid;
397
+ grid-template-columns: 1fr 1fr auto;
398
+ align-items: center;
399
+ gap: 8px;
400
+ margin-top: 10px;
401
+ }
402
+ .wide {
403
+ grid-column: 1 / -1;
404
+ }
405
+ .date {
406
+ /* the whole field opens a picker; a date input does not take free text, so it is not a caret */
407
+ cursor: pointer;
408
+ width: 100%;
409
+ padding: 4px 8px;
410
+ border: 1px solid var(--kern-border);
411
+ border-radius: var(--kern-r-sm);
412
+ background: var(--kern-surface);
413
+ color: inherit;
414
+ font: inherit;
415
+ font-size: 13px;
416
+ }
417
+ .state {
418
+ display: grid;
419
+ place-items: center;
420
+ padding: 18px;
421
+ }
422
+ </style>