@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,324 @@
1
+ <script lang="ts">
2
+ import { Button, Checkbox, Dialog, IconButton, Input, Select, Textarea } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import type { FieldDef, FieldOption, FieldType } from '../index.js'
5
+
6
+ /**
7
+ * Creating or editing one custom field.
8
+ *
9
+ * A field's `key` and `type` are fixed after creation: the key is where values live in
10
+ * `issues.custom` and the type is what validates them, so changing either would reinterpret every
11
+ * value already stored. The dialog says so rather than silently disabling the inputs.
12
+ */
13
+ interface Props {
14
+ open: boolean
15
+ /** null when creating */
16
+ field: FieldDef | null
17
+ busy?: boolean
18
+ onclose: () => void
19
+ onsave: (input: Record<string, unknown>) => void
20
+ }
21
+ let { open, field, busy = false, onclose, onsave }: Props = $props()
22
+
23
+ const TYPES: FieldType[] = [
24
+ 'text',
25
+ 'textarea',
26
+ 'number',
27
+ 'select',
28
+ 'multiselect',
29
+ 'date',
30
+ 'datetime',
31
+ 'user',
32
+ 'multiuser',
33
+ 'label',
34
+ 'url',
35
+ 'checkbox',
36
+ 'relation',
37
+ 'formula',
38
+ ]
39
+
40
+ const TYPE_LABELS: Record<FieldType, () => string> = {
41
+ text: () => t('ft_text'),
42
+ textarea: () => t('ft_textarea'),
43
+ number: () => t('ft_number'),
44
+ select: () => t('ft_select'),
45
+ multiselect: () => t('ft_multiselect'),
46
+ date: () => t('ft_date'),
47
+ datetime: () => t('ft_datetime'),
48
+ user: () => t('ft_user'),
49
+ multiuser: () => t('ft_multiuser'),
50
+ label: () => t('ft_label'),
51
+ url: () => t('ft_url'),
52
+ checkbox: () => t('ft_checkbox'),
53
+ relation: () => t('ft_relation'),
54
+ formula: () => t('ft_formula'),
55
+ }
56
+
57
+ const HAS_OPTIONS: ReadonlySet<FieldType> = new Set(['select', 'multiselect', 'label'])
58
+
59
+ let name = $state('')
60
+ let key = $state('')
61
+ let description = $state('')
62
+ let type = $state<FieldType>('text')
63
+ let required = $state(false)
64
+ let searchable = $state(false)
65
+ let showInCards = $state(false)
66
+ let options = $state<FieldOption[]>([])
67
+ let minText = $state('')
68
+ let maxText = $state('')
69
+ let maxLengthText = $state('')
70
+ let pattern = $state('')
71
+ /** Touched once, the key stops following the name — otherwise a rename would rewrite it. */
72
+ let keyEdited = $state(false)
73
+
74
+ const editing = $derived(Boolean(field))
75
+
76
+ /** Reset every time the dialog opens, so a cancelled edit does not leak into the next one. */
77
+ $effect(() => {
78
+ if (!open) return
79
+ name = field?.name ?? ''
80
+ key = field?.key ?? ''
81
+ description = field?.description ?? ''
82
+ type = field?.type ?? 'text'
83
+ required = field?.required ?? false
84
+ searchable = field?.searchable ?? false
85
+ showInCards = field?.showInCards ?? false
86
+ options = field ? field.options.map((o) => ({ ...o })) : []
87
+ minText = field?.config.min?.toString() ?? ''
88
+ maxText = field?.config.max?.toString() ?? ''
89
+ maxLengthText = field?.config.maxLength?.toString() ?? ''
90
+ pattern = field?.config.pattern ?? ''
91
+ keyEdited = Boolean(field)
92
+ })
93
+
94
+ /** `Customer email` → `customer_email`: the key is a machine name, not a sentence. */
95
+ const slugify = (value: string) =>
96
+ value
97
+ .toLowerCase()
98
+ .replace(/[^a-z0-9]+/g, '_')
99
+ .replace(/^_+|_+$/g, '')
100
+ .slice(0, 40)
101
+
102
+ $effect(() => {
103
+ if (!keyEdited) key = slugify(name)
104
+ })
105
+
106
+ const keyValid = $derived(/^[a-z][a-z0-9_]*$/.test(key))
107
+ /**
108
+ * A bad pattern is compiled here rather than at value-check time: `config.pattern` is
109
+ * administrator-supplied, and one unparseable save would otherwise poison every write to a project.
110
+ */
111
+ const patternValid = $derived.by(() => {
112
+ if (!pattern) return true
113
+ try {
114
+ new RegExp(pattern)
115
+ return true
116
+ } catch {
117
+ return false
118
+ }
119
+ })
120
+ const optionsValid = $derived(
121
+ !HAS_OPTIONS.has(type) || (options.length > 0 && options.every((o) => o.label.trim())),
122
+ )
123
+ const valid = $derived(Boolean(name.trim()) && keyValid && patternValid && optionsValid)
124
+
125
+ function addOption() {
126
+ options = [
127
+ ...options,
128
+ {
129
+ id: `opt_${Math.random().toString(36).slice(2, 8)}`,
130
+ label: '',
131
+ color: null,
132
+ order: options.length,
133
+ archived: false,
134
+ },
135
+ ]
136
+ }
137
+
138
+ function save() {
139
+ if (!valid || busy) return
140
+ const config: Record<string, unknown> = {}
141
+ if (type === 'number' && minText.trim()) config.min = Number(minText)
142
+ if (type === 'number' && maxText.trim()) config.max = Number(maxText)
143
+ if ((type === 'text' || type === 'textarea') && maxLengthText.trim())
144
+ config.maxLength = Number(maxLengthText)
145
+ if ((type === 'text' || type === 'textarea') && pattern.trim()) config.pattern = pattern.trim()
146
+
147
+ onsave({
148
+ name: name.trim(),
149
+ description: description.trim() || null,
150
+ required,
151
+ searchable,
152
+ showInCards,
153
+ config,
154
+ ...(HAS_OPTIONS.has(type) ? { options: options.map((o, i) => ({ ...o, order: i })) } : {}),
155
+ // key and type are fixed once values exist under them
156
+ ...(editing ? {} : { key, type }),
157
+ })
158
+ }
159
+ </script>
160
+
161
+ <Dialog
162
+ {open}
163
+ title={editing ? t('field_edit') : t('field_new')}
164
+ size="md"
165
+ onOpenChange={(next: boolean) => {
166
+ if (!next) onclose()
167
+ }}
168
+ >
169
+ <div class="grid gap-3">
170
+ <label class="grid gap-1">
171
+ <span class="lbl">{t('field_name')}</span>
172
+ <Input bind:value={name} placeholder="Severity" data-testid="field-name" />
173
+ </label>
174
+
175
+ <label class="grid gap-1">
176
+ <span class="lbl">{t('field_key')}</span>
177
+ <Input
178
+ bind:value={key}
179
+ disabled={editing}
180
+ oninput={() => (keyEdited = true)}
181
+ placeholder="severity"
182
+ data-testid="field-key"
183
+ />
184
+ <span class="hint" class:bad={!keyValid && key.length > 0}>
185
+ {editing ? t('field_key_fixed') : t('field_key_hint')}
186
+ </span>
187
+ </label>
188
+
189
+ <label class="grid gap-1">
190
+ <span class="lbl">{t('field_type')}</span>
191
+ <Select
192
+ value={type}
193
+ disabled={editing}
194
+ options={TYPES.map((kind) => ({ value: kind, label: TYPE_LABELS[kind]() }))}
195
+ onValueChange={(v: string) => (type = v as FieldType)}
196
+ />
197
+ {#if editing}
198
+ <span class="hint">{t('field_type_fixed')}</span>
199
+ {/if}
200
+ </label>
201
+
202
+ <label class="grid gap-1">
203
+ <span class="lbl">{t('field_description')}</span>
204
+ <Textarea bind:value={description} rows={2} />
205
+ </label>
206
+
207
+ {#if HAS_OPTIONS.has(type)}
208
+ <div class="grid gap-1">
209
+ <span class="lbl">{t('field_options')}</span>
210
+ {#each options as option, i (option.id)}
211
+ <div class="orow">
212
+ <Input
213
+ value={option.label}
214
+ placeholder={t('field_option_label')}
215
+ oninput={(e: Event) => {
216
+ const value = (e.currentTarget as HTMLInputElement).value
217
+ options = options.map((o, j) => (i === j ? { ...o, label: value } : o))
218
+ }}
219
+ />
220
+ <IconButton
221
+ icon="x"
222
+ size={26}
223
+ label={t('field_option_remove')}
224
+ onclick={() => (options = options.filter((_, j) => j !== i))}
225
+ />
226
+ </div>
227
+ {/each}
228
+ <div>
229
+ <Button size="sm" variant="ghost" onclick={addOption}>{t('field_option_add')}</Button>
230
+ </div>
231
+ {#if !optionsValid}
232
+ <span class="hint bad">{t('field_options_required')}</span>
233
+ {/if}
234
+ </div>
235
+ {/if}
236
+
237
+ {#if type === 'number'}
238
+ <div class="two">
239
+ <label class="grid gap-1">
240
+ <span class="lbl">{t('field_min')}</span>
241
+ <Input bind:value={minText} type="number" />
242
+ </label>
243
+ <label class="grid gap-1">
244
+ <span class="lbl">{t('field_max')}</span>
245
+ <Input bind:value={maxText} type="number" />
246
+ </label>
247
+ </div>
248
+ {/if}
249
+
250
+ {#if type === 'text' || type === 'textarea'}
251
+ <div class="two">
252
+ <label class="grid gap-1">
253
+ <span class="lbl">{t('field_maxlength')}</span>
254
+ <Input bind:value={maxLengthText} type="number" />
255
+ </label>
256
+ <label class="grid gap-1">
257
+ <span class="lbl">{t('field_pattern')}</span>
258
+ <Input bind:value={pattern} placeholder="^[A-Z]{2}-\d+$" />
259
+ {#if !patternValid}
260
+ <span class="hint bad">{t('field_pattern_invalid')}</span>
261
+ {/if}
262
+ </label>
263
+ </div>
264
+ {/if}
265
+
266
+ <label class="check">
267
+ <Checkbox checked={required} onCheckedChange={(v: boolean) => (required = v)} />
268
+ <span>
269
+ {t('field_required')}
270
+ <span class="hint">{t('field_required_hint')}</span>
271
+ </span>
272
+ </label>
273
+ <label class="check">
274
+ <Checkbox checked={searchable} onCheckedChange={(v: boolean) => (searchable = v)} />
275
+ <span>
276
+ {t('field_searchable')}
277
+ <span class="hint">{t('field_searchable_hint')}</span>
278
+ </span>
279
+ </label>
280
+ <label class="check">
281
+ <Checkbox checked={showInCards} onCheckedChange={(v: boolean) => (showInCards = v)} />
282
+ <span>{t('field_show_in_cards')}</span>
283
+ </label>
284
+ </div>
285
+
286
+ {#snippet footer()}
287
+ <Button variant="ghost" size="sm" onclick={onclose}>{t('common.cancel')}</Button>
288
+ <Button size="sm" disabled={!valid} loading={busy} onclick={save} data-testid="field-save">
289
+ {editing ? t('common.save') : t('common.add')}
290
+ </Button>
291
+ {/snippet}
292
+ </Dialog>
293
+
294
+ <style>
295
+ .lbl {
296
+ font-size: 12px;
297
+ color: var(--kern-ink-550);
298
+ }
299
+ .hint {
300
+ display: block;
301
+ font-size: 11.5px;
302
+ color: var(--kern-ink-400);
303
+ }
304
+ .hint.bad {
305
+ color: var(--kern-danger);
306
+ }
307
+ .two {
308
+ display: grid;
309
+ grid-template-columns: 1fr 1fr;
310
+ gap: 10px;
311
+ }
312
+ .orow {
313
+ display: flex;
314
+ align-items: center;
315
+ gap: 6px;
316
+ }
317
+ .check {
318
+ display: flex;
319
+ align-items: flex-start;
320
+ gap: 8px;
321
+ font-size: 13px;
322
+ cursor: pointer;
323
+ }
324
+ </style>
@@ -0,0 +1,273 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Checkbox,
6
+ EmptyState,
7
+ navigation,
8
+ SettingsPage,
9
+ SettingsSection,
10
+ Spinner,
11
+ session,
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 { FieldDef } from '../index.js'
18
+ import { canTracker } from '../permissions.js'
19
+ import { trackerKeys } from '../query.js'
20
+ import FieldEditor from './FieldEditor.svelte'
21
+
22
+ /**
23
+ * The workspace's custom fields.
24
+ *
25
+ * Creating a field here is what makes the layout editor useful: a field exists once, in one
26
+ * catalogue, and each work item type decides where — or whether — it appears.
27
+ */
28
+ const api = getTrackerApi()
29
+ const queryClient = useQueryClient()
30
+
31
+ const slug = $derived(navigation.workspaceSlug)
32
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
33
+ const canManage = $derived(canTracker('fieldManage'))
34
+
35
+ let editing = $state<FieldDef | null>(null)
36
+ let editorOpen = $state(false)
37
+ let confirmingDelete = $state<FieldDef | null>(null)
38
+ let showArchived = $state(false)
39
+
40
+ const fieldsQuery = createQuery(() => ({
41
+ // The toggle is part of the key: without it the cached list is served and the switch does
42
+ // nothing until something else happens to invalidate it.
43
+ queryKey: [...trackerKeys.fields(workspaceId), showArchived],
44
+ queryFn: () => api.fields.list({ workspaceId, includeArchived: showArchived }),
45
+ enabled: Boolean(workspaceId),
46
+ }))
47
+ const fields = $derived(fieldsQuery.data ?? [])
48
+
49
+ const invalidate = () => {
50
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.fields(workspaceId) })
51
+ // A field's existence changes every type's resolved layout, so those go too.
52
+ void queryClient.invalidateQueries({ queryKey: ['tracker', 'type', workspaceId] })
53
+ }
54
+ const fail = (error: Error) => toast.error(error.message)
55
+
56
+ const save = createMutation(() => ({
57
+ mutationFn: (input: Record<string, unknown>) =>
58
+ editing
59
+ ? api.fields.update({ workspaceId, id: editing.id, patch: input })
60
+ : api.fields.create({ workspaceId, ...(input as { key: string; name: string; type: never }) }),
61
+ onSuccess: () => {
62
+ editorOpen = false
63
+ editing = null
64
+ invalidate()
65
+ },
66
+ onError: fail,
67
+ }))
68
+
69
+ const remove = createMutation(() => ({
70
+ mutationFn: (id: string) => api.fields.delete({ workspaceId, id }),
71
+ onSuccess: () => {
72
+ confirmingDelete = null
73
+ invalidate()
74
+ },
75
+ onError: fail,
76
+ }))
77
+
78
+ /**
79
+ * Archiving, which the page could not do.
80
+ *
81
+ * Deleting a field strips its value off every work item; archiving takes it off every form and
82
+ * leaves what was already recorded. Almost every "we do not use this any more" is the second one,
83
+ * and it was the one that had no control.
84
+ */
85
+ const archive = createMutation(() => ({
86
+ mutationFn: (input: { id: string; archived: boolean }) => api.fields.archive({ workspaceId, ...input }),
87
+ onSuccess: invalidate,
88
+ onError: fail,
89
+ }))
90
+
91
+ const openNew = () => {
92
+ editing = null
93
+ editorOpen = true
94
+ }
95
+ const openEdit = (field: FieldDef) => {
96
+ editing = field
97
+ editorOpen = true
98
+ }
99
+ </script>
100
+
101
+ <SettingsPage title={t('settings_fields')} description={t('settings_fields_hint')}>
102
+ {#snippet actions()}
103
+ <Checkbox
104
+ checked={showArchived}
105
+ label={t('field_show_archived')}
106
+ onCheckedChange={(on: boolean) => (showArchived = on)}
107
+ />
108
+ {#if canManage}
109
+ <Button size="sm" onclick={openNew} data-testid="new-field">{t('field_new')}</Button>
110
+ {/if}
111
+ {/snippet}
112
+
113
+ <SettingsSection flush>
114
+ {#if fieldsQuery.isPending}
115
+ <div class="state"><Spinner /></div>
116
+ {:else if fieldsQuery.isError}
117
+ <div class="state">
118
+ <p>{t('settings_fields_failed')}</p>
119
+ <Button size="sm" variant="ghost" onclick={() => fieldsQuery.refetch()}>{t('common.retry')}</Button>
120
+ </div>
121
+ {:else if !fields.length}
122
+ <EmptyState
123
+ icon="tag"
124
+ title={t('settings_fields_empty')}
125
+ description={t('settings_fields_empty_hint')}
126
+ />
127
+ {:else}
128
+ <ul class="rows" data-testid="field-list">
129
+ {#each fields as field (field.id)}
130
+ <li>
131
+ <button
132
+ type="button"
133
+ class="main"
134
+ disabled={!canManage}
135
+ onclick={() => openEdit(field)}
136
+ data-testid="field-row"
137
+ data-field-key={field.key}
138
+ >
139
+ <span class="fname">{field.name}</span>
140
+ <span class="fkey">cf.{field.key}</span>
141
+ <span class="ftype">{field.type}</span>
142
+ {#if field.required}<Badge tone="warning">{t('field_required')}</Badge>{/if}
143
+ {#if field.projectId}<Badge>{t('field_project_scoped')}</Badge>{/if}
144
+ {#if field.archivedAt}<Badge tone="grey">{t('field_archived')}</Badge>{/if}
145
+ </button>
146
+ {#if canManage}
147
+ {#if field.archivedAt}
148
+ <Button
149
+ size="sm"
150
+ variant="ghost"
151
+ onclick={() => archive.mutate({ id: field.id, archived: false })}
152
+ >
153
+ {t('field_restore')}
154
+ </Button>
155
+ {:else}
156
+ <Button
157
+ size="sm"
158
+ variant="ghost"
159
+ onclick={() => archive.mutate({ id: field.id, archived: true })}
160
+ data-testid="field-archive"
161
+ >
162
+ {t('common.archive')}
163
+ </Button>
164
+ {/if}
165
+ <Button
166
+ size="sm"
167
+ variant="ghost"
168
+ onclick={() => (confirmingDelete = field)}
169
+ data-testid="field-delete"
170
+ >
171
+ {t('common.delete')}
172
+ </Button>
173
+ {/if}
174
+ </li>
175
+ {/each}
176
+ </ul>
177
+ {/if}
178
+ </SettingsSection>
179
+
180
+ {#if confirmingDelete}
181
+ <!-- Inline rather than window.confirm, and it names the consequence: deleting a field strips
182
+ its value from every issue that has one, which no undo brings back. -->
183
+ <SettingsSection tone="danger" title={t('field_delete_title', { name: confirmingDelete.name })}>
184
+ <p class="warn">{t('field_delete_body')}</p>
185
+ <div class="row">
186
+ <Button
187
+ size="sm"
188
+ variant="danger"
189
+ loading={remove.isPending}
190
+ onclick={() => remove.mutate(confirmingDelete!.id)}
191
+ data-testid="field-delete-confirm"
192
+ >
193
+ {t('common.delete')}
194
+ </Button>
195
+ <Button size="sm" variant="ghost" onclick={() => (confirmingDelete = null)}>{t('common.cancel')}</Button>
196
+ </div>
197
+ </SettingsSection>
198
+ {/if}
199
+ </SettingsPage>
200
+
201
+ <FieldEditor
202
+ open={editorOpen}
203
+ field={editing}
204
+ busy={save.isPending}
205
+ onclose={() => {
206
+ editorOpen = false
207
+ editing = null
208
+ }}
209
+ onsave={(input) => save.mutate(input)}
210
+ />
211
+
212
+ <style>
213
+ .state {
214
+ display: grid;
215
+ place-items: center;
216
+ gap: 8px;
217
+ padding: 32px;
218
+ font-size: 13px;
219
+ }
220
+ .rows {
221
+ list-style: none;
222
+ margin: 0;
223
+ padding: 0;
224
+ }
225
+ .rows li {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 8px;
229
+ padding-inline-end: 12px;
230
+ border-bottom: 1px solid var(--kern-border-hairline);
231
+ }
232
+ .rows li:last-child {
233
+ border-bottom: 0;
234
+ }
235
+ .main {
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 10px;
239
+ flex: 1;
240
+ min-width: 0;
241
+ padding: 10px 18px;
242
+ border: 0;
243
+ background: none;
244
+ color: inherit;
245
+ font: inherit;
246
+ text-align: start;
247
+ cursor: pointer;
248
+ }
249
+ .main:disabled {
250
+ cursor: default;
251
+ }
252
+ .main:not(:disabled):hover {
253
+ background: var(--kern-surface-hover);
254
+ }
255
+ .fname {
256
+ font-size: 13px;
257
+ color: var(--kern-ink-800);
258
+ }
259
+ .fkey,
260
+ .ftype {
261
+ font-family: var(--kern-font-mono);
262
+ font-size: 11.5px;
263
+ color: var(--kern-ink-400);
264
+ }
265
+ .warn {
266
+ margin: 0 0 10px;
267
+ font-size: 13px;
268
+ }
269
+ .row {
270
+ display: flex;
271
+ gap: 8px;
272
+ }
273
+ </style>