@kernhq/module-tracker 0.10.1 → 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 (83) 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/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,342 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DropdownMenu,
6
+ EmptyState,
7
+ IconButton,
8
+ Input,
9
+ type MenuItem,
10
+ messageLocale,
11
+ Textarea,
12
+ toast,
13
+ } from '@kernhq/ui'
14
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
15
+ import { getTrackerApi } from '../api-instance.js'
16
+ import { t } from '../i18n.js'
17
+ import type { Milestone } from '../index.js'
18
+ import { projectKql, trackerHref } from '../nav.js'
19
+ import { canTracker } from '../permissions.js'
20
+ import { trackerKeys } from '../query.js'
21
+ import { useRouteProject } from './context.svelte.js'
22
+ import PlanCard from './PlanCard.svelte'
23
+ import ProjectShell from './ProjectShell.svelte'
24
+
25
+ /**
26
+ * A project's milestones (DESIGN.md 3.5).
27
+ *
28
+ * What a stretch of work is aiming at, and how far along it is — a date, a goal in a sentence, and
29
+ * the count of what is done against what is in it. The date is the point of a milestone and the
30
+ * settings screen never offered one, so every milestone in the workspace was undated until now.
31
+ *
32
+ * A milestone opens as the work inside it: the card is a heading, the issues are the thing.
33
+ */
34
+ const api = getTrackerApi()
35
+ const queryClient = useQueryClient()
36
+
37
+ const at = useRouteProject()
38
+ const slug = $derived(at.slug)
39
+ const workspaceId = $derived(at.workspaceId)
40
+ const projectId = $derived(at.projectId)
41
+ const canManage = $derived(canTracker('projectManage'))
42
+ const milestonesQuery = createQuery(() => ({
43
+ queryKey: trackerKeys.milestones(workspaceId, projectId),
44
+ queryFn: () => api.milestones.list({ workspaceId, projectId }),
45
+ enabled: Boolean(projectId),
46
+ }))
47
+ const milestones = $derived(milestonesQuery.data ?? [])
48
+ const openCount = $derived(milestones.filter((ms) => ms.status !== 'completed').length)
49
+
50
+ const invalidate = () => {
51
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'milestone'] })
52
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
53
+ }
54
+ const fail = (error: Error) => toast.error(error.message)
55
+
56
+ /** One dialog for making and for correcting: the fields are the same, and so is what they mean. */
57
+ let editing = $state<Milestone | null>(null)
58
+ let creating = $state(false)
59
+ let draftName = $state('')
60
+ let draftDate = $state('')
61
+ let draftGoal = $state('')
62
+ /** Named on screen before it happens, never straight off a menu. */
63
+ let confirming = $state<Milestone | null>(null)
64
+
65
+ const open = (milestone: Milestone | null) => {
66
+ editing = milestone
67
+ creating = milestone === null
68
+ draftName = milestone?.name ?? ''
69
+ draftDate = milestone?.targetDate ?? ''
70
+ draftGoal = milestone?.description ?? ''
71
+ }
72
+ const close = () => {
73
+ editing = null
74
+ creating = false
75
+ }
76
+
77
+ const save = createMutation(() => ({
78
+ mutationFn: () => {
79
+ const patch = {
80
+ name: draftName.trim(),
81
+ targetDate: draftDate || null,
82
+ description: draftGoal.trim() || null,
83
+ }
84
+ return editing
85
+ ? api.milestones.update({ workspaceId, id: editing.id, patch })
86
+ : api.milestones.create({ workspaceId, projectId, ...patch })
87
+ },
88
+ onSuccess: () => {
89
+ close()
90
+ invalidate()
91
+ },
92
+ onError: fail,
93
+ }))
94
+
95
+ const setStatus = createMutation(() => ({
96
+ mutationFn: (input: { id: string; status: 'open' | 'completed' }) =>
97
+ api.milestones.update({ workspaceId, id: input.id, patch: { status: input.status } }),
98
+ onSuccess: invalidate,
99
+ onError: fail,
100
+ }))
101
+
102
+ const remove = createMutation(() => ({
103
+ mutationFn: (id: string) => api.milestones.delete({ workspaceId, id }),
104
+ onSuccess: () => {
105
+ confirming = null
106
+ invalidate()
107
+ },
108
+ onError: fail,
109
+ }))
110
+
111
+ const dateFormat = $derived(
112
+ new Intl.DateTimeFormat(messageLocale(), { day: 'numeric', month: 'short', year: 'numeric' }),
113
+ )
114
+ const when = (milestone: Milestone) =>
115
+ milestone.targetDate ? dateFormat.format(new Date(milestone.targetDate)) : t('milestone_no_date')
116
+
117
+ const toneOf = (milestone: Milestone) =>
118
+ milestone.status === 'completed' ? 'done' : milestone.stats.done > 0 ? 'running' : 'planned'
119
+ const stateOf = (milestone: Milestone) =>
120
+ milestone.status === 'completed'
121
+ ? t('milestone_completed')
122
+ : milestone.stats.done > 0
123
+ ? t('cycle_active')
124
+ : t('cycle_upcoming')
125
+
126
+ /** The work in one milestone, as the query the issue list already understands. */
127
+ const issuesHref = (milestone: Milestone) =>
128
+ trackerHref(slug, { q: `${projectKql(projectId)} and milestone = ${JSON.stringify(milestone.id)}` })
129
+
130
+ const menuFor = (milestone: Milestone): MenuItem[] => [
131
+ {
132
+ type: 'item',
133
+ id: 'issues',
134
+ label: t('milestone_issues'),
135
+ icon: 'list',
136
+ href: issuesHref(milestone),
137
+ },
138
+ ...(canManage
139
+ ? [
140
+ { type: 'separator' as const },
141
+ {
142
+ type: 'item' as const,
143
+ id: 'edit',
144
+ label: t('common.edit'),
145
+ icon: 'pencil',
146
+ onSelect: () => open(milestone),
147
+ },
148
+ {
149
+ type: 'item' as const,
150
+ id: 'status',
151
+ label: milestone.status === 'completed' ? t('milestone_reopen') : t('milestone_complete'),
152
+ icon: milestone.status === 'completed' ? 'rotate-ccw' : 'check',
153
+ onSelect: () =>
154
+ setStatus.mutate({
155
+ id: milestone.id,
156
+ status: milestone.status === 'completed' ? 'open' : 'completed',
157
+ }),
158
+ },
159
+ {
160
+ type: 'item' as const,
161
+ id: 'delete',
162
+ label: t('common.delete'),
163
+ icon: 'trash-2',
164
+ danger: true,
165
+ onSelect: () => (confirming = milestone),
166
+ },
167
+ ]
168
+ : []),
169
+ ]
170
+ </script>
171
+
172
+ <ProjectShell
173
+ project={at.project}
174
+ pending={at.pending}
175
+ {slug}
176
+ projectKey={at.projectKey}
177
+ title={t('planning_milestones')}
178
+ subtitle={t('milestones_count', { open: openCount, total: milestones.length })}
179
+ >
180
+ {#snippet headerActions()}
181
+ {#if canManage}
182
+ <Button size="sm" icon="plus" onclick={() => open(null)} data-testid="milestone-new">
183
+ {t('milestone_new')}
184
+ </Button>
185
+ {/if}
186
+ {/snippet}
187
+
188
+ {#snippet children()}
189
+ {#if milestonesQuery.isPending}
190
+ <p class="quiet">{t('common.loading')}</p>
191
+ {:else if !milestones.length}
192
+ <EmptyState
193
+ icon="flag"
194
+ title={t('planning_milestones_empty')}
195
+ description={t('planning_milestones_hint')}
196
+ >
197
+ {#snippet actions()}
198
+ {#if canManage}
199
+ <Button size="sm" icon="plus" onclick={() => open(null)}>{t('milestone_new')}</Button>
200
+ {/if}
201
+ {/snippet}
202
+ </EmptyState>
203
+ {:else}
204
+ <div class="cards">
205
+ {#each milestones as milestone (milestone.id)}
206
+ <PlanCard
207
+ icon="flag"
208
+ tone={toneOf(milestone)}
209
+ name={milestone.name}
210
+ href={issuesHref(milestone)}
211
+ state={stateOf(milestone)}
212
+ when={when(milestone)}
213
+ goal={milestone.description}
214
+ progress={{ done: milestone.stats.done, total: milestone.stats.total }}
215
+ >
216
+ {#snippet actions()}
217
+ <DropdownMenu items={menuFor(milestone)} align="end">
218
+ {#snippet trigger(props)}
219
+ <IconButton
220
+ {...props}
221
+ icon="ellipsis"
222
+ size={26}
223
+ label={t('milestone_actions', { name: milestone.name })}
224
+ />
225
+ {/snippet}
226
+ </DropdownMenu>
227
+ {/snippet}
228
+ </PlanCard>
229
+ {/each}
230
+ </div>
231
+ {/if}
232
+ {/snippet}
233
+ </ProjectShell>
234
+
235
+ <Dialog
236
+ open={creating || editing !== null}
237
+ title={editing ? t('milestone_edit') : t('milestone_new')}
238
+ size="sm"
239
+ onOpenChange={(next: boolean) => {
240
+ if (!next) close()
241
+ }}
242
+ >
243
+ <div class="form">
244
+ <label class="row">
245
+ <span class="lbl">{t('project_name')}</span>
246
+ <Input bind:value={draftName} data-testid="milestone-name" />
247
+ </label>
248
+ <label class="row">
249
+ <span class="lbl">{t('milestone_date')}</span>
250
+ <!-- A date input, because a milestone without one is a wish. -->
251
+ <input class="date" type="date" bind:value={draftDate} data-testid="milestone-date" />
252
+ </label>
253
+ <label class="row">
254
+ <span class="lbl">{t('milestone_goal')}</span>
255
+ <Textarea bind:value={draftGoal} rows={3} data-testid="milestone-goal" />
256
+ </label>
257
+ </div>
258
+
259
+ {#snippet footer()}
260
+ <Button variant="ghost" size="sm" onclick={close}>{t('common.cancel')}</Button>
261
+ <Button
262
+ size="sm"
263
+ disabled={!draftName.trim()}
264
+ loading={save.isPending}
265
+ onclick={() => save.mutate()}
266
+ data-testid="milestone-save"
267
+ >
268
+ {t('common.save')}
269
+ </Button>
270
+ {/snippet}
271
+ </Dialog>
272
+
273
+ <Dialog
274
+ open={confirming !== null}
275
+ title={t('planning_remove', { name: confirming?.name ?? '' })}
276
+ size="sm"
277
+ onOpenChange={(next: boolean) => {
278
+ if (!next) confirming = null
279
+ }}
280
+ >
281
+ <p class="body">{t('planning_remove_body', { name: confirming?.name ?? '' })}</p>
282
+
283
+ {#snippet footer()}
284
+ <Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
285
+ <Button
286
+ size="sm"
287
+ variant="danger"
288
+ loading={remove.isPending}
289
+ onclick={() => confirming && remove.mutate(confirming.id)}
290
+ data-testid="milestone-delete-confirm"
291
+ >
292
+ {t('common.delete')}
293
+ </Button>
294
+ {/snippet}
295
+ </Dialog>
296
+
297
+ <style>
298
+ .cards {
299
+ display: flex;
300
+ flex-direction: column;
301
+ gap: 10px;
302
+ }
303
+ .quiet {
304
+ margin: 0;
305
+ font-size: 13px;
306
+ color: var(--kern-ink-400);
307
+ }
308
+ .form {
309
+ display: grid;
310
+ gap: 12px;
311
+ }
312
+ .row {
313
+ display: grid;
314
+ gap: 4px;
315
+ }
316
+ .lbl {
317
+ font-size: 12px;
318
+ color: var(--kern-ink-550);
319
+ }
320
+ /* The one native control the kit does not wrap; it borrows the input's shape. */
321
+ .date {
322
+ /* the whole field opens a picker; a date input does not take free text, so it is not a caret */
323
+ cursor: pointer;
324
+ height: 36px;
325
+ padding: 0 12px;
326
+ border: 1px solid var(--kern-border-strong);
327
+ border-radius: var(--kern-r-lg);
328
+ background: var(--kern-surface-raised);
329
+ color: var(--kern-ink-800);
330
+ font: inherit;
331
+ font-size: 13.5px;
332
+ }
333
+ .date:focus-visible {
334
+ outline: none;
335
+ border-color: var(--kern-accent);
336
+ box-shadow: 0 0 0 3px var(--kern-ring);
337
+ }
338
+ .body {
339
+ margin: 0;
340
+ font-size: 13px;
341
+ }
342
+ </style>
@@ -0,0 +1,191 @@
1
+ <script lang="ts">
2
+ import { Icon } from '@kernhq/ui'
3
+ import type { Snippet } from 'svelte'
4
+
5
+ /**
6
+ * A milestone or a cycle, as a card (DESIGN.md 3.5).
7
+ *
8
+ * Both are a stretch of work with a name, a window, something it is aiming at and how far along it
9
+ * is — so they are drawn the same way, and the tone says which state it is in: blue while it is
10
+ * running, green when it is finished, grey while it is still ahead.
11
+ */
12
+ export type PlanTone = 'running' | 'done' | 'planned'
13
+
14
+ interface Props {
15
+ icon: string
16
+ tone: PlanTone
17
+ name: string
18
+ /** the state, in words: "In progress", "Reached", "Upcoming" */
19
+ state: string
20
+ /** the window or the target date, already formatted */
21
+ when?: string | null
22
+ goal?: string | null
23
+ /** how much of it is finished */
24
+ progress?: { done: number; total: number } | null
25
+ meta?: Snippet
26
+ actions?: Snippet
27
+ href?: string
28
+ }
29
+ let {
30
+ icon,
31
+ tone,
32
+ name,
33
+ state,
34
+ when = null,
35
+ goal = null,
36
+ progress = null,
37
+ meta,
38
+ actions,
39
+ href,
40
+ }: Props = $props()
41
+
42
+ const percent = $derived(
43
+ progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0,
44
+ )
45
+ </script>
46
+
47
+ <article class="card t-{tone}" data-testid="plan-card" data-item={name}>
48
+ <header>
49
+ <Icon name={icon} size={16} strokeWidth={1.7} class="flag" />
50
+ {#if href}
51
+ <a class="name" {href}>{name}</a>
52
+ {:else}
53
+ <span class="name">{name}</span>
54
+ {/if}
55
+ <span class="state">{state}</span>
56
+ <span class="sp"></span>
57
+ {#if when}<span class="when">{when}</span>{/if}
58
+ {#if actions}<span class="acts">{@render actions()}</span>{/if}
59
+ </header>
60
+
61
+ {#if goal}<p class="goal">{goal}</p>{/if}
62
+
63
+ {#if progress}
64
+ <div class="measure">
65
+ <div class="track"><span class="fill" style:width="{percent}%"></span></div>
66
+ <span class="pct">{progress.done}/{progress.total} · {percent}%</span>
67
+ </div>
68
+ {/if}
69
+
70
+ {#if meta}<div class="meta">{@render meta()}</div>{/if}
71
+ </article>
72
+
73
+ <style>
74
+ /* DESIGN.md 3.5 — white card, 10px radius, 16/18 padding, stacked with a 10px gap. */
75
+ .card {
76
+ background: var(--kern-surface-raised);
77
+ border: 1px solid var(--kern-border);
78
+ border-radius: var(--kern-r-2xl);
79
+ padding: 16px 18px;
80
+ }
81
+ header {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 10px;
85
+ min-width: 0;
86
+ }
87
+ header :global(.flag) {
88
+ flex: none;
89
+ color: var(--kern-neutral-flag);
90
+ }
91
+ .t-running header :global(.flag) {
92
+ color: var(--kern-info-bar);
93
+ }
94
+ .t-done header :global(.flag) {
95
+ color: var(--kern-success-chip);
96
+ }
97
+ .name {
98
+ font-size: 15px;
99
+ font-weight: 500;
100
+ color: var(--kern-ink-900);
101
+ text-decoration: none;
102
+ min-width: 0;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ }
107
+ a.name:hover {
108
+ text-decoration: underline;
109
+ }
110
+ .state {
111
+ flex: none;
112
+ display: inline-flex;
113
+ align-items: center;
114
+ height: 22px;
115
+ padding: 0 9px;
116
+ border-radius: var(--kern-r-md);
117
+ font-size: 12px;
118
+ background: var(--kern-surface-chip);
119
+ color: var(--kern-ink-450);
120
+ white-space: nowrap;
121
+ }
122
+ .t-running .state {
123
+ background: var(--kern-info-tint);
124
+ color: var(--kern-info);
125
+ }
126
+ .t-done .state {
127
+ background: var(--kern-success-tint);
128
+ color: var(--kern-success-chip);
129
+ }
130
+ .sp {
131
+ flex: 1;
132
+ }
133
+ .when {
134
+ flex: none;
135
+ font-size: 12.5px;
136
+ color: var(--kern-ink-350);
137
+ white-space: nowrap;
138
+ }
139
+ .acts {
140
+ flex: none;
141
+ display: inline-flex;
142
+ align-items: center;
143
+ gap: 2px;
144
+ }
145
+ .goal {
146
+ margin: 8px 0 0;
147
+ font-size: 13.5px;
148
+ line-height: 1.5;
149
+ color: var(--kern-ink-500);
150
+ }
151
+ .measure {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 12px;
155
+ margin-top: 14px;
156
+ }
157
+ .track {
158
+ flex: 1;
159
+ height: 6px;
160
+ border-radius: var(--kern-r-full);
161
+ background: var(--kern-border-strong);
162
+ overflow: hidden;
163
+ }
164
+ .fill {
165
+ display: block;
166
+ height: 100%;
167
+ border-radius: var(--kern-r-full);
168
+ background: var(--kern-info-bar);
169
+ transition: width var(--kern-dur-fast) var(--kern-ease-out);
170
+ }
171
+ .t-done .fill {
172
+ background: var(--kern-success-chip);
173
+ }
174
+ .t-planned .fill {
175
+ background: var(--kern-ink-330);
176
+ }
177
+ .pct {
178
+ width: 92px;
179
+ text-align: end;
180
+ font-size: 12.5px;
181
+ color: var(--kern-ink-450);
182
+ white-space: nowrap;
183
+ }
184
+ .meta {
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 6px;
188
+ flex-wrap: wrap;
189
+ margin-top: 12px;
190
+ }
191
+ </style>
@@ -0,0 +1,93 @@
1
+ <script lang="ts">
2
+ import { EmptyState, PageHeader, Skeleton, session } from '@kernhq/ui'
3
+ import type { Snippet } from 'svelte'
4
+ import { t } from '../i18n.js'
5
+ import type { Project } from '../index.js'
6
+ import { projectTargets, trackerHref } from '../nav.js'
7
+
8
+ /**
9
+ * The frame every one of a project's pages shares.
10
+ *
11
+ * Each of them is its own screen — components, milestones, cycles, templates — rather than tabs
12
+ * over one, because they are different work: you come to the milestones page to see where a release
13
+ * stands, and to the components page to name a part of the system. What they share is how you know
14
+ * where you are, and the way back to the work itself (DESIGN.md 2.4).
15
+ */
16
+ interface Props {
17
+ project: Project | null
18
+ pending: boolean
19
+ slug: string
20
+ /** what the URL asked for, so a stale link can say what it was looking for */
21
+ projectKey: string
22
+ /** the page's own title, e.g. "Milestones" */
23
+ title: string
24
+ /** the mono line under it: what this page counts */
25
+ subtitle?: string | null
26
+ /** the page's primary action, shown once the project is known */
27
+ headerActions?: Snippet
28
+ children: Snippet
29
+ }
30
+ let { project, pending, slug, projectKey, title, subtitle = null, headerActions, children }: Props = $props()
31
+
32
+ const workspaceName = $derived(session.workspaces.find((w) => w.slug === slug)?.name ?? '')
33
+ </script>
34
+
35
+ <svelte:head><title>{title} · {project?.name ?? ''} · Kern</title></svelte:head>
36
+
37
+ <div class="kproj">
38
+ <PageHeader
39
+ crumbs={[
40
+ { label: workspaceName },
41
+ { label: t('title'), href: `/${slug}/tracker` },
42
+ ...(project
43
+ ? [{ label: project.name, href: trackerHref(slug, projectTargets(project.id).issues) }]
44
+ : []),
45
+ ]}
46
+ {title}
47
+ subtitle={project ? subtitle : null}
48
+ >
49
+ {#snippet actions()}
50
+ {#if project && headerActions}{@render headerActions()}{/if}
51
+ {/snippet}
52
+ </PageHeader>
53
+
54
+ <div class="body">
55
+ {#if pending}
56
+ <div class="load">
57
+ {#each [1, 2, 3] as row (row)}<Skeleton class="h-[76px] w-full" />{/each}
58
+ </div>
59
+ {:else if !project}
60
+ <EmptyState
61
+ icon="folder"
62
+ title={t('project_unknown', { key: projectKey })}
63
+ description={t('project_unknown_hint')}
64
+ />
65
+ {:else}
66
+ {@render children()}
67
+ {/if}
68
+ </div>
69
+ </div>
70
+
71
+ <style>
72
+ .kproj {
73
+ display: flex;
74
+ flex-direction: column;
75
+ flex: 1;
76
+ min-height: 0;
77
+ }
78
+ /* The gutters the issue list uses (DESIGN.md 2.4), and the full width with them: a project's pages
79
+ sit in the same frame as its work, and a column capped mid-screen reads as a page that failed to
80
+ load the rest. */
81
+ .body {
82
+ flex: 1;
83
+ min-height: 0;
84
+ overflow-y: auto;
85
+ padding: 18px 28px 44px;
86
+ width: 100%;
87
+ }
88
+ .load {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 10px;
92
+ }
93
+ </style>