@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,410 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ messageLocale,
6
+ navigation,
7
+ ProgressBar,
8
+ Select,
9
+ SettingsPage,
10
+ SettingsSection,
11
+ Spinner,
12
+ session,
13
+ toast,
14
+ uploadFile,
15
+ } from '@kernhq/ui'
16
+ import { createMutation, createQuery } from '@tanstack/svelte-query'
17
+ import { getTrackerApi } from '../api-instance.js'
18
+ import { previewCsv } from '../csv.js'
19
+ import { t } from '../i18n.js'
20
+ import type { ImportJob } from '../index.js'
21
+ import { canTracker } from '../permissions.js'
22
+ import { trackerKeys } from '../query.js'
23
+
24
+ /**
25
+ * Bringing work in from somewhere else.
26
+ *
27
+ * The columns are read in the browser before anything is uploaded, so the mapping screen offers the
28
+ * file's real names and a row of its real values. Asking somebody to map "column 3" turns a
29
+ * five-minute import into a spreadsheet-counting exercise.
30
+ *
31
+ * Nothing is imported until the mapping is confirmed. An import that guesses is an import somebody
32
+ * has to undo, and there is no undo.
33
+ */
34
+ const api = getTrackerApi()
35
+
36
+ const slug = $derived(navigation.workspaceSlug)
37
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
38
+ const canManage = $derived(canTracker('projectManage'))
39
+
40
+ /** `''` means "do not import this column", which is the safe default for every one of them. */
41
+ const DESTINATIONS = [
42
+ { value: '', label: () => t('import_skip') },
43
+ { value: 'title', label: () => t('import_field_title') },
44
+ { value: 'description', label: () => t('import_field_description') },
45
+ { value: 'status', label: () => t('import_field_status') },
46
+ { value: 'priority', label: () => t('import_field_priority') },
47
+ { value: 'assignee', label: () => t('import_field_assignee') },
48
+ { value: 'labels', label: () => t('import_field_labels') },
49
+ { value: 'due', label: () => t('import_field_due') },
50
+ { value: 'estimate', label: () => t('import_field_estimate') },
51
+ { value: 'key', label: () => t('import_field_key') },
52
+ ]
53
+
54
+ let selectedProject = $state<string | null>(null)
55
+ let file = $state<File | null>(null)
56
+ let preview = $state<{ columns: string[]; rows: string[][] }>({ columns: [], rows: [] })
57
+ let mapping = $state<Record<string, string>>({})
58
+ let hasHeader = $state(true)
59
+ let jobId = $state<string | null>(null)
60
+
61
+ const projectsQuery = createQuery(() => ({
62
+ queryKey: trackerKeys.projects(workspaceId),
63
+ queryFn: () => api.projects.list({ workspaceId }),
64
+ enabled: Boolean(workspaceId),
65
+ }))
66
+ const projects = $derived(projectsQuery.data ?? [])
67
+ const projectId = $derived(selectedProject ?? projects[0]?.id ?? '')
68
+
69
+ const fieldsQuery = createQuery(() => ({
70
+ queryKey: trackerKeys.fields(workspaceId),
71
+ queryFn: () => api.fields.list({ workspaceId, includeArchived: false }),
72
+ enabled: Boolean(workspaceId),
73
+ }))
74
+
75
+ /** A column can go to a built-in field or to any custom field, by the same `cf.<key>` name KQL uses. */
76
+ const destinations = $derived([
77
+ ...DESTINATIONS.map((d) => ({ value: d.value, label: d.label() })),
78
+ ...(fieldsQuery.data ?? []).map((f) => ({ value: `cf.${f.key}`, label: f.name })),
79
+ ])
80
+
81
+ /** Polls only while something is running: a finished import has nothing more to say. */
82
+ const jobQuery = createQuery(() => ({
83
+ queryKey: [...trackerKeys.projects(workspaceId), 'import', jobId ?? ''],
84
+ queryFn: () => api.imports.get({ workspaceId, id: jobId as string }),
85
+ enabled: Boolean(jobId),
86
+ refetchInterval: (query: { state: { data?: ImportJob } }) =>
87
+ query.state.data && ['completed', 'failed', 'cancelled'].includes(query.state.data.status) ? false : 1500,
88
+ }))
89
+ const job = $derived((jobQuery.data ?? null) as ImportJob | null)
90
+ const running = $derived(job ? ['pending', 'running'].includes(job.status) : false)
91
+
92
+ async function chooseFile(list: FileList | null) {
93
+ const chosen = list?.[0]
94
+ if (!chosen) return
95
+ file = chosen
96
+ jobId = null
97
+ const text = await chosen.text()
98
+ preview = previewCsv(text, { hasHeader })
99
+ // Nothing is mapped to begin with. A guess that lands a "Notes" column in the title is worse
100
+ // than an empty form, because nobody re-reads a field that already looks filled in.
101
+ mapping = {}
102
+ }
103
+
104
+ const start = createMutation(() => ({
105
+ mutationFn: async () => {
106
+ const uploaded = await uploadFile({
107
+ workspaceId,
108
+ file: file as File,
109
+ name: (file as File).name,
110
+ mimeType: 'text/csv',
111
+ })
112
+ const columns: Record<string, string> = {}
113
+ for (const [column, destination] of Object.entries(mapping))
114
+ if (destination) columns[column] = destination
115
+ return api.imports.start({
116
+ workspaceId,
117
+ projectId,
118
+ source: 'csv',
119
+ fileId: uploaded.id,
120
+ mapping: { columns, hasHeader, delimiter: ',' },
121
+ } as never)
122
+ },
123
+ onSuccess: (created: ImportJob) => {
124
+ jobId = created.id
125
+ },
126
+ onError: (error: Error) => toast.error(error.message),
127
+ }))
128
+
129
+ const cancel = createMutation(() => ({
130
+ mutationFn: () => api.imports.cancel({ workspaceId, id: jobId as string }),
131
+ onError: (error: Error) => toast.error(error.message),
132
+ }))
133
+
134
+ /**
135
+ * What was imported before.
136
+ *
137
+ * An import is the one thing here that happens to a project rather than in front of you: it runs,
138
+ * you navigate away, and afterwards the only question is what it did. `imports.list` answered that
139
+ * from the start and nothing asked it, so leaving the page lost the outcome for good.
140
+ */
141
+ const historyQuery = createQuery(() => ({
142
+ queryKey: [...trackerKeys.projects(workspaceId), 'imports', projectId],
143
+ queryFn: () => api.imports.list({ workspaceId, projectId }),
144
+ enabled: Boolean(projectId),
145
+ }))
146
+ /** Newest first: the one somebody is looking for is almost always the last one. */
147
+ const history = $derived(
148
+ [...(historyQuery.data ?? [])].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
149
+ )
150
+
151
+ const STATUS_LABELS: Record<string, () => string> = {
152
+ pending: () => t('import_pending'),
153
+ running: () => t('import_running'),
154
+ completed: () => t('import_completed'),
155
+ failed: () => t('import_failed'),
156
+ cancelled: () => t('import_cancelled'),
157
+ }
158
+ const statusLabel = (status: string) => STATUS_LABELS[status]?.() ?? status
159
+ const when = new Intl.DateTimeFormat(messageLocale(), { dateStyle: 'medium', timeStyle: 'short' })
160
+
161
+ /** A title is the one thing an issue cannot be created without. */
162
+ const mappedTitle = $derived(Object.values(mapping).includes('title'))
163
+ const canStart = $derived(Boolean(file) && mappedTitle && canManage && !running)
164
+ </script>
165
+
166
+ <SettingsPage title={t('settings_import')} description={t('settings_import_hint')}>
167
+ {#if projectsQuery.isPending}
168
+ <SettingsSection><div class="state"><Spinner /></div></SettingsSection>
169
+ {:else if !projects.length}
170
+ <SettingsSection><p class="state">{t('settings_planning_no_projects')}</p></SettingsSection>
171
+ {:else}
172
+ <SettingsSection title={t('settings_planning_project')}>
173
+ <Select
174
+ value={projectId}
175
+ options={projects.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` }))}
176
+ onValueChange={(v: string) => (selectedProject = v)}
177
+ />
178
+ </SettingsSection>
179
+
180
+ <SettingsSection title={t('import_file')} description={t('import_file_hint')}>
181
+ <input
182
+ type="file"
183
+ accept=".csv,text/csv"
184
+ aria-label={t('import_file')}
185
+ data-testid="import-file"
186
+ onchange={(e) => {
187
+ void chooseFile(e.currentTarget.files)
188
+ }}
189
+ />
190
+ <label class="check">
191
+ <input
192
+ type="checkbox"
193
+ checked={hasHeader}
194
+ onchange={async (e) => {
195
+ hasHeader = e.currentTarget.checked
196
+ if (file) preview = previewCsv(await file.text(), { hasHeader })
197
+ }}
198
+ />
199
+ {t('import_has_header')}
200
+ </label>
201
+ </SettingsSection>
202
+
203
+ {#if preview.columns.length}
204
+ <SettingsSection title={t('import_mapping')} description={t('import_mapping_hint')}>
205
+ <table class="map" data-testid="import-mapping">
206
+ <thead>
207
+ <tr>
208
+ <th scope="col">{t('import_column')}</th>
209
+ <th scope="col">{t('import_example')}</th>
210
+ <th scope="col">{t('import_goes_to')}</th>
211
+ </tr>
212
+ </thead>
213
+ <tbody>
214
+ {#each preview.columns as column, i (column + i)}
215
+ <tr>
216
+ <th scope="row">{column}</th>
217
+ <td class="example">{preview.rows[0]?.[i] ?? ''}</td>
218
+ <td>
219
+ <Select
220
+ value={mapping[column] ?? ''}
221
+ options={destinations}
222
+ onValueChange={(v: string) => (mapping = { ...mapping, [column]: v })}
223
+ />
224
+ </td>
225
+ </tr>
226
+ {/each}
227
+ </tbody>
228
+ </table>
229
+
230
+ {#if !mappedTitle}
231
+ <p class="warn" data-testid="needs-title">{t('import_needs_title')}</p>
232
+ {/if}
233
+
234
+ {#snippet footer()}
235
+ <Button
236
+ size="sm"
237
+ disabled={!canStart}
238
+ loading={start.isPending}
239
+ onclick={() => start.mutate()}
240
+ data-testid="import-start"
241
+ >
242
+ {t('import_start')}
243
+ </Button>
244
+ {/snippet}
245
+ </SettingsSection>
246
+ {/if}
247
+
248
+ {#if job}
249
+ <SettingsSection title={t('import_progress')}>
250
+ <div class="jrow" data-testid="import-job">
251
+ <Badge tone={job.status === 'failed' ? 'danger' : undefined}>{statusLabel(job.status)}</Badge>
252
+ <span class="counts">
253
+ {t('import_counts', {
254
+ created: job.progress.created,
255
+ processed: job.progress.processed,
256
+ total: job.progress.total,
257
+ failed: job.progress.failed,
258
+ })}
259
+ </span>
260
+ {#if running}
261
+ <Button size="sm" variant="ghost" onclick={() => cancel.mutate()}>{t('common.cancel')}</Button>
262
+ {/if}
263
+ </div>
264
+ <ProgressBar
265
+ value={job.progress.total ? (job.progress.processed / job.progress.total) * 100 : 0}
266
+ />
267
+ {#if job.errors.length}
268
+ <ul class="errors">
269
+ {#each job.errors.slice(0, 20) as problem, i (problem.message + i)}
270
+ <li>
271
+ {#if problem.row !== null}<span class="row">{t('import_row', { row: problem.row })}</span>{/if}
272
+ {problem.message}
273
+ </li>
274
+ {/each}
275
+ </ul>
276
+ {#if job.errors.length > 20}
277
+ <p class="note">{t('import_more_errors', { count: job.errors.length - 20 })}</p>
278
+ {/if}
279
+ {/if}
280
+ </SettingsSection>
281
+ {/if}
282
+
283
+ {#if history.length}
284
+ <SettingsSection title={t('import_history')} description={t('import_history_hint')}>
285
+ <ul class="history" data-testid="import-history">
286
+ {#each history as past (past.id)}
287
+ <li>
288
+ <button type="button" onclick={() => (jobId = past.id)} data-testid="import-history-row">
289
+ <Badge tone={past.status === 'failed' ? 'danger' : undefined}>
290
+ {statusLabel(past.status)}
291
+ </Badge>
292
+ <span class="hwhen">{when.format(new Date(past.createdAt))}</span>
293
+ <span class="counts">
294
+ {t('import_counts', {
295
+ created: past.progress.created,
296
+ processed: past.progress.processed,
297
+ total: past.progress.total,
298
+ failed: past.progress.failed,
299
+ })}
300
+ </span>
301
+ </button>
302
+ </li>
303
+ {/each}
304
+ </ul>
305
+ </SettingsSection>
306
+ {/if}
307
+ {/if}
308
+ </SettingsPage>
309
+
310
+ <style>
311
+ .history {
312
+ list-style: none;
313
+ margin: 0;
314
+ padding: 0;
315
+ }
316
+ .history button {
317
+ display: flex;
318
+ align-items: center;
319
+ gap: 10px;
320
+ width: 100%;
321
+ padding: 6px 0;
322
+ border: 0;
323
+ background: none;
324
+ color: inherit;
325
+ font: inherit;
326
+ text-align: start;
327
+ cursor: pointer;
328
+ }
329
+ .history button:hover .hwhen {
330
+ color: var(--kern-ink-700);
331
+ }
332
+ .hwhen {
333
+ flex: 1;
334
+ font-size: 12.5px;
335
+ color: var(--kern-ink-550);
336
+ }
337
+ .state {
338
+ display: grid;
339
+ place-items: center;
340
+ padding: 24px;
341
+ font-size: 13px;
342
+ }
343
+ .check {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 6px;
347
+ margin-top: 10px;
348
+ font-size: 13px;
349
+ }
350
+ .map {
351
+ width: 100%;
352
+ border-collapse: collapse;
353
+ font-size: 13px;
354
+ }
355
+ .map th,
356
+ .map td {
357
+ padding: 6px 8px 6px 0;
358
+ border-bottom: 1px solid var(--kern-border-hairline);
359
+ text-align: start;
360
+ vertical-align: middle;
361
+ }
362
+ .map thead th {
363
+ font-size: 11.5px;
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.08em;
366
+ color: var(--kern-ink-400);
367
+ }
368
+ .example {
369
+ max-width: 180px;
370
+ overflow: hidden;
371
+ text-overflow: ellipsis;
372
+ white-space: nowrap;
373
+ color: var(--kern-ink-450, var(--kern-ink-400));
374
+ }
375
+ .warn {
376
+ margin: 10px 0 0;
377
+ font-size: 13px;
378
+ color: var(--kern-warning);
379
+ }
380
+ .jrow {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 10px;
384
+ margin-bottom: 8px;
385
+ }
386
+ .counts {
387
+ flex: 1;
388
+ font-size: 13px;
389
+ }
390
+ .errors {
391
+ list-style: none;
392
+ margin: 10px 0 0;
393
+ padding: 0;
394
+ display: flex;
395
+ flex-direction: column;
396
+ gap: 2px;
397
+ font-size: 12.5px;
398
+ color: var(--kern-danger);
399
+ }
400
+ .errors .row {
401
+ font-family: var(--kern-font-mono);
402
+ font-size: 11.5px;
403
+ margin-inline-end: 6px;
404
+ }
405
+ .note {
406
+ margin: 6px 0 0;
407
+ font-size: 12px;
408
+ color: var(--kern-ink-400);
409
+ }
410
+ </style>
@@ -0,0 +1,263 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, Icon, IconButton, type MenuItem } from '@kernhq/ui'
3
+ import { untrack } from 'svelte'
4
+ import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
5
+ import { t } from '../i18n.js'
6
+ import type { FieldLayoutItem, ResolvedField, ResolvedLayout } from '../index.js'
7
+
8
+ /**
9
+ * Where each field sits on a work item type: the main column, the sidebar, or hidden.
10
+ *
11
+ * Three drop zones over `svelte-dnd-action`, with both lessons the board taught: `$state.raw`,
12
+ * because a deep-reactive proxy hands the drag library a different object on every read and it
13
+ * reads that as an endless stream of changes; and a `dragging` guard, so a refetch cannot yank the
14
+ * lists out from under a drag in progress.
15
+ *
16
+ * Pinned fields render locked. An issue without a title, a status or a type is not an issue.
17
+ */
18
+ interface Props {
19
+ layout: ResolvedLayout
20
+ disabled?: boolean
21
+ onchange: (items: FieldLayoutItem[]) => void
22
+ }
23
+ let { layout, disabled = false, onchange }: Props = $props()
24
+
25
+ type Zone = 'main' | 'sidebar' | 'hidden'
26
+ /**
27
+ * `svelte-dnd-action` tracks items by an `id` property and nothing else. A `ResolvedField` is keyed
28
+ * by `fieldId`, so without this the zones look populated and refuse to move anything at all —
29
+ * silently, by mouse and by keyboard alike.
30
+ */
31
+ type Draggable = ResolvedField & { id: string }
32
+ const draggable = (field: ResolvedField): Draggable => ({ ...field, id: field.fieldId })
33
+ const ZONES: Array<{ id: Zone; label: () => string; hint: () => string }> = [
34
+ { id: 'main', label: () => t('layout_main'), hint: () => t('layout_main_hint') },
35
+ { id: 'sidebar', label: () => t('layout_sidebar'), hint: () => t('layout_sidebar_hint') },
36
+ { id: 'hidden', label: () => t('layout_hidden'), hint: () => t('layout_hidden_hint') },
37
+ ]
38
+
39
+ let columns = $state.raw<Record<Zone, Draggable[]>>({ main: [], sidebar: [], hidden: [] })
40
+ let dragging = $state(false)
41
+
42
+ /**
43
+ * Seed the zones from the resolved layout, and *only* from it.
44
+ *
45
+ * `dragging` is read through `untrack` on purpose: reading it normally makes it a dependency, so
46
+ * clearing it at the end of a drop re-runs this effect, which re-seeds the zones from the server's
47
+ * layout and undoes the move on screen — while the change that was already emitted is still
48
+ * pending. The screen and the unsaved change would disagree.
49
+ */
50
+ $effect(() => {
51
+ const next = layout
52
+ untrack(() => {
53
+ if (dragging) return
54
+ columns = {
55
+ main: next.main.map(draggable),
56
+ sidebar: next.sidebar.map(draggable),
57
+ hidden: next.hidden.map(draggable),
58
+ }
59
+ })
60
+ })
61
+
62
+ const isShadow = (field: Draggable) =>
63
+ (field as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
64
+
65
+ /** The whole arrangement as the contract stores it: one entry per field, in the order shown. */
66
+ function emit(next: Record<Zone, Draggable[]>) {
67
+ const items: FieldLayoutItem[] = []
68
+ for (const zone of ['main', 'sidebar', 'hidden'] as Zone[])
69
+ for (const [index, field] of next[zone].entries()) {
70
+ if (isShadow(field)) continue
71
+ items.push({
72
+ fieldId: field.fieldId,
73
+ section: zone,
74
+ order: index,
75
+ required: field.required && !field.pinned,
76
+ hidden: zone === 'hidden',
77
+ })
78
+ }
79
+ onchange(items)
80
+ }
81
+
82
+ /**
83
+ * Move a field without dragging.
84
+ *
85
+ * A drag is a fine gesture and a poor requirement: it is unreachable by keyboard and by anyone who
86
+ * cannot hold a pointer steady. Every arrangement possible by dragging is possible here too.
87
+ */
88
+ function moveTo(field: Draggable, to: Zone) {
89
+ const next: Record<Zone, Draggable[]> = {
90
+ main: columns.main.filter((f) => f.id !== field.id),
91
+ sidebar: columns.sidebar.filter((f) => f.id !== field.id),
92
+ hidden: columns.hidden.filter((f) => f.id !== field.id),
93
+ }
94
+ // A pinned field is never hidden, whatever route asked for it.
95
+ const target = to === 'hidden' && field.pinned ? 'sidebar' : to
96
+ next[target] = [...next[target], field]
97
+ columns = next
98
+ emit(next)
99
+ }
100
+
101
+ const moveMenu = (field: Draggable, from: Zone): MenuItem[] =>
102
+ ZONES.filter((z) => z.id !== from && !(z.id === 'hidden' && field.pinned)).map((z) => ({
103
+ type: 'item' as const,
104
+ id: z.id,
105
+ label: t('layout_move_to', { zone: z.label() }),
106
+ onSelect: () => moveTo(field, z.id),
107
+ }))
108
+
109
+ function consider(zone: Zone, event: CustomEvent<{ items: Draggable[] }>) {
110
+ dragging = true
111
+ columns = { ...columns, [zone]: event.detail.items }
112
+ }
113
+
114
+ function finalize(zone: Zone, event: CustomEvent<{ items: Draggable[] }>) {
115
+ let next = { ...columns, [zone]: event.detail.items }
116
+ // A pinned field that somehow reached `hidden` goes back to the sidebar. Enforcing it here rather
117
+ // than refusing the drop covers every route in — drag, keyboard, a stored layout written by hand.
118
+ if (zone === 'hidden') {
119
+ const stowaways = next.hidden.filter((f) => f.pinned)
120
+ if (stowaways.length)
121
+ next = {
122
+ ...next,
123
+ hidden: next.hidden.filter((f) => !f.pinned),
124
+ sidebar: [...next.sidebar, ...stowaways],
125
+ }
126
+ }
127
+ columns = next
128
+ dragging = false
129
+ emit(next)
130
+ }
131
+ </script>
132
+
133
+ <div class="zones">
134
+ {#each ZONES as zone (zone.id)}
135
+ <section class="zone">
136
+ <header>
137
+ <span class="kern-sublabel">{zone.label()}</span>
138
+ <span class="hint">{zone.hint()}</span>
139
+ </header>
140
+ <ul
141
+ class="drop"
142
+ class:empty={columns[zone.id].length === 0}
143
+ data-testid="zone-{zone.id}"
144
+ use:dndzone={{
145
+ items: columns[zone.id],
146
+ dragDisabled: disabled,
147
+ dropFromOthersDisabled: disabled,
148
+ flipDurationMs: 120,
149
+ dropTargetStyle: {},
150
+ }}
151
+ onconsider={(e) => consider(zone.id, e)}
152
+ onfinalize={(e) => finalize(zone.id, e)}
153
+ >
154
+ {#each columns[zone.id] as field (field.id)}
155
+ <li class:pinned={field.pinned} data-field-id={field.fieldId}>
156
+ <Icon name={field.pinned ? 'lock' : 'menu'} size={13} strokeWidth={1.8} />
157
+ <span class="label">{field.label}</span>
158
+ {#if field.kind === 'custom'}
159
+ <span class="key">cf.{field.field?.key}</span>
160
+ {/if}
161
+ {#if field.required}
162
+ <span class="req" title={t('field_required')}>*</span>
163
+ {/if}
164
+ {#if !disabled}
165
+ <DropdownMenu items={moveMenu(field, zone.id)} align="end">
166
+ {#snippet trigger(props)}
167
+ <IconButton
168
+ {...props}
169
+ icon="ellipsis"
170
+ size={22}
171
+ label={t('layout_move', { field: field.label })}
172
+ />
173
+ {/snippet}
174
+ </DropdownMenu>
175
+ {/if}
176
+ </li>
177
+ {/each}
178
+ {#if columns[zone.id].length === 0}
179
+ <li class="placeholder">{t('layout_drop_here')}</li>
180
+ {/if}
181
+ </ul>
182
+ </section>
183
+ {/each}
184
+ </div>
185
+
186
+ <style>
187
+ .zones {
188
+ display: grid;
189
+ grid-template-columns: repeat(3, minmax(0, 1fr));
190
+ gap: 12px;
191
+ }
192
+ @media (max-width: 720px) {
193
+ .zones {
194
+ grid-template-columns: 1fr;
195
+ }
196
+ }
197
+ header {
198
+ display: flex;
199
+ flex-direction: column;
200
+ gap: 1px;
201
+ margin-bottom: 6px;
202
+ }
203
+ .hint {
204
+ font-size: 11.5px;
205
+ color: var(--kern-ink-400);
206
+ }
207
+ .drop {
208
+ list-style: none;
209
+ margin: 0;
210
+ padding: 6px;
211
+ /* an empty zone still has to be a target, so it keeps a height of its own */
212
+ min-height: 96px;
213
+ display: flex;
214
+ flex-direction: column;
215
+ gap: 4px;
216
+ border: 1px dashed var(--kern-border);
217
+ border-radius: var(--kern-r-sm);
218
+ background: var(--kern-shell);
219
+ }
220
+ .drop li {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 6px;
224
+ padding: 5px 8px;
225
+ border: 1px solid var(--kern-border);
226
+ border-radius: var(--kern-r-sm);
227
+ background: var(--kern-surface);
228
+ font-size: 13px;
229
+ cursor: grab;
230
+ }
231
+ .drop li.pinned {
232
+ cursor: default;
233
+ /*
234
+ * Muted by colour, not by `opacity`. A row at 0.75 fades its text against the page it sits on —
235
+ * the required marker inside it dropped to 3.2:1, which is a readability failure rather than a
236
+ * visual hint — and opacity fades everything by the same amount whatever its starting contrast.
237
+ */
238
+ color: var(--kern-ink-450);
239
+ }
240
+ .drop li.placeholder {
241
+ justify-content: center;
242
+ border-style: dashed;
243
+ border-color: transparent;
244
+ background: none;
245
+ color: var(--kern-ink-400);
246
+ font-size: 12px;
247
+ cursor: default;
248
+ }
249
+ .label {
250
+ flex: 1;
251
+ overflow: hidden;
252
+ text-overflow: ellipsis;
253
+ white-space: nowrap;
254
+ }
255
+ .key {
256
+ font-family: var(--kern-font-mono);
257
+ font-size: 11px;
258
+ color: var(--kern-ink-400);
259
+ }
260
+ .req {
261
+ color: var(--kern-warning);
262
+ }
263
+ </style>