@kernhq/module-tracker 0.10.1 → 0.11.1

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 +19 -6
  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,263 @@
1
+ <script lang="ts">
2
+ import {
3
+ DropdownMenu,
4
+ formatCount,
5
+ IconButton,
6
+ type MenuItem,
7
+ navigation,
8
+ SidebarGroup,
9
+ SidebarItem,
10
+ SidebarPill,
11
+ Skeleton,
12
+ session,
13
+ } from '@kernhq/ui'
14
+ import { createQuery } from '@tanstack/svelte-query'
15
+ import { getTrackerApi } from '../api-instance.js'
16
+ import { t } from '../i18n.js'
17
+ import type { Project } from '../index.js'
18
+ import {
19
+ isTrackerTarget,
20
+ type ProjectSection,
21
+ projectColor,
22
+ projectSectionHref,
23
+ projectSettingsHref,
24
+ projectTargets,
25
+ type TrackerTarget,
26
+ trackerHref,
27
+ } from '../nav.js'
28
+ import { canTracker } from '../permissions.js'
29
+ import { trackerKeys } from '../query.js'
30
+
31
+ /**
32
+ * The workspace's projects, in the sidebar (DESIGN.md 2.3).
33
+ *
34
+ * A project is a heading with the ways its work is looked at underneath — its issues, and the
35
+ * planning it actually uses. Cycles and triage appear only for the projects that turned them on:
36
+ * a row that leads to an empty screen for most of a workspace is a row people learn to ignore.
37
+ *
38
+ * The rows open the project's own pages, where a component is added and a milestone marked
39
+ * reached. What is genuinely configuration — repeating work, the project's settings — stays in the
40
+ * menu, because those are places you go once and the rows above are places you go daily.
41
+ */
42
+ interface Props {
43
+ /** the sidebar's own "new project", so the dialog opens on the page you are on */
44
+ oncreate?: () => void
45
+ }
46
+ let { oncreate }: Props = $props()
47
+
48
+ const api = getTrackerApi()
49
+
50
+ const slug = $derived(navigation.workspaceSlug)
51
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
52
+ const params = $derived(new URLSearchParams(navigation.search))
53
+ const inTracker = $derived(navigation.pathname === `/${slug}/tracker`)
54
+ const canManage = $derived(canTracker('projectManage'))
55
+
56
+ const projectsQuery = createQuery(() => ({
57
+ queryKey: trackerKeys.projects(workspaceId),
58
+ queryFn: () => api.projects.list({ workspaceId }),
59
+ enabled: Boolean(workspaceId),
60
+ }))
61
+ const projects = $derived(projectsQuery.data ?? [])
62
+
63
+ /**
64
+ * Which projects are folded away, kept on the device.
65
+ *
66
+ * Somebody with eight projects works in one of them; re-opening the same three headings on every
67
+ * visit is the sort of small tax that makes a sidebar feel like it is not listening. Collapsed
68
+ * rather than expanded is stored, so a project created later arrives open.
69
+ */
70
+ const STORAGE_KEY = 'kern.tracker.projects.collapsed'
71
+ let collapsed = $state<string[]>(read())
72
+
73
+ function read(): string[] {
74
+ if (typeof localStorage === 'undefined') return []
75
+ try {
76
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown
77
+ return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : []
78
+ } catch {
79
+ return []
80
+ }
81
+ }
82
+
83
+ function toggle(id: string) {
84
+ collapsed = collapsed.includes(id) ? collapsed.filter((c) => c !== id) : [...collapsed, id]
85
+ if (typeof localStorage === 'undefined') return
86
+ try {
87
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed))
88
+ } catch {
89
+ // private browsing and a full quota both land here; the fold still applies to this session
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The rows under one project.
95
+ *
96
+ * Two kinds, and the difference matters: a query row narrows the issue list, a section row opens
97
+ * the project's own page. Components, milestones and cycles are things a team makes and manages —
98
+ * they are pages you work on, not sortings of a list.
99
+ */
100
+ type ProjectRow = { id: string; label: string; icon: string } & (
101
+ | { target: TrackerTarget; section?: undefined }
102
+ | { section: ProjectSection; target?: undefined }
103
+ )
104
+
105
+ function rowsFor(project: Project): ProjectRow[] {
106
+ const targets = projectTargets(project.id)
107
+ return [
108
+ { id: 'issues', label: t('nav'), icon: 'square-check-big', target: targets.issues },
109
+ { id: 'components', label: t('planning_components'), icon: 'puzzle', section: 'components' },
110
+ { id: 'milestones', label: t('planning_milestones'), icon: 'flag', section: 'milestones' },
111
+ ...(project.settings.cycles.enabled
112
+ ? [
113
+ {
114
+ id: 'cycles',
115
+ label: t('planning_cycles'),
116
+ icon: 'refresh-cw',
117
+ section: 'cycles' as const,
118
+ },
119
+ ]
120
+ : []),
121
+ { id: 'templates', label: t('template_title'), icon: 'copy', section: 'templates' },
122
+ ...(project.settings.triage.enabled
123
+ ? [{ id: 'triage', label: t('triage_title'), icon: 'inbox', target: targets.triage }]
124
+ : []),
125
+ ]
126
+ }
127
+
128
+ /** Where a row goes, and whether the screen is already showing it. */
129
+ const hrefFor = (project: Project, row: ProjectRow) =>
130
+ row.section ? projectSectionHref(slug, project.key, row.section) : trackerHref(slug, row.target)
131
+
132
+ const isCurrent = (project: Project, row: ProjectRow) =>
133
+ row.section
134
+ ? navigation.pathname === projectSectionHref(slug, project.key, row.section)
135
+ : inTracker && isTrackerTarget(params, row.target)
136
+
137
+ const menuFor = (project: Project): MenuItem[] => [
138
+ ...(canTracker('create')
139
+ ? [
140
+ {
141
+ type: 'item' as const,
142
+ id: 'new-issue',
143
+ label: t('new_issue'),
144
+ icon: 'plus',
145
+ // the list it filters stays underneath, and the dialog opens on that project
146
+ href: `${trackerHref(slug, projectTargets(project.id).issues)}&new=1&project=${project.id}`,
147
+ },
148
+ ]
149
+ : []),
150
+ ...(canManage
151
+ ? [
152
+ { type: 'separator' as const },
153
+ {
154
+ type: 'item' as const,
155
+ id: 'repeating',
156
+ label: t('settings_repeating'),
157
+ icon: 'refresh-cw',
158
+ href: projectSettingsHref(slug, 'repeating', project.id),
159
+ },
160
+ {
161
+ type: 'item' as const,
162
+ id: 'settings',
163
+ label: t('project_settings'),
164
+ icon: 'settings',
165
+ href: projectSettingsHref(slug, 'projects', project.id),
166
+ },
167
+ ]
168
+ : []),
169
+ ]
170
+ </script>
171
+
172
+ <SidebarGroup title={t('projects_yours')} count={projects.length || null}>
173
+ {#snippet trailing()}
174
+ {#if canManage}
175
+ <IconButton
176
+ icon="plus"
177
+ label={t('project_new')}
178
+ size={22}
179
+ onclick={() => oncreate?.()}
180
+ data-testid="sidebar-new-project"
181
+ />
182
+ {/if}
183
+ {/snippet}
184
+
185
+ {#if projectsQuery.isPending}
186
+ <div class="loading">
187
+ {#each [1, 2, 3] as row (row)}<Skeleton class="h-[26px] w-full" />{/each}
188
+ </div>
189
+ {:else if !projects.length}
190
+ <p class="none">{t('projects_empty_hint')}</p>
191
+ {:else}
192
+ {#each projects as project (project.id)}
193
+ {@const open = !collapsed.includes(project.id)}
194
+ {@const menu = menuFor(project)}
195
+ <div class="prow">
196
+ <SidebarPill
197
+ label={project.name}
198
+ color={projectColor(project)}
199
+ aria-expanded={open}
200
+ onclick={() => toggle(project.id)}
201
+ title="{project.key} · {project.name}"
202
+ data-testid="sidebar-project"
203
+ data-project={project.key}
204
+ />
205
+ {#if menu.length}
206
+ <DropdownMenu items={menu} align="end">
207
+ {#snippet trigger(props)}
208
+ <IconButton
209
+ {...props}
210
+ icon="ellipsis"
211
+ size={22}
212
+ class="pmenu"
213
+ label={t('project_actions', { name: project.name })}
214
+ />
215
+ {/snippet}
216
+ </DropdownMenu>
217
+ {/if}
218
+ </div>
219
+ {#if open}
220
+ {#each rowsFor(project) as row (row.id)}
221
+ <SidebarItem
222
+ label={row.label}
223
+ icon={row.icon}
224
+ indent={1}
225
+ href={hrefFor(project, row)}
226
+ active={isCurrent(project, row)}
227
+ badge={row.id === 'issues' && project.openIssueCount ? formatCount(project.openIssueCount) : null}
228
+ data-testid="project-{row.id}"
229
+ data-project={project.key}
230
+ />
231
+ {/each}
232
+ {/if}
233
+ {/each}
234
+ {/if}
235
+ </SidebarGroup>
236
+
237
+ <style>
238
+ /* The pill hugs its name (DESIGN.md 2.3) and the menu sits at the row's end regardless. */
239
+ .prow {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 4px;
243
+ }
244
+ .prow :global(.kspill) {
245
+ min-width: 0;
246
+ max-width: calc(100% - 26px);
247
+ }
248
+ .prow :global(.pmenu) {
249
+ margin-inline-start: auto;
250
+ }
251
+ /* A sidebar has no room for an illustrated empty state; one quiet line says the same thing. */
252
+ .none {
253
+ margin: 2px 10px 6px;
254
+ font-size: 12.5px;
255
+ color: var(--kern-ink-330);
256
+ }
257
+ .loading {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 4px;
261
+ padding: 4px 2px;
262
+ }
263
+ </style>
@@ -0,0 +1,336 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DropdownMenu,
6
+ Icon,
7
+ IconButton,
8
+ Input,
9
+ type MenuItem,
10
+ navigation,
11
+ Select,
12
+ SidebarGroup,
13
+ Skeleton,
14
+ session,
15
+ toast,
16
+ } from '@kernhq/ui'
17
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
18
+ import { getTrackerApi } from '../api-instance.js'
19
+ import { t } from '../i18n.js'
20
+ import type { View } from '../index.js'
21
+ import { canTracker } from '../permissions.js'
22
+ import { trackerKeys } from '../query.js'
23
+ import { viewHref } from '../views.js'
24
+
25
+ /**
26
+ * Saved views, in the application sidebar (DESIGN.md 2.3).
27
+ *
28
+ * A view is a query somebody named: the KQL, the layout, the grouping and the filters, kept
29
+ * together so "the bugs I own that are overdue" is one click rather than four. The server has had
30
+ * them since the module existed and nothing offered them.
31
+ *
32
+ * Pinned views come first because they are the ones somebody uses daily; the rest follow under a
33
+ * quieter heading rather than in a menu, because a view you cannot see is a view you forget you
34
+ * saved.
35
+ */
36
+ interface Props {
37
+ /** the sidebar's "+": save what the screen is showing right now, without leaving it */
38
+ onsave?: () => void
39
+ }
40
+ let { onsave }: Props = $props()
41
+
42
+ const api = getTrackerApi()
43
+ const queryClient = useQueryClient()
44
+
45
+ const slug = $derived(navigation.workspaceSlug)
46
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
47
+ const canManageShared = $derived(canTracker('viewManageShared'))
48
+ /** Saving a view is saving a query, which anyone who may raise work may do. */
49
+ const canSave = $derived(canTracker('create'))
50
+
51
+ const viewsQuery = createQuery(() => ({
52
+ queryKey: trackerKeys.views(workspaceId),
53
+ queryFn: () => api.views.list({ workspaceId }),
54
+ enabled: Boolean(workspaceId),
55
+ }))
56
+ const views = $derived(viewsQuery.data ?? [])
57
+ const pinned = $derived(views.filter((v) => v.pinned))
58
+ const rest = $derived(views.filter((v) => !v.pinned))
59
+
60
+ /** The view the page is showing, so the sidebar marks it the way the nav marks a page. */
61
+ const activeId = $derived(navigation.search.view_id)
62
+
63
+ let editing = $state<View | null>(null)
64
+ let draftName = $state('')
65
+ let draftVisibility = $state<View['visibility']>('private')
66
+ /** Named on screen before it happens, never straight off a menu. */
67
+ let confirming = $state<View | null>(null)
68
+
69
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: trackerKeys.views(workspaceId) })
70
+ const fail = (error: Error) => toast.error(error.message)
71
+
72
+ const pin = createMutation(() => ({
73
+ mutationFn: (input: { id: string; pinned: boolean }) => api.views.pin({ workspaceId, ...input }),
74
+ onSuccess: invalidate,
75
+ onError: fail,
76
+ }))
77
+
78
+ /** A view can be renamed and reshared. `views.update` existed from the start and nothing called
79
+ * it, so a view named in a hurry kept that name and a private one stayed private. */
80
+ const rename = createMutation(() => ({
81
+ mutationFn: () =>
82
+ api.views.update({
83
+ workspaceId,
84
+ id: editing?.id as string,
85
+ patch: { name: draftName.trim(), visibility: draftVisibility },
86
+ } as never),
87
+ onSuccess: () => {
88
+ editing = null
89
+ invalidate()
90
+ },
91
+ onError: fail,
92
+ }))
93
+
94
+ const remove = createMutation(() => ({
95
+ mutationFn: (id: string) => api.views.delete({ workspaceId, id }),
96
+ onSuccess: () => {
97
+ confirming = null
98
+ invalidate()
99
+ // Leaving the deleted view in the URL would reopen a query that no longer exists.
100
+ if (activeId) void navigation.go(`/${slug}/tracker`, { replaceState: true, keepFocus: true })
101
+ },
102
+ onError: fail,
103
+ }))
104
+
105
+ /** Who may change a view: your own always, a shared one only with the permission for it. */
106
+ const visibilityOptions = $derived([
107
+ { value: 'private', label: t('view_private') },
108
+ ...(canManageShared
109
+ ? [
110
+ { value: 'project', label: t('view_project') },
111
+ { value: 'workspace', label: t('view_workspace') },
112
+ ]
113
+ : []),
114
+ ])
115
+
116
+ const mayEdit = (view: View) =>
117
+ !view.builtin && (view.visibility === 'private' ? view.ownerId === session.user?.id : canManageShared)
118
+
119
+ const openEditor = (view: View) => {
120
+ editing = view
121
+ draftName = view.name
122
+ draftVisibility = view.visibility
123
+ }
124
+
125
+ const menuFor = (view: View): MenuItem[] => [
126
+ {
127
+ type: 'item',
128
+ id: 'pin',
129
+ label: view.pinned ? t('view_unpin') : t('view_pin'),
130
+ icon: 'star',
131
+ onSelect: () => pin.mutate({ id: view.id, pinned: !view.pinned }),
132
+ },
133
+ ...(mayEdit(view)
134
+ ? [
135
+ {
136
+ type: 'item' as const,
137
+ id: 'rename',
138
+ label: t('view_rename'),
139
+ icon: 'pencil',
140
+ onSelect: () => openEditor(view),
141
+ },
142
+ {
143
+ type: 'item' as const,
144
+ id: 'delete',
145
+ label: t('common.delete'),
146
+ icon: 'trash-2',
147
+ danger: true,
148
+ // Asked for, not fired from a menu — the only irreversible entry in it.
149
+ onSelect: () => (confirming = view),
150
+ },
151
+ ]
152
+ : []),
153
+ ]
154
+ </script>
155
+
156
+ {#snippet row(view: View)}
157
+ <li>
158
+ <a
159
+ href={viewHref(slug, view)}
160
+ aria-current={activeId === view.id ? 'page' : undefined}
161
+ class="vrow"
162
+ class:on={activeId === view.id}
163
+ data-testid="saved-view"
164
+ data-view-name={view.name}
165
+ >
166
+ <Icon name={view.icon ?? (view.layout === 'board' ? 'columns-3' : 'list')} size={14} strokeWidth={1.8} />
167
+ <span class="vname">{view.name}</span>
168
+ </a>
169
+ <DropdownMenu items={menuFor(view)} align="end">
170
+ {#snippet trigger(props)}
171
+ <IconButton {...props} icon="ellipsis" size={22} label={t('view_actions', { name: view.name })} />
172
+ {/snippet}
173
+ </DropdownMenu>
174
+ </li>
175
+ {/snippet}
176
+
177
+ {#if pinned.length}
178
+ <SidebarGroup title={t('views_pinned')} count={pinned.length}>
179
+ <ul>{#each pinned as view (view.id)}{@render row(view)}{/each}</ul>
180
+ </SidebarGroup>
181
+ {/if}
182
+
183
+ <SidebarGroup title={t('views_all')} count={rest.length || null}>
184
+ {#snippet trailing()}
185
+ {#if canSave}
186
+ <IconButton
187
+ icon="plus"
188
+ label={t('view_save_current')}
189
+ size={22}
190
+ onclick={() => onsave?.()}
191
+ data-testid="sidebar-save-view"
192
+ />
193
+ {/if}
194
+ {/snippet}
195
+
196
+ {#if viewsQuery.isPending}
197
+ <div class="loading">
198
+ {#each [1, 2, 3] as row (row)}<Skeleton class="h-[26px] w-full" />{/each}
199
+ </div>
200
+ {:else if !views.length}
201
+ <p class="none">{t('views_empty_hint')}</p>
202
+ {:else}
203
+ <ul>{#each rest as view (view.id)}{@render row(view)}{/each}</ul>
204
+ {/if}
205
+ </SidebarGroup>
206
+
207
+ <Dialog
208
+ open={editing !== null}
209
+ title={t('view_rename')}
210
+ size="sm"
211
+ onOpenChange={(next: boolean) => {
212
+ if (!next) editing = null
213
+ }}
214
+ >
215
+ <div class="egrid">
216
+ <label class="erow">
217
+ <span class="elbl">{t('view_name')}</span>
218
+ <Input bind:value={draftName} data-testid="view-rename-name" />
219
+ </label>
220
+ <label class="erow">
221
+ <span class="elbl">{t('view_visibility')}</span>
222
+ <Select
223
+ value={draftVisibility}
224
+ options={visibilityOptions}
225
+ onValueChange={(v: string) => (draftVisibility = v as View['visibility'])}
226
+ />
227
+ </label>
228
+ </div>
229
+
230
+ {#snippet footer()}
231
+ <Button variant="ghost" size="sm" onclick={() => (editing = null)}>{t('common.cancel')}</Button>
232
+ <Button
233
+ size="sm"
234
+ disabled={!draftName.trim()}
235
+ loading={rename.isPending}
236
+ onclick={() => rename.mutate()}
237
+ data-testid="view-rename-save"
238
+ >
239
+ {t('common.save')}
240
+ </Button>
241
+ {/snippet}
242
+ </Dialog>
243
+
244
+ <Dialog
245
+ open={confirming !== null}
246
+ title={t('view_delete_title', { name: confirming?.name ?? '' })}
247
+ size="sm"
248
+ onOpenChange={(next: boolean) => {
249
+ if (!next) confirming = null
250
+ }}
251
+ >
252
+ <p class="ebody">{t('view_delete_body')}</p>
253
+
254
+ {#snippet footer()}
255
+ <Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
256
+ <Button
257
+ size="sm"
258
+ variant="danger"
259
+ loading={remove.isPending}
260
+ onclick={() => confirming && remove.mutate(confirming.id)}
261
+ data-testid="view-delete-confirm"
262
+ >
263
+ {t('common.delete')}
264
+ </Button>
265
+ {/snippet}
266
+ </Dialog>
267
+
268
+ <style>
269
+ .egrid {
270
+ display: grid;
271
+ gap: 12px;
272
+ }
273
+ .erow {
274
+ display: grid;
275
+ gap: 4px;
276
+ }
277
+ .elbl {
278
+ font-size: 12px;
279
+ color: var(--kern-ink-550);
280
+ }
281
+ .ebody {
282
+ margin: 0;
283
+ font-size: 13px;
284
+ }
285
+ /* A sidebar has no room for an illustrated empty state; one quiet line says the same thing. */
286
+ .none {
287
+ margin: 2px 10px 6px;
288
+ font-size: 12.5px;
289
+ color: var(--kern-ink-330);
290
+ }
291
+ .loading {
292
+ display: flex;
293
+ flex-direction: column;
294
+ gap: 4px;
295
+ padding: 4px 2px;
296
+ }
297
+ ul {
298
+ list-style: none;
299
+ margin: 0;
300
+ padding: 0;
301
+ display: flex;
302
+ flex-direction: column;
303
+ gap: 1px;
304
+ }
305
+ li {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 2px;
309
+ }
310
+ .vrow {
311
+ display: flex;
312
+ align-items: center;
313
+ gap: 8px;
314
+ flex: 1;
315
+ min-width: 0;
316
+ height: 34px;
317
+ padding: 0 10px;
318
+ border-radius: var(--kern-r-xl);
319
+ color: var(--kern-ink-650);
320
+ font-size: 13.5px;
321
+ text-decoration: none;
322
+ }
323
+ .vrow:hover {
324
+ background: var(--kern-border);
325
+ }
326
+ .vrow.on {
327
+ background: var(--kern-ink-900);
328
+ color: var(--kern-ink-inverse);
329
+ font-weight: 600;
330
+ }
331
+ .vname {
332
+ overflow: hidden;
333
+ text-overflow: ellipsis;
334
+ white-space: nowrap;
335
+ }
336
+ </style>
@@ -0,0 +1,50 @@
1
+ <script lang="ts">
2
+ import { type StatusCategory, statusStyle } from '../index.js'
3
+
4
+ /**
5
+ * The status glyph (DESIGN.md 3.0): a rounded square whose ring colour comes from the workflow
6
+ * category and whose inner block fills as work moves along. Triage is dashed, Done is solid.
7
+ */
8
+ interface Props {
9
+ category: StatusCategory
10
+ /** status id and name refine the category, so "In review" gets its own colour */
11
+ statusId?: string | null
12
+ name?: string | null
13
+ size?: number
14
+ /** set to expose the status to assistive technology; otherwise the icon is decorative */
15
+ label?: string | null
16
+ }
17
+ let { category, statusId = null, name = null, size = 16, label = null }: Props = $props()
18
+ const style = $derived(statusStyle(category, statusId, name))
19
+ </script>
20
+
21
+ <svg
22
+ width={size}
23
+ height={size}
24
+ viewBox="0 0 16 16"
25
+ fill="none"
26
+ class="ksi"
27
+ role={label ? 'img' : 'presentation'}
28
+ aria-label={label ?? undefined}
29
+ aria-hidden={label ? undefined : 'true'}
30
+ >
31
+ <rect
32
+ x="1.7"
33
+ y="1.7"
34
+ width="12.6"
35
+ height="12.6"
36
+ rx="4"
37
+ fill="none"
38
+ stroke={style.color}
39
+ stroke-width="1.5"
40
+ stroke-dasharray={style.dash ?? undefined}
41
+ />
42
+ {#if style.fill}<path d={style.fill} fill={style.color} />{/if}
43
+ </svg>
44
+
45
+ <style>
46
+ .ksi {
47
+ flex: none;
48
+ display: block;
49
+ }
50
+ </style>