@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,75 @@
1
+ <script lang="ts">
2
+ import { navigation, Skeleton, session } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { statusStyle } from '../index.js'
6
+ import { trackerKeys } from '../query.js'
7
+ import StatusIcon from './StatusIcon.svelte'
8
+
9
+ /**
10
+ * How an issue appears when another module refers to one: a chat message that mentions KRN-12, a
11
+ * document that embeds it, a search result. Registered as the tracker's object presenter, so those
12
+ * surfaces do not need to know anything about issues beyond the id.
13
+ */
14
+ interface Props {
15
+ /** issue id or key */
16
+ id: string
17
+ workspaceId?: string
18
+ }
19
+ let { id, workspaceId }: Props = $props()
20
+
21
+ const api = getTrackerApi()
22
+ const slug = $derived(navigation.workspaceSlug)
23
+ const wsId = $derived(workspaceId ?? session.workspaces.find((w) => w.slug === slug)?.id ?? '')
24
+ const isKey = $derived(/^[A-Z][A-Z0-9]{1,9}-\d+$/.test(id))
25
+
26
+ const issueQuery = createQuery(() => ({
27
+ queryKey: trackerKeys.issue(wsId, id),
28
+ queryFn: () =>
29
+ isKey
30
+ ? api.issues.getByKey({ workspaceId: wsId, key: id })
31
+ : api.issues.get({ workspaceId: wsId, issueId: id }),
32
+ enabled: Boolean(wsId),
33
+ }))
34
+ const issue = $derived(issueQuery.data ?? null)
35
+ </script>
36
+
37
+ {#if !issue}
38
+ <span class="kinline loading"><Skeleton class="h-[14px] w-[140px]" /></span>
39
+ {:else}
40
+ <a class="kinline" href="/{slug}/tracker?issue={issue.key}" style:--tint={statusStyle(issue.statusCategory, issue.statusId).tint}>
41
+ <StatusIcon category={issue.statusCategory} statusId={issue.statusId} size={14} />
42
+ <span class="key">{issue.key}</span>
43
+ <span class="title">{issue.title}</span>
44
+ </a>
45
+ {/if}
46
+
47
+ <style>
48
+ .kinline {
49
+ display: inline-flex;
50
+ align-items: center;
51
+ gap: 6px;
52
+ max-width: 100%;
53
+ padding: 2px 7px;
54
+ border-radius: var(--kern-r-md);
55
+ background: var(--tint, var(--kern-surface-chip));
56
+ font-size: 12.5px;
57
+ color: var(--kern-ink-800);
58
+ vertical-align: baseline;
59
+ }
60
+ .kinline:hover {
61
+ color: var(--kern-ink-900);
62
+ }
63
+ .key {
64
+ font-family: var(--kern-font-mono);
65
+ font-size: 11.5px;
66
+ letter-spacing: -0.01em;
67
+ color: var(--kern-ink-350);
68
+ }
69
+ .title {
70
+ overflow: hidden;
71
+ text-overflow: ellipsis;
72
+ white-space: nowrap;
73
+ max-width: 28ch;
74
+ }
75
+ </style>
@@ -0,0 +1,153 @@
1
+ <script lang="ts">
2
+ import { EmptyState } from '@kernhq/ui'
3
+ import { getTrackerCatalogue } from '../context.svelte.js'
4
+ import { t } from '../i18n.js'
5
+ import { type GroupBy, type GroupKey, groupIssues, type Issue } from '../index.js'
6
+ import { describeGroup } from '../labels.js'
7
+ import GroupHeader from './GroupHeader.svelte'
8
+ import IssueRow from './IssueRow.svelte'
9
+
10
+ /**
11
+ * The issues list (DESIGN.md 3.2).
12
+ *
13
+ * Group headings sit flush under the toolbar and stick while you scroll; empty groups are hidden.
14
+ * Rows are real buttons, so tabbing and screen readers work without any extra wiring, and the page
15
+ * forwards j/k/x/Enter here through `handleKey` so the keyboard works before anything has focus.
16
+ */
17
+ interface Props {
18
+ issues: Issue[]
19
+ /** a built-in group key or `cf.<key>` */
20
+ groupBy: string
21
+ selection: Set<string>
22
+ loading?: boolean
23
+ onopen: (issue: Issue) => void
24
+ onselect: (issue: Issue) => void
25
+ oncreate?: (groupKey: GroupKey) => void
26
+ }
27
+ let { issues, groupBy, selection, loading = false, onopen, onselect, oncreate }: Props = $props()
28
+
29
+ const cat = getTrackerCatalogue()
30
+
31
+ let collapsed = $state<string[]>([])
32
+ let cursor = $state(0)
33
+ let listEl = $state<HTMLDivElement | null>(null)
34
+
35
+ const groups = $derived(
36
+ groupIssues(issues, groupBy, {
37
+ statusOrder: cat.statusOrder,
38
+ statusCategory: cat.statusCategory,
39
+ order: (_by, key) => {
40
+ const i = cat.projects.findIndex((p) => p.id === key)
41
+ return i < 0 ? 500 : i
42
+ },
43
+ }),
44
+ )
45
+
46
+ const keyId = (key: GroupKey) => key ?? '__none__'
47
+ const isCollapsed = (key: GroupKey) => collapsed.includes(keyId(key))
48
+
49
+ /** Rows in visual order, so the cursor moves the way the eye does. */
50
+ const visible = $derived(groups.filter((g) => !isCollapsed(g.key)).flatMap((g) => g.items))
51
+ const indexOf = $derived(new Map(visible.map((issue, i) => [issue.id, i])))
52
+
53
+ function toggleGroup(key: GroupKey) {
54
+ const id = keyId(key)
55
+ collapsed = collapsed.includes(id) ? collapsed.filter((c) => c !== id) : [...collapsed, id]
56
+ }
57
+
58
+ function move(delta: number) {
59
+ if (visible.length === 0) return
60
+ cursor = Math.max(0, Math.min(visible.length - 1, cursor + delta))
61
+ const key = visible[cursor]?.key
62
+ // rows are looked up by key rather than held in an array of refs: groups collapse and the query
63
+ // changes underneath the cursor, and a stale ref would focus a row that is no longer there
64
+ if (key) listEl?.querySelector<HTMLButtonElement>(`[data-issue-key="${CSS.escape(key)}"]`)?.focus()
65
+ }
66
+
67
+ /**
68
+ * Page-level keys for the list. Returns true when the event was consumed, so the page can leave
69
+ * everything else alone.
70
+ */
71
+ export function handleKey(event: KeyboardEvent): boolean {
72
+ const current = visible[cursor]
73
+ switch (event.key) {
74
+ case 'j':
75
+ case 'ArrowDown':
76
+ move(1)
77
+ return true
78
+ case 'k':
79
+ case 'ArrowUp':
80
+ move(-1)
81
+ return true
82
+ case 'x':
83
+ if (!current) return false
84
+ onselect(current)
85
+ return true
86
+ case 'Enter':
87
+ if (!current) return false
88
+ onopen(current)
89
+ return true
90
+ default:
91
+ return false
92
+ }
93
+ }
94
+
95
+ // keep the cursor inside the list when filtering or grouping changes the rows underneath it
96
+ $effect(() => {
97
+ if (cursor > visible.length - 1) cursor = Math.max(0, visible.length - 1)
98
+ })
99
+ </script>
100
+
101
+ <div class="klist" bind:this={listEl}>
102
+ {#if groups.length === 0}
103
+ <div class="empty">
104
+ <EmptyState
105
+ icon="square-check-big"
106
+ title={loading ? t('common.loading') : t('empty_title')}
107
+ description={loading ? undefined : t('empty_body')}
108
+ />
109
+ </div>
110
+ {:else}
111
+ {#each groups as group (keyId(group.key))}
112
+ {@const described = describeGroup(group.key, groupBy, cat)}
113
+ <section>
114
+ <GroupHeader
115
+ label={described.label}
116
+ badge={described.badge}
117
+ count={group.items.length}
118
+ estimate={group.estimate}
119
+ open={!isCollapsed(group.key)}
120
+ onToggle={() => toggleGroup(group.key)}
121
+ onAdd={oncreate ? () => oncreate(group.key) : undefined}
122
+ />
123
+ {#if !isCollapsed(group.key)}
124
+ {#each group.items as issue (issue.id)}
125
+ {@const index = indexOf.get(issue.id) ?? 0}
126
+ <IssueRow
127
+ {issue}
128
+ selected={selection.has(issue.id)}
129
+ active={index === cursor}
130
+ onopen={(i) => {
131
+ cursor = index
132
+ onopen(i)
133
+ }}
134
+ ontoggle={(i) => {
135
+ cursor = index
136
+ onselect(i)
137
+ }}
138
+ />
139
+ {/each}
140
+ {/if}
141
+ </section>
142
+ {/each}
143
+ {/if}
144
+ </div>
145
+
146
+ <style>
147
+ .klist {
148
+ padding-bottom: 40px;
149
+ }
150
+ .empty {
151
+ padding: 40px 28px;
152
+ }
153
+ </style>
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import { Spinner } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getTrackerApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import type { Issue } from '../index.js'
7
+ import { trackerKeys } from '../query.js'
8
+ import StatusIcon from './StatusIcon.svelte'
9
+
10
+ /**
11
+ * Find an issue by key or title.
12
+ *
13
+ * Searching goes through KQL (`text ~ "…"`), the same reader the query box uses, so what is
14
+ * matchable here is exactly what is matchable there — one search behaviour, not two.
15
+ */
16
+ interface Props {
17
+ workspaceId: string
18
+ projectId?: string | null
19
+ /** never offer these — an issue cannot block or parent itself */
20
+ exclude?: string[]
21
+ placeholder?: string
22
+ onpick: (issue: Issue) => void
23
+ oncancel?: () => void
24
+ }
25
+ let { workspaceId, projectId = null, exclude = [], placeholder, onpick, oncancel }: Props = $props()
26
+
27
+ let term = $state('')
28
+ let active = $state(0)
29
+ let box = $state<HTMLInputElement | null>(null)
30
+
31
+ /** Quote for KQL: a title with a `"` in it must not end the string early. */
32
+ const quoted = $derived(term.trim().replace(/["\\]/g, '\\$&'))
33
+
34
+ const results = createQuery(() => ({
35
+ queryKey: trackerKeys.issues(workspaceId, `picker:${projectId ?? 'all'}:${quoted}`),
36
+ queryFn: () =>
37
+ api.issues.query({
38
+ workspaceId,
39
+ kql: `text ~ "${quoted}"`,
40
+ ...(projectId ? { projectIds: [projectId] } : {}),
41
+ limit: 8,
42
+ includeArchived: false,
43
+ include: { total: false, groupCounts: false, full: false },
44
+ }),
45
+ enabled: quoted.length >= 2,
46
+ }))
47
+
48
+ const api = getTrackerApi()
49
+ const excluded = $derived(new Set(exclude))
50
+ const items = $derived((results.data?.items ?? []).filter((i) => !excluded.has(i.id)))
51
+
52
+ function onkeydown(event: KeyboardEvent) {
53
+ if (event.key === 'ArrowDown') {
54
+ event.preventDefault()
55
+ active = items.length ? (active + 1) % items.length : 0
56
+ } else if (event.key === 'ArrowUp') {
57
+ event.preventDefault()
58
+ active = items.length ? (active - 1 + items.length) % items.length : 0
59
+ } else if (event.key === 'Enter') {
60
+ event.preventDefault()
61
+ const chosen = items[active]
62
+ if (chosen) onpick(chosen)
63
+ } else if (event.key === 'Escape') {
64
+ event.preventDefault()
65
+ oncancel?.()
66
+ }
67
+ }
68
+
69
+ $effect(() => {
70
+ box?.focus()
71
+ })
72
+ </script>
73
+
74
+ <div class="picker">
75
+ <input
76
+ bind:this={box}
77
+ bind:value={term}
78
+ type="search"
79
+ placeholder={placeholder ?? t('pick_issue')}
80
+ aria-label={placeholder ?? t('pick_issue')}
81
+ data-testid="issue-picker"
82
+ {onkeydown}
83
+ oninput={() => (active = 0)}
84
+ />
85
+ {#if quoted.length >= 2}
86
+ <ul class="results" role="listbox">
87
+ {#if results.isPending}
88
+ <li class="state"><Spinner size={14} /></li>
89
+ {:else if !items.length}
90
+ <li class="state">{t('pick_issue_none')}</li>
91
+ {:else}
92
+ {#each items as issue, i (issue.id)}
93
+ <li>
94
+ <button
95
+ type="button"
96
+ role="option"
97
+ aria-selected={i === active}
98
+ class:active={i === active}
99
+ onclick={() => onpick(issue)}
100
+ onmouseenter={() => (active = i)}
101
+ >
102
+ <StatusIcon category={issue.statusCategory} statusId={issue.statusId} size={13} />
103
+ <span class="ikey">{issue.key}</span>
104
+ <span class="ititle">{issue.title}</span>
105
+ </button>
106
+ </li>
107
+ {/each}
108
+ {/if}
109
+ </ul>
110
+ {/if}
111
+ </div>
112
+
113
+ <style>
114
+ .picker {
115
+ position: relative;
116
+ }
117
+ input {
118
+ width: 100%;
119
+ padding: 5px 8px;
120
+ border: 1px solid var(--kern-border);
121
+ border-radius: var(--kern-r-sm);
122
+ background: var(--kern-surface);
123
+ color: inherit;
124
+ font: inherit;
125
+ font-size: 13px;
126
+ }
127
+ input:focus-visible {
128
+ outline: none;
129
+ border-color: var(--kern-accent);
130
+ box-shadow: none;
131
+ }
132
+ .results {
133
+ position: absolute;
134
+ inset-inline: 0;
135
+ top: calc(100% + 4px);
136
+ z-index: 20;
137
+ max-height: 220px;
138
+ overflow-y: auto;
139
+ margin: 0;
140
+ padding: 4px;
141
+ list-style: none;
142
+ border: 1px solid var(--kern-border);
143
+ border-radius: var(--kern-r-sm);
144
+ background: var(--kern-surface);
145
+ box-shadow: var(--kern-shadow-popover);
146
+ }
147
+ .results button {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 6px;
151
+ width: 100%;
152
+ padding: 4px 6px;
153
+ border: 0;
154
+ border-radius: var(--kern-r-sm);
155
+ background: none;
156
+ color: inherit;
157
+ font: inherit;
158
+ font-size: 13px;
159
+ text-align: start;
160
+ cursor: pointer;
161
+ }
162
+ .results button.active {
163
+ background: var(--kern-surface-active);
164
+ }
165
+ .ikey {
166
+ font-family: var(--kern-font-mono);
167
+ font-size: 12px;
168
+ color: var(--kern-ink-350);
169
+ }
170
+ .ititle {
171
+ overflow: hidden;
172
+ text-overflow: ellipsis;
173
+ white-space: nowrap;
174
+ }
175
+ .state {
176
+ padding: 6px;
177
+ font-size: 12px;
178
+ color: var(--kern-ink-350);
179
+ }
180
+ </style>
@@ -0,0 +1,171 @@
1
+ <script lang="ts">
2
+ import { Avatar, AvatarStack, Icon } from '@kernhq/ui'
3
+ import { getTrackerCatalogue } from '../context.svelte.js'
4
+ import { t } from '../i18n.js'
5
+ import type { Issue } from '../index.js'
6
+ import { estimateLabel } from '../labels.js'
7
+ import DueDate from './DueDate.svelte'
8
+ import PriorityGlyph from './PriorityGlyph.svelte'
9
+ import StatusIcon from './StatusIcon.svelte'
10
+
11
+ /**
12
+ * One row of the issues list (DESIGN.md 3.2).
13
+ *
14
+ * The grid is fixed so every row lines up down the page: priority, key, status, title, project,
15
+ * estimate, due date, assignee. Selecting swaps the priority cell for a tick, which keeps the
16
+ * columns from shifting when you multi-select.
17
+ */
18
+ interface Props {
19
+ issue: Issue
20
+ selected?: boolean
21
+ active?: boolean
22
+ onopen: (issue: Issue) => void
23
+ ontoggle: (issue: Issue, event: MouseEvent | KeyboardEvent) => void
24
+ }
25
+ let { issue, selected = false, active = false, onopen, ontoggle }: Props = $props()
26
+
27
+ const cat = getTrackerCatalogue()
28
+ const project = $derived(cat.project(issue.projectId))
29
+ const status = $derived(cat.status(issue.statusId))
30
+ const assignees = $derived(issue.assigneeIds.map((id) => cat.person(id)).filter((p) => p !== undefined))
31
+ </script>
32
+
33
+ <button
34
+ type="button"
35
+ class="krow"
36
+ class:selected
37
+ class:active
38
+ data-testid="issue-row"
39
+ data-issue-key={issue.key}
40
+ aria-current={active ? 'true' : undefined}
41
+ onclick={(e) => (e.metaKey || e.ctrlKey || e.shiftKey ? ontoggle(issue, e) : onopen(issue))}
42
+ >
43
+ <span class="cell mark">
44
+ {#if selected}
45
+ <Icon name="check" size={14} strokeWidth={2} />
46
+ {:else}
47
+ <PriorityGlyph priority={issue.priority} size={15} />
48
+ {/if}
49
+ </span>
50
+
51
+ <span class="cell key">{issue.key}</span>
52
+
53
+ <span class="cell st">
54
+ <StatusIcon
55
+ category={issue.statusCategory}
56
+ statusId={issue.statusId}
57
+ name={status?.name}
58
+ size={15}
59
+ label={status?.name}
60
+ />
61
+ </span>
62
+
63
+ <span class="cell title">{issue.title}</span>
64
+
65
+ {#if project}
66
+ <span class="cell chip">{project.name}</span>
67
+ {:else}
68
+ <span class="cell"></span>
69
+ {/if}
70
+
71
+ <span class="cell est">
72
+ {#if issue.estimate !== null}{estimateLabel(issue.estimate, issue.estimateUnit)}{/if}
73
+ </span>
74
+
75
+ <span class="cell due">
76
+ {#if issue.dueDate}<DueDate date={issue.dueDate} />{/if}
77
+ </span>
78
+
79
+ <span class="cell people">
80
+ {#if assignees.length === 1}
81
+ <Avatar name={assignees[0]?.name} src={assignees[0]?.avatarUrl} id={assignees[0]?.id} size={22} />
82
+ {:else if assignees.length > 1}
83
+ <AvatarStack people={assignees} size={22} max={2} />
84
+ {/if}
85
+ </span>
86
+ </button>
87
+
88
+ <style>
89
+ .krow {
90
+ display: grid;
91
+ /* the last column is 24px for one avatar and grows for a stack, rather than overlapping the due date */
92
+ grid-template-columns: 18px 62px 18px minmax(0, 1fr) auto auto auto minmax(24px, auto);
93
+ align-items: center;
94
+ gap: 10px;
95
+ width: 100%;
96
+ height: 40px;
97
+ padding: 0 16px;
98
+ border-bottom: 1px solid var(--kern-border-hairline);
99
+ text-align: start;
100
+ transition: background-color 80ms;
101
+ }
102
+ .krow:hover {
103
+ background: var(--kern-surface-raised);
104
+ }
105
+ /* the keyboard cursor is only drawn while the list has focus, so the list has no extra resting state */
106
+ .krow:focus-visible {
107
+ outline: none;
108
+ background: var(--kern-surface-raised);
109
+ box-shadow: inset 2px 0 0 var(--kern-accent);
110
+ }
111
+ :global([dir='rtl']) .krow:focus-visible {
112
+ box-shadow: inset -2px 0 0 var(--kern-accent);
113
+ }
114
+ .krow.selected {
115
+ background: var(--kern-accent-tint);
116
+ }
117
+ .cell {
118
+ display: flex;
119
+ align-items: center;
120
+ min-width: 0;
121
+ }
122
+ .mark {
123
+ justify-content: center;
124
+ color: var(--kern-accent-deep);
125
+ }
126
+ .key {
127
+ font-family: var(--kern-font-mono);
128
+ letter-spacing: -0.01em;
129
+ font-size: 13px;
130
+ color: var(--kern-ink-350);
131
+ white-space: nowrap;
132
+ overflow: hidden;
133
+ text-overflow: ellipsis;
134
+ }
135
+ .title {
136
+ font-size: 14px;
137
+ color: var(--kern-ink-800);
138
+ white-space: nowrap;
139
+ overflow: hidden;
140
+ text-overflow: ellipsis;
141
+ display: block;
142
+ }
143
+ .chip {
144
+ font-size: 12px;
145
+ padding: 3px 9px;
146
+ border-radius: var(--kern-r-md);
147
+ background: var(--kern-surface-chip);
148
+ color: var(--kern-ink-520);
149
+ white-space: nowrap;
150
+ }
151
+ .est,
152
+ .due {
153
+ font-size: 12.5px;
154
+ color: var(--kern-ink-350);
155
+ white-space: nowrap;
156
+ }
157
+ .people {
158
+ justify-content: flex-end;
159
+ }
160
+ @media (max-width: 768px) {
161
+ .krow {
162
+ grid-template-columns: 18px 58px minmax(0, 1fr) auto minmax(24px, auto);
163
+ padding: 0 12px;
164
+ }
165
+ .st,
166
+ .chip,
167
+ .est {
168
+ display: none;
169
+ }
170
+ }
171
+ </style>