@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,306 @@
1
+ <script lang="ts">
2
+ import { Button, Icon, IconButton, Input, relativeTime, session, toast } from '@kernhq/ui'
3
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { getTrackerCatalogue } from '../context.svelte.js'
6
+ import { t } from '../i18n.js'
7
+ import { formatDuration, type Timer, type Worklog } from '../index.js'
8
+ import { canTracker } from '../permissions.js'
9
+ import { trackerKeys } from '../query.js'
10
+ import { parseDuration } from '../time.js'
11
+
12
+ /**
13
+ * Time spent on an issue: a running timer, and the work already logged.
14
+ *
15
+ * The server has had both since the module existed and nothing offered either, so `timeSpentSec`
16
+ * on an issue could only ever be zero. A timer belongs to a person rather than to an issue —
17
+ * starting one anywhere stops the one you had running — so the control says which issue it is on.
18
+ */
19
+ interface Props {
20
+ workspaceId: string
21
+ issueId: string
22
+ canLog: boolean
23
+ }
24
+ let { workspaceId, issueId, canLog }: Props = $props()
25
+
26
+ const api = getTrackerApi()
27
+ const cat = getTrackerCatalogue()
28
+ const queryClient = useQueryClient()
29
+
30
+ let manual = $state('')
31
+ let adding = $state(false)
32
+
33
+ const worklogsQuery = createQuery(() => ({
34
+ queryKey: trackerKeys.worklogs(workspaceId, issueId),
35
+ queryFn: () => api.worklogs.list({ workspaceId, issueId }),
36
+ }))
37
+ const timerQuery = createQuery(() => ({
38
+ queryKey: trackerKeys.timer(workspaceId),
39
+ queryFn: () => api.worklogs.timers.current({ workspaceId }),
40
+ // A timer runs while nobody is looking, so its elapsed time has to be refreshed rather than read
41
+ // once and left to drift.
42
+ refetchInterval: 30_000,
43
+ }))
44
+
45
+ const worklogs = $derived(worklogsQuery.data ?? [])
46
+ const timer = $derived((timerQuery.data ?? null) as Timer | null)
47
+ const onThisIssue = $derived(timer?.issueId === issueId)
48
+ const total = $derived(worklogs.reduce((sum, w) => sum + w.durationSec, 0))
49
+
50
+ const refresh = () => {
51
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.worklogs(workspaceId, issueId) })
52
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.timer(workspaceId) })
53
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.issue(workspaceId, issueId) })
54
+ }
55
+ const fail = (error: Error) => toast.error(error.message)
56
+
57
+ const start = createMutation(() => ({
58
+ mutationFn: () => api.worklogs.timers.start({ workspaceId, issueId }),
59
+ onSuccess: refresh,
60
+ onError: fail,
61
+ }))
62
+ const stop = createMutation(() => ({
63
+ mutationFn: (discard: boolean) => api.worklogs.timers.stop({ workspaceId, discard }),
64
+ onSuccess: refresh,
65
+ onError: fail,
66
+ }))
67
+ const log = createMutation(() => ({
68
+ mutationFn: (durationSec: number) =>
69
+ api.worklogs.create({ workspaceId, issueId, durationSec, adjustRemaining: 'auto' }),
70
+ onSuccess: () => {
71
+ manual = ''
72
+ adding = false
73
+ refresh()
74
+ },
75
+ onError: fail,
76
+ }))
77
+ const remove = createMutation(() => ({
78
+ mutationFn: (id: string) => api.worklogs.delete({ workspaceId, id }),
79
+ onSuccess: refresh,
80
+ onError: fail,
81
+ }))
82
+
83
+ /**
84
+ * Correcting an entry.
85
+ *
86
+ * `worklogs.update` existed from the start and nothing called it, so "2h" typed where "20m" was
87
+ * meant could only be deleted and logged again — which moves the entry to now and loses the day it
88
+ * was worked.
89
+ */
90
+ const edit = createMutation(() => ({
91
+ mutationFn: (input: { id: string; durationSec: number }) =>
92
+ api.worklogs.update({ workspaceId, id: input.id, patch: { durationSec: input.durationSec } }),
93
+ onSuccess: () => {
94
+ editingId = null
95
+ refresh()
96
+ },
97
+ onError: fail,
98
+ }))
99
+
100
+ let editingId = $state<string | null>(null)
101
+ let editDraft = $state('')
102
+ const editParsed = $derived(parseDuration(editDraft))
103
+
104
+ const startEdit = (entry: { id: string; durationSec: number }) => {
105
+ editingId = entry.id
106
+ editDraft = formatDuration(entry.durationSec)
107
+ }
108
+ const commitEdit = () => {
109
+ const id = editingId
110
+ if (!id || editParsed === null) return
111
+ edit.mutate({ id, durationSec: editParsed })
112
+ }
113
+
114
+ const parsed = $derived(parseDuration(manual))
115
+ const elapsed = $derived(
116
+ timer ? Math.max(0, Math.floor((Date.now() - Date.parse(timer.startedAt)) / 1000)) : 0,
117
+ )
118
+ const who = (userId: string) =>
119
+ userId === session.user?.id ? t('time_you') : (cat.person(userId)?.name ?? '')
120
+ </script>
121
+
122
+ <section class="time" data-testid="issue-time">
123
+ <div class="thead">
124
+ <span class="kern-sublabel">{t('time_title')}</span>
125
+ {#if total > 0}<span class="total">{formatDuration(total)}</span>{/if}
126
+ </div>
127
+
128
+ {#if canLog}
129
+ <div class="row">
130
+ {#if onThisIssue}
131
+ <Button size="sm" onclick={() => stop.mutate(false)} data-testid="timer-stop">
132
+ <Icon name="clock" size={13} strokeWidth={1.9} />
133
+ {t('time_stop', { elapsed: formatDuration(elapsed) })}
134
+ </Button>
135
+ <Button size="sm" variant="ghost" onclick={() => stop.mutate(true)}>
136
+ {t('time_discard')}
137
+ </Button>
138
+ {:else}
139
+ <Button size="sm" variant="secondary" onclick={() => start.mutate()} data-testid="timer-start">
140
+ <Icon name="clock" size={13} strokeWidth={1.9} />
141
+ {t('time_start')}
142
+ </Button>
143
+ {#if timer}
144
+ <!-- A timer belongs to a person, so starting one here stops the one running elsewhere. -->
145
+ <span class="elsewhere">{t('time_running_elsewhere')}</span>
146
+ {/if}
147
+ {/if}
148
+
149
+ {#if adding}
150
+ <Input
151
+ bind:value={manual}
152
+ placeholder={t('time_placeholder')}
153
+ data-testid="time-amount"
154
+ onkeydown={(e: KeyboardEvent) => {
155
+ if (e.key === 'Enter' && parsed) log.mutate(parsed)
156
+ if (e.key === 'Escape') adding = false
157
+ }}
158
+ />
159
+ <Button size="sm" disabled={!parsed} onclick={() => parsed && log.mutate(parsed)} data-testid="time-log">
160
+ {t('time_log')}
161
+ </Button>
162
+ {:else}
163
+ <button type="button" class="manual" onclick={() => (adding = true)}>
164
+ {t('time_log_manually')}
165
+ </button>
166
+ {/if}
167
+ </div>
168
+ {/if}
169
+
170
+ {#if worklogs.length}
171
+ <ul class="logs">
172
+ {#each worklogs as entry (entry.id)}
173
+ <li>
174
+ {#if editingId === entry.id}
175
+ <input
176
+ class="edur"
177
+ value={editDraft}
178
+ aria-label={t('time_edit')}
179
+ data-testid="worklog-edit"
180
+ oninput={(e) => (editDraft = e.currentTarget.value)}
181
+ onblur={commitEdit}
182
+ onkeydown={(e) => {
183
+ if (e.key === 'Enter') commitEdit()
184
+ if (e.key === 'Escape') editingId = null
185
+ }}
186
+ />
187
+ {:else if canLog && entry.userId === session.user?.id}
188
+ <button
189
+ type="button"
190
+ class="dur edit"
191
+ title={t('time_edit')}
192
+ onclick={() => startEdit(entry)}
193
+ data-testid="worklog-duration"
194
+ >
195
+ {formatDuration(entry.durationSec)}
196
+ </button>
197
+ {:else}
198
+ <span class="dur">{formatDuration(entry.durationSec)}</span>
199
+ {/if}
200
+ <span class="by">{who(entry.userId)}</span>
201
+ <time datetime={entry.startedAt}>{relativeTime(entry.startedAt)}</time>
202
+ {#if entry.note}<span class="note">{entry.note}</span>{/if}
203
+ {#if canLog && entry.userId === session.user?.id}
204
+ <IconButton
205
+ icon="x"
206
+ size={22}
207
+ label={t('time_remove')}
208
+ onclick={() => remove.mutate(entry.id)}
209
+ />
210
+ {/if}
211
+ </li>
212
+ {/each}
213
+ </ul>
214
+ {:else if !canLog}
215
+ <p class="empty">{t('time_empty')}</p>
216
+ {/if}
217
+ </section>
218
+
219
+ <style>
220
+ .time {
221
+ margin-top: 16px;
222
+ }
223
+ .thead {
224
+ display: flex;
225
+ align-items: baseline;
226
+ justify-content: space-between;
227
+ }
228
+ .edit {
229
+ padding: 1px 4px;
230
+ margin-inline-start: -4px;
231
+ border: 0;
232
+ border-radius: var(--kern-r-sm);
233
+ background: none;
234
+ color: inherit;
235
+ font: inherit;
236
+ cursor: pointer;
237
+ }
238
+ .edit:hover {
239
+ background: var(--kern-surface-hover);
240
+ }
241
+ .edur {
242
+ width: 72px;
243
+ padding: 1px 4px;
244
+ border: 1px solid var(--kern-border);
245
+ border-radius: var(--kern-r-sm);
246
+ background: var(--kern-surface);
247
+ color: inherit;
248
+ font: inherit;
249
+ }
250
+ .total {
251
+ font-size: 13px;
252
+ font-weight: 600;
253
+ }
254
+ .row {
255
+ display: flex;
256
+ align-items: center;
257
+ gap: 6px;
258
+ margin-top: 6px;
259
+ flex-wrap: wrap;
260
+ }
261
+ .manual,
262
+ .elsewhere {
263
+ border: 0;
264
+ background: none;
265
+ color: var(--kern-ink-350);
266
+ font: inherit;
267
+ font-size: 12px;
268
+ padding: 2px 4px;
269
+ }
270
+ .manual {
271
+ cursor: pointer;
272
+ }
273
+ .manual:hover {
274
+ color: var(--kern-ink-900);
275
+ }
276
+ .logs {
277
+ list-style: none;
278
+ margin: 8px 0 0;
279
+ padding: 0;
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 3px;
283
+ }
284
+ .logs li {
285
+ display: flex;
286
+ align-items: center;
287
+ gap: 8px;
288
+ font-size: 12.5px;
289
+ }
290
+ .dur {
291
+ font-weight: 600;
292
+ min-width: 42px;
293
+ }
294
+ .by {
295
+ color: var(--kern-ink-550);
296
+ }
297
+ .note,
298
+ time {
299
+ color: var(--kern-ink-350);
300
+ }
301
+ .empty {
302
+ margin: 6px 0 0;
303
+ font-size: 13px;
304
+ color: var(--kern-ink-350);
305
+ }
306
+ </style>
@@ -0,0 +1,295 @@
1
+ <script lang="ts">
2
+ import { Icon } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import type { KqlField, KqlSuggestion } from '../index.js'
5
+ import { parseKql } from '../index.js'
6
+
7
+ /**
8
+ * The KQL box in the toolbar.
9
+ *
10
+ * It validates as you type with the same reader the server uses, so a mistake is reported next to
11
+ * the caret instead of after a round trip, and it offers the next token: a field, then an operator,
12
+ * then the values that field actually has. Enter applies the query; a query that does not parse is
13
+ * never applied, so the list cannot silently show the wrong thing.
14
+ */
15
+ interface Props {
16
+ /** the applied query; only updated on submit or clear */
17
+ value: string
18
+ fields: KqlField[]
19
+ onapply: (kql: string) => void
20
+ }
21
+ let { value, fields, onapply }: Props = $props()
22
+
23
+ let draft = $state('')
24
+ let cursor = $state(0)
25
+ let focused = $state(false)
26
+ let highlighted = $state(0)
27
+ let input = $state<HTMLInputElement | null>(null)
28
+ let box = $state<HTMLDivElement | null>(null)
29
+ /**
30
+ * The toolbar clips its own overflow, so the error and the suggestions are positioned against the
31
+ * viewport and measured from the field rather than being laid out inside it.
32
+ */
33
+ let anchor = $state({ top: 0, left: 0, width: 0 })
34
+ function measure() {
35
+ const r = box?.getBoundingClientRect()
36
+ if (r) anchor = { top: r.bottom + 6, left: r.left, width: r.width }
37
+ }
38
+
39
+ // adopt the applied query when it changes from elsewhere (a preset tab, a cleared filter)
40
+ $effect(() => {
41
+ draft = value
42
+ })
43
+
44
+ const parsed = $derived(parseKql(draft, { fields, cursor }))
45
+ const suggestions = $derived(focused ? parsed.suggestions.slice(0, 8) : [])
46
+ const problem = $derived(draft.trim() && !parsed.ok ? (parsed.errors[0] ?? null) : null)
47
+ /**
48
+ * A half-typed query is not a mistake. While there is something useful to offer, the box guides;
49
+ * only when nothing fits does it say what is wrong. Enter refuses either way until it parses.
50
+ */
51
+ const error = $derived(problem && suggestions.length === 0 ? problem : null)
52
+
53
+ $effect(() => {
54
+ if (highlighted > suggestions.length - 1) highlighted = 0
55
+ })
56
+
57
+ function accept(suggestion: KqlSuggestion) {
58
+ // replace the partial token under the caret rather than appending to it
59
+ const before = draft.slice(0, cursor)
60
+ const tokenStart = before.search(/[\w.]+$/)
61
+ const head = tokenStart >= 0 ? before.slice(0, tokenStart) : before
62
+ draft = head + suggestion.insertText + draft.slice(cursor)
63
+ const next = (head + suggestion.insertText).length
64
+ queueMicrotask(() => {
65
+ input?.setSelectionRange(next, next)
66
+ input?.focus()
67
+ cursor = next
68
+ })
69
+ }
70
+
71
+ function onkeydown(event: KeyboardEvent) {
72
+ if (suggestions.length && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
73
+ event.preventDefault()
74
+ const delta = event.key === 'ArrowDown' ? 1 : -1
75
+ highlighted = (highlighted + delta + suggestions.length) % suggestions.length
76
+ return
77
+ }
78
+ if (event.key === 'Tab' && suggestions.length) {
79
+ event.preventDefault()
80
+ const picked = suggestions[highlighted]
81
+ if (picked) accept(picked)
82
+ return
83
+ }
84
+ if (event.key === 'Enter') {
85
+ event.preventDefault()
86
+ if (suggestions.length && highlighted > 0) {
87
+ const picked = suggestions[highlighted]
88
+ if (picked) accept(picked)
89
+ return
90
+ }
91
+ if (parsed.ok) {
92
+ onapply(draft.trim())
93
+ input?.blur()
94
+ }
95
+ return
96
+ }
97
+ if (event.key === 'Escape') {
98
+ if (focused) {
99
+ event.stopPropagation()
100
+ draft = value
101
+ input?.blur()
102
+ }
103
+ }
104
+ }
105
+
106
+ function track(event: Event) {
107
+ cursor = (event.currentTarget as HTMLInputElement).selectionStart ?? draft.length
108
+ measure()
109
+ }
110
+ </script>
111
+
112
+ <svelte:window onresize={() => focused && measure()} />
113
+
114
+ <div class="kkql" class:invalid={Boolean(error)} bind:this={box}>
115
+ <Icon name="search" size={14} strokeWidth={1.7} />
116
+ <input
117
+ bind:this={input}
118
+ bind:value={draft}
119
+ type="text"
120
+ spellcheck="false"
121
+ autocomplete="off"
122
+ data-testid="kql-input"
123
+ placeholder={t('kql_placeholder')}
124
+ aria-label={t('kql_label')}
125
+ aria-invalid={error ? 'true' : undefined}
126
+ aria-describedby={error ? 'kql-error' : undefined}
127
+ onfocus={() => {
128
+ focused = true
129
+ measure()
130
+ }}
131
+ onblur={() => setTimeout(() => (focused = false), 120)}
132
+ oninput={track}
133
+ onclick={track}
134
+ onkeyup={track}
135
+ {onkeydown}
136
+ />
137
+ {#if draft}
138
+ <button
139
+ type="button"
140
+ class="clear"
141
+ aria-label={t('kql_clear')}
142
+ onclick={() => {
143
+ draft = ''
144
+ onapply('')
145
+ }}
146
+ >
147
+ <Icon name="x" size={12} strokeWidth={2} />
148
+ </button>
149
+ {/if}
150
+
151
+ {#if focused && suggestions.length}
152
+ <ul class="sug" style:top="{anchor.top}px" style:left="{anchor.left}px" style:width="{anchor.width}px">
153
+ {#each suggestions as suggestion, i (suggestion.kind + suggestion.label)}
154
+ <li>
155
+ <button
156
+ type="button"
157
+ class:on={i === highlighted}
158
+ onmousedown={(e) => {
159
+ e.preventDefault()
160
+ accept(suggestion)
161
+ }}
162
+ >
163
+ <span class="kind">{suggestion.kind}</span>
164
+ <span class="lbl">{suggestion.label}</span>
165
+ {#if suggestion.detail}<span class="det">{suggestion.detail}</span>{/if}
166
+ </button>
167
+ </li>
168
+ {/each}
169
+ </ul>
170
+ {:else if error}
171
+ <p
172
+ class="err"
173
+ id="kql-error"
174
+ role="status"
175
+ style:top="{anchor.top}px"
176
+ style:left="{anchor.left}px"
177
+ style:width="{anchor.width}px"
178
+ >
179
+ {error.message}
180
+ </p>
181
+ {/if}
182
+ </div>
183
+
184
+ <style>
185
+ .kkql {
186
+ position: relative;
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 8px;
190
+ height: 30px;
191
+ min-width: 180px;
192
+ flex: 1 1 240px;
193
+ max-width: 460px;
194
+ padding: 0 10px;
195
+ border: 1px solid var(--kern-border);
196
+ border-radius: var(--kern-r-lg);
197
+ background: var(--kern-surface-input);
198
+ color: var(--kern-ink-250);
199
+ }
200
+ .kkql:focus-within {
201
+ border-color: var(--kern-accent);
202
+ box-shadow: 0 0 0 3px var(--kern-ring);
203
+ }
204
+ .kkql.invalid {
205
+ border-color: var(--kern-danger);
206
+ }
207
+ input {
208
+ flex: 1;
209
+ min-width: 0;
210
+ border: 0;
211
+ background: transparent;
212
+ font-family: var(--kern-font-mono);
213
+ font-size: 12.5px;
214
+ letter-spacing: -0.01em;
215
+ color: var(--kern-ink-800);
216
+ }
217
+ input:focus-visible {
218
+ box-shadow: none;
219
+ }
220
+ .clear {
221
+ display: grid;
222
+ place-items: center;
223
+ color: var(--kern-ink-350);
224
+ }
225
+ .clear:hover {
226
+ color: var(--kern-ink-900);
227
+ }
228
+ .err,
229
+ .sug {
230
+ position: fixed;
231
+ z-index: 30;
232
+ margin: 0;
233
+ background: var(--kern-surface-raised);
234
+ border-radius: var(--kern-r-2xl);
235
+ box-shadow: var(--kern-shadow-popover);
236
+ animation: kfade 0.12s ease-out;
237
+ }
238
+ .err {
239
+ padding: 8px 12px;
240
+ font-size: 12.5px;
241
+ color: var(--kern-danger);
242
+ }
243
+ .sug {
244
+ list-style: none;
245
+ padding: 4px;
246
+ /* the field is narrow; the list is not bound by it */
247
+ min-width: 340px;
248
+ max-width: min(520px, 90vw);
249
+ max-height: 260px;
250
+ overflow-y: auto;
251
+ }
252
+ .sug button {
253
+ display: flex;
254
+ align-items: baseline;
255
+ gap: 9px;
256
+ width: 100%;
257
+ padding: 6px 9px;
258
+ border-radius: var(--kern-r-md);
259
+ text-align: start;
260
+ }
261
+ .sug button:hover,
262
+ .sug button.on {
263
+ background: var(--kern-surface-popover-hover);
264
+ }
265
+ .kind {
266
+ font-family: var(--kern-font-mono);
267
+ font-size: 10px;
268
+ text-transform: uppercase;
269
+ letter-spacing: 0.1em;
270
+ color: var(--kern-ink-250);
271
+ width: 58px;
272
+ flex: none;
273
+ }
274
+ .lbl {
275
+ font-family: var(--kern-font-mono);
276
+ font-size: 12.5px;
277
+ color: var(--kern-ink-800);
278
+ white-space: nowrap;
279
+ }
280
+ .det {
281
+ font-size: 12px;
282
+ color: var(--kern-ink-350);
283
+ margin-inline-start: auto;
284
+ padding-inline-start: 12px;
285
+ min-width: 0;
286
+ overflow: hidden;
287
+ text-overflow: ellipsis;
288
+ white-space: nowrap;
289
+ }
290
+ @media (max-width: 900px) {
291
+ .kkql {
292
+ display: none;
293
+ }
294
+ }
295
+ </style>