@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,293 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Icon,
6
+ IconButton,
7
+ Input,
8
+ navigation,
9
+ Select,
10
+ SettingsPage,
11
+ SettingsSection,
12
+ Spinner,
13
+ session,
14
+ } from '@kernhq/ui'
15
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
16
+ import { getTrackerApi } from '../api-instance.js'
17
+ import { t } from '../i18n.js'
18
+ import type { RecurrenceRule } from '../index.js'
19
+ import { canTracker } from '../permissions.js'
20
+ import { trackerKeys } from '../query.js'
21
+ import { describeRecurrence, type RecurrenceStrings } from '../recurrence.js'
22
+ import { listMutation } from './mutations'
23
+
24
+ /**
25
+ * Work that comes back: issue templates, and issues that create themselves.
26
+ *
27
+ * Both had a server and no screen. A recurring issue in particular is something you set up once and
28
+ * then have to trust — so what it will do is spelled out in a sentence, and when it last ran and
29
+ * what it made are shown beside it, because a schedule you cannot check is a schedule you cannot
30
+ * rely on.
31
+ */
32
+ const api = getTrackerApi()
33
+ const queryClient = useQueryClient()
34
+
35
+ const slug = $derived(navigation.workspaceSlug)
36
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
37
+ const canManage = $derived(canTracker('projectManage'))
38
+
39
+ /**
40
+ * Which project the page is on, and how a link can say so.
41
+ *
42
+ * `?project=<id>` is what the sidebar's project menu links to — landing on the project you clicked
43
+ * rather than on whichever one happens to be first. An id for a project this workspace does not
44
+ * have is ignored rather than obeyed, so a stale link opens the page instead of an empty one.
45
+ */
46
+ let selectedProject = $state<string | null>(null)
47
+ const asked = $derived(navigation.search.project)
48
+ let newName = $state('')
49
+ let freq = $state<RecurrenceRule['freq']>('weekly')
50
+ let at = $state('09:00')
51
+
52
+ const projectsQuery = createQuery(() => ({
53
+ queryKey: trackerKeys.projects(workspaceId),
54
+ queryFn: () => api.projects.list({ workspaceId }),
55
+ enabled: Boolean(workspaceId),
56
+ }))
57
+ const projects = $derived(projectsQuery.data ?? [])
58
+ const projectId = $derived(
59
+ selectedProject ?? (asked && projects.some((p) => p.id === asked) ? asked : (projects[0]?.id ?? '')),
60
+ )
61
+
62
+ const templatesQuery = createQuery(() => ({
63
+ queryKey: [...trackerKeys.projects(workspaceId), 'issue-templates', projectId],
64
+ queryFn: () => api.issues.templates.list({ workspaceId, projectId }),
65
+ enabled: Boolean(projectId),
66
+ }))
67
+ const recurringQuery = createQuery(() => ({
68
+ queryKey: [...trackerKeys.projects(workspaceId), 'recurring', projectId],
69
+ queryFn: () => api.issues.recurring.list({ workspaceId, projectId }),
70
+ enabled: Boolean(projectId),
71
+ }))
72
+
73
+ const refresh = () => queryClient.invalidateQueries({ queryKey: trackerKeys.projects(workspaceId) })
74
+
75
+ const addRecurring = listMutation(
76
+ (input: { name: string; rule: RecurrenceRule }) =>
77
+ api.issues.recurring.create({
78
+ workspaceId,
79
+ projectId,
80
+ name: input.name,
81
+ rule: input.rule,
82
+ defaults: { title: input.name },
83
+ } as never),
84
+ refresh,
85
+ )
86
+ const toggleRecurring = listMutation(
87
+ (input: { id: string; enabled: boolean }) =>
88
+ api.issues.recurring.update({ workspaceId, id: input.id, patch: { enabled: input.enabled } }),
89
+ refresh,
90
+ )
91
+ const removeRecurring = listMutation(
92
+ (id: string) => api.issues.recurring.delete({ workspaceId, id }),
93
+ refresh,
94
+ )
95
+ const removeTemplate = listMutation((id: string) => api.issues.templates.delete({ workspaceId, id }), refresh)
96
+
97
+ /** The wording, handed to the describer so the sentence can be built and tested separately. */
98
+ const strings: RecurrenceStrings = {
99
+ every: (unit) => t('recur_every', { unit }),
100
+ everyN: (n, unit) => t('recur_every_n', { n, unit }),
101
+ day: t('recur_day'),
102
+ week: t('recur_week'),
103
+ month: t('recur_month'),
104
+ year: t('recur_year'),
105
+ on: (when, days) => t('recur_on', { when, days }),
106
+ dayOfMonth: (day) => t('recur_day_of_month', { day }),
107
+ at: (when, time) => t('recur_at', { when, time }),
108
+ times: (text, count) => t('recur_times', { text, count }),
109
+ until: (text, date) => t('recur_until', { text, date }),
110
+ }
111
+
112
+ const add = () => {
113
+ const name = newName.trim()
114
+ if (!name) return
115
+ addRecurring.mutate({ name, rule: { freq, interval: 1, at } as RecurrenceRule })
116
+ newName = ''
117
+ }
118
+
119
+ const when = (iso: string | null) => (iso ? iso.slice(0, 16).replace('T', ' ') : t('repeat_never'))
120
+ </script>
121
+
122
+ <SettingsPage title={t('settings_repeating')} description={t('settings_repeating_hint')}>
123
+ {#if projectsQuery.isPending}
124
+ <SettingsSection><div class="state"><Spinner /></div></SettingsSection>
125
+ {:else if !projects.length}
126
+ <SettingsSection><p class="state">{t('settings_planning_no_projects')}</p></SettingsSection>
127
+ {:else}
128
+ <SettingsSection title={t('settings_planning_project')}>
129
+ <Select
130
+ value={projectId}
131
+ options={projects.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` }))}
132
+ onValueChange={(v: string) => (selectedProject = v)}
133
+ />
134
+ </SettingsSection>
135
+
136
+ <SettingsSection title={t('repeat_title')} description={t('repeat_hint')}>
137
+ {#if recurringQuery.isPending}
138
+ <div class="state"><Spinner /></div>
139
+ {:else}
140
+ <ul class="rows" data-testid="recurring-list">
141
+ {#each recurringQuery.data ?? [] as entry (entry.id)}
142
+ <li>
143
+ <div class="what">
144
+ <span class="name">{entry.name}</span>
145
+ <span class="rule">{describeRecurrence(entry.rule, strings)}</span>
146
+ <span class="ran">
147
+ {t('repeat_last_run', { when: when(entry.lastRunAt) })}
148
+ · {t('repeat_next_run', { when: when(entry.nextRunAt) })}
149
+ · {t('repeat_made', { count: entry.runCount })}
150
+ </span>
151
+ </div>
152
+ {#if !entry.enabled}<Badge>{t('repeat_paused')}</Badge>{/if}
153
+ {#if canManage}
154
+ <!-- A verb rather than a switch: "Pause" says what will happen, where a toggle
155
+ asks somebody to work out which way is on. -->
156
+ <Button
157
+ size="sm"
158
+ variant="ghost"
159
+ onclick={() => toggleRecurring.mutate({ id: entry.id, enabled: !entry.enabled })}
160
+ data-testid="recurring-toggle"
161
+ >
162
+ {entry.enabled ? t('repeat_pause') : t('repeat_resume')}
163
+ </Button>
164
+ <IconButton
165
+ icon="x"
166
+ size={22}
167
+ label={t('planning_remove', { name: entry.name })}
168
+ onclick={() => removeRecurring.mutate(entry.id)}
169
+ />
170
+ {/if}
171
+ </li>
172
+ {:else}
173
+ <li class="empty">{t('repeat_empty')}</li>
174
+ {/each}
175
+ </ul>
176
+
177
+ {#if canManage}
178
+ <div class="add">
179
+ <Input bind:value={newName} placeholder={t('repeat_name')} data-testid="recurring-name" />
180
+ <Select
181
+ value={freq}
182
+ options={[
183
+ { value: 'daily', label: t('recur_day') },
184
+ { value: 'weekly', label: t('recur_week') },
185
+ { value: 'monthly', label: t('recur_month') },
186
+ ]}
187
+ onValueChange={(v: string) => (freq = v as RecurrenceRule['freq'])}
188
+ />
189
+ <Input bind:value={at} type="time" aria-label={t('repeat_at')} />
190
+ <Button size="sm" disabled={!newName.trim()} onclick={add} data-testid="recurring-add">
191
+ {t('common.add')}
192
+ </Button>
193
+ </div>
194
+ <p class="note">{t('repeat_note')}</p>
195
+ {/if}
196
+ {/if}
197
+ </SettingsSection>
198
+
199
+ <SettingsSection title={t('template_title')} description={t('template_hint')}>
200
+ {#if templatesQuery.isPending}
201
+ <div class="state"><Spinner /></div>
202
+ {:else}
203
+ <ul class="rows" data-testid="template-list">
204
+ {#each templatesQuery.data ?? [] as template (template.id)}
205
+ <li>
206
+ <div class="what">
207
+ <span class="name">{template.name}</span>
208
+ {#if template.description}<span class="rule">{template.description}</span>{/if}
209
+ {#if template.subItems.length}
210
+ <span class="ran">{t('template_subitems', { count: template.subItems.length })}</span>
211
+ {/if}
212
+ </div>
213
+ {#if canManage}
214
+ <IconButton
215
+ icon="x"
216
+ size={22}
217
+ label={t('planning_remove', { name: template.name })}
218
+ onclick={() => removeTemplate.mutate(template.id)}
219
+ />
220
+ {/if}
221
+ </li>
222
+ {:else}
223
+ <li class="empty">
224
+ <Icon name="bookmark" size={13} strokeWidth={1.8} />
225
+ {t('template_empty')}
226
+ </li>
227
+ {/each}
228
+ </ul>
229
+ {/if}
230
+ </SettingsSection>
231
+ {/if}
232
+ </SettingsPage>
233
+
234
+ <style>
235
+ .state {
236
+ display: grid;
237
+ place-items: center;
238
+ padding: 24px;
239
+ font-size: 13px;
240
+ }
241
+ .rows {
242
+ list-style: none;
243
+ margin: 0;
244
+ padding: 0;
245
+ }
246
+ .rows li {
247
+ display: flex;
248
+ align-items: center;
249
+ gap: 10px;
250
+ padding: 8px 0;
251
+ border-bottom: 1px solid var(--kern-border-hairline);
252
+ }
253
+ .rows li:last-child {
254
+ border-bottom: 0;
255
+ }
256
+ .what {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 1px;
260
+ flex: 1;
261
+ min-width: 0;
262
+ }
263
+ .name {
264
+ font-size: 13px;
265
+ color: var(--kern-ink-800);
266
+ }
267
+ .rule {
268
+ font-size: 12.5px;
269
+ color: var(--kern-ink-550);
270
+ }
271
+ .ran {
272
+ font-size: 11.5px;
273
+ color: var(--kern-ink-400);
274
+ }
275
+ .empty {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 6px;
279
+ font-size: 13px;
280
+ color: var(--kern-ink-400);
281
+ }
282
+ .add {
283
+ display: flex;
284
+ gap: 8px;
285
+ margin-top: 12px;
286
+ flex-wrap: wrap;
287
+ }
288
+ .note {
289
+ margin: 8px 0 0;
290
+ font-size: 12px;
291
+ color: var(--kern-ink-400);
292
+ }
293
+ </style>
@@ -0,0 +1,214 @@
1
+ <script lang="ts">
2
+ import { Button, Checkbox, Dialog, Icon, Input, Select, Textarea } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import type { WorkItemType } from '../index.js'
5
+
6
+ /**
7
+ * Creating or editing one work item type.
8
+ *
9
+ * A type's `key` is fixed after creation for the same reason a field's is: it is what imports, the
10
+ * API and saved queries name it by, so changing it would orphan every one of them.
11
+ *
12
+ * `level` is the one setting whose meaning is not obvious from its name, so the dialog spells out
13
+ * what each level does rather than showing the numbers the contract uses. It decides what may
14
+ * parent what — an epic can hold a story, a story cannot hold an epic.
15
+ */
16
+ interface Props {
17
+ open: boolean
18
+ /** null when creating */
19
+ type: WorkItemType | null
20
+ busy?: boolean
21
+ onclose: () => void
22
+ onsave: (input: Record<string, unknown>) => void
23
+ }
24
+ let { open, type, busy = false, onclose, onsave }: Props = $props()
25
+
26
+ /** The registry holds 106 icons; these are the ones that mean something on a work item type. */
27
+ const ICONS = [
28
+ 'square-check-big',
29
+ 'bug',
30
+ 'bookmark',
31
+ 'zap',
32
+ 'flag',
33
+ 'target',
34
+ 'file-text',
35
+ 'wrench',
36
+ 'shield',
37
+ 'palette',
38
+ 'circle-alert',
39
+ 'briefcase',
40
+ ]
41
+
42
+ const LEVELS: { value: string; label: () => string; description: () => string }[] = [
43
+ { value: '2', label: () => t('level_2'), description: () => t('level_2_hint') },
44
+ { value: '1', label: () => t('level_1'), description: () => t('level_1_hint') },
45
+ { value: '0', label: () => t('level_0'), description: () => t('level_0_hint') },
46
+ { value: '-1', label: () => t('level_sub'), description: () => t('level_sub_hint') },
47
+ ]
48
+
49
+ let name = $state('')
50
+ let key = $state('')
51
+ let description = $state('')
52
+ let icon = $state('square-check-big')
53
+ let level = $state('0')
54
+ let isDefault = $state(false)
55
+ /** Touched once, the key stops following the name — otherwise a rename would rewrite it. */
56
+ let keyEdited = $state(false)
57
+
58
+ const editing = $derived(Boolean(type))
59
+
60
+ $effect(() => {
61
+ if (!open) return
62
+ name = type?.name ?? ''
63
+ key = type?.key ?? ''
64
+ description = type?.description ?? ''
65
+ icon = type?.icon ?? 'square-check-big'
66
+ level = String(type?.level ?? 0)
67
+ isDefault = type?.isDefault ?? false
68
+ keyEdited = Boolean(type)
69
+ })
70
+
71
+ /** `Customer request` → `customer_request`: the key is a machine name, not a sentence. */
72
+ const slugify = (value: string) =>
73
+ value
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9]+/g, '_')
76
+ .replace(/^_+|_+$/g, '')
77
+ .slice(0, 40)
78
+
79
+ $effect(() => {
80
+ if (!keyEdited) key = slugify(name)
81
+ })
82
+
83
+ const keyValid = $derived(/^[a-z][a-z0-9_]*$/.test(key))
84
+ const valid = $derived(Boolean(name.trim()) && keyValid)
85
+
86
+ function save() {
87
+ if (!valid || busy) return
88
+ onsave({
89
+ name: name.trim(),
90
+ description: description.trim() || null,
91
+ icon,
92
+ level: Number(level),
93
+ isDefault,
94
+ // the key is what every import, query and API caller names this type by
95
+ ...(editing ? {} : { key }),
96
+ })
97
+ }
98
+ </script>
99
+
100
+ <Dialog
101
+ {open}
102
+ title={editing ? t('type_edit') : t('type_new')}
103
+ size="md"
104
+ onOpenChange={(next: boolean) => {
105
+ if (!next) onclose()
106
+ }}
107
+ >
108
+ <div class="grid gap-3">
109
+ <label class="grid gap-1">
110
+ <span class="lbl">{t('type_name')}</span>
111
+ <Input bind:value={name} placeholder="Incident" data-testid="type-name" />
112
+ </label>
113
+
114
+ <label class="grid gap-1">
115
+ <span class="lbl">{t('type_key')}</span>
116
+ <Input
117
+ bind:value={key}
118
+ disabled={editing}
119
+ oninput={() => (keyEdited = true)}
120
+ placeholder="incident"
121
+ data-testid="type-key"
122
+ />
123
+ <span class="hint" class:bad={!keyValid && key.length > 0}>
124
+ {editing ? t('type_key_fixed') : t('type_key_hint')}
125
+ </span>
126
+ </label>
127
+
128
+ <div class="grid gap-1">
129
+ <span class="lbl">{t('type_icon')}</span>
130
+ <!-- Twelve buttons rather than a menu of 106 names: an icon is chosen by looking at it. -->
131
+ <div class="icons" data-testid="type-icons">
132
+ {#each ICONS as candidate (candidate)}
133
+ <button
134
+ type="button"
135
+ class="ico"
136
+ class:on={icon === candidate}
137
+ aria-pressed={icon === candidate}
138
+ aria-label={candidate}
139
+ onclick={() => (icon = candidate)}
140
+ >
141
+ <Icon name={candidate} size={16} strokeWidth={1.8} />
142
+ </button>
143
+ {/each}
144
+ </div>
145
+ </div>
146
+
147
+ <label class="grid gap-1">
148
+ <span class="lbl">{t('type_level')}</span>
149
+ <Select
150
+ value={level}
151
+ options={LEVELS.map((l) => ({ value: l.value, label: l.label(), description: l.description() }))}
152
+ onValueChange={(v: string) => (level = v)}
153
+ />
154
+ <span class="hint">{t('type_level_hint')}</span>
155
+ </label>
156
+
157
+ <label class="grid gap-1">
158
+ <span class="lbl">{t('type_description')}</span>
159
+ <Textarea bind:value={description} rows={2} />
160
+ </label>
161
+
162
+ <Checkbox
163
+ checked={isDefault}
164
+ label={t('type_make_default')}
165
+ onCheckedChange={(on: boolean) => (isDefault = on)}
166
+ />
167
+ <span class="hint">{t('type_default_hint')}</span>
168
+ </div>
169
+
170
+ {#snippet footer()}
171
+ <Button variant="ghost" size="sm" onclick={onclose}>{t('common.cancel')}</Button>
172
+ <Button size="sm" disabled={!valid} loading={busy} onclick={save} data-testid="type-save">
173
+ {t('common.save')}
174
+ </Button>
175
+ {/snippet}
176
+ </Dialog>
177
+
178
+ <style>
179
+ .lbl {
180
+ font-size: 12px;
181
+ font-weight: 500;
182
+ color: var(--kern-ink-600);
183
+ }
184
+ .hint {
185
+ font-size: 11.5px;
186
+ color: var(--kern-ink-400);
187
+ }
188
+ .hint.bad {
189
+ color: var(--kern-danger);
190
+ }
191
+ .icons {
192
+ display: flex;
193
+ flex-wrap: wrap;
194
+ gap: 6px;
195
+ }
196
+ .ico {
197
+ display: grid;
198
+ place-items: center;
199
+ width: 32px;
200
+ height: 32px;
201
+ border: 1px solid var(--kern-border-hairline);
202
+ border-radius: var(--kern-r-sm);
203
+ background: none;
204
+ color: var(--kern-ink-600);
205
+ cursor: pointer;
206
+ }
207
+ .ico:hover {
208
+ background: var(--kern-surface-hover);
209
+ }
210
+ .ico.on {
211
+ border-color: var(--kern-accent);
212
+ color: var(--kern-accent);
213
+ }
214
+ </style>