@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-tracker",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Kern tracker module: projects, work item types, custom fields, workflows, issues, KQL, cycles, views, reports, intake, time tracking (server + client skeleton)",
5
5
  "homepage": "https://kernaio.com",
6
6
  "license": "AGPL-3.0-only",
@@ -56,21 +56,34 @@
56
56
  "@orpc/contract": "^1.15.0",
57
57
  "@orpc/server": "^1.15.0",
58
58
  "drizzle-orm": "^0.45.0",
59
+ "svelte-dnd-action": "^0.9.0",
59
60
  "zod": "^4.1.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@kernhq/tsconfig": "^0.1.0",
64
+ "@kernhq/ui": "^0.7.0",
65
+ "@tanstack/svelte-query": "^6.1.0",
63
66
  "@types/node": "^24.0.0",
64
67
  "@types/pg": "^8.15.0",
65
68
  "drizzle-kit": "^0.31.0",
66
69
  "pg": "^8.16.0",
70
+ "svelte": "^5.46.0",
71
+ "svelte-check": "^4.0.0",
67
72
  "typescript": "~5.9.3",
68
73
  "vitest": "^4.0.0"
69
74
  },
70
75
  "peerDependencies": {
71
- "svelte": "^5.0.0"
76
+ "@kernhq/ui": "^0.7.0",
77
+ "@tanstack/svelte-query": "^6.1.0",
78
+ "svelte": "^5.46.0"
72
79
  },
73
80
  "peerDependenciesMeta": {
81
+ "@kernhq/ui": {
82
+ "optional": true
83
+ },
84
+ "@tanstack/svelte-query": {
85
+ "optional": true
86
+ },
74
87
  "svelte": {
75
88
  "optional": true
76
89
  }
@@ -78,7 +91,7 @@
78
91
  "scripts": {
79
92
  "build": "tsc -p tsconfig.json",
80
93
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
81
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
94
+ "typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
82
95
  "lint": "biome check .",
83
96
  "test": "vitest run",
84
97
  "test:unit": "vitest run --exclude 'src/**/*.int.test.ts'",
@@ -0,0 +1,34 @@
1
+ import { getHost } from '@kernhq/ui'
2
+ import { createTrackerClient, type TrackerApi } from './api.js'
3
+ import { createMockTrackerApi } from './mock.js'
4
+
5
+ /**
6
+ * The tracker API client.
7
+ *
8
+ * A module is reached at `/api/<module>`, so every module gets its own client built from its own
9
+ * contract; this is the tracker's, alongside `getApi()` for core. `PUBLIC_API_MOCK=1` swaps in the
10
+ * in-memory implementation from `./mock`, which satisfies the same contract types, so no view has a
11
+ * second code path for demos and end-to-end tests.
12
+ */
13
+ export type { TrackerApi }
14
+
15
+ let cached: TrackerApi | null = null
16
+
17
+ export function getTrackerApi(): TrackerApi {
18
+ if (cached) return cached
19
+ cached = getHost().isMock ? (createMockTrackerApi() as unknown as TrackerApi) : createReal()
20
+ return cached
21
+ }
22
+
23
+ function createReal(): TrackerApi {
24
+ return createTrackerClient({
25
+ // empty base URL keeps requests same-origin: the dev server proxies /api, and in production the
26
+ // reverse proxy routes it, so the session cookie works without CORS in both
27
+ baseUrl: getHost().apiBaseUrl,
28
+ })
29
+ }
30
+
31
+ /** Test seam: lets tests install a fake without touching module state elsewhere. */
32
+ export function __setTrackerApi(api: TrackerApi | null) {
33
+ cached = api
34
+ }
@@ -0,0 +1,182 @@
1
+ <script lang="ts">
2
+ import { Avatar, Icon, realtime } from '@kernhq/ui'
3
+ import { getTrackerCatalogue } from '../context.svelte.js'
4
+ import { t } from '../i18n.js'
5
+ import { formatDuration, type Issue } from '../index.js'
6
+ import { estimateLabel, priorityLabel } from '../labels.js'
7
+ import DueDate from './DueDate.svelte'
8
+ import PriorityGlyph from './PriorityGlyph.svelte'
9
+ import StatusIcon from './StatusIcon.svelte'
10
+
11
+ /**
12
+ * A board card (DESIGN.md 3.3): status and key on top with the assignee on the far side, the title,
13
+ * a row of chips for the properties worth seeing without opening anything, and a footer of numbers.
14
+ * The comment count disappears when there are no comments rather than showing a zero.
15
+ */
16
+ interface Props {
17
+ issue: Issue
18
+ onopen: (issue: Issue) => void
19
+ }
20
+ let { issue, onopen }: Props = $props()
21
+
22
+ const cat = getTrackerCatalogue()
23
+ const project = $derived(cat.project(issue.projectId))
24
+ const status = $derived(cat.status(issue.statusId))
25
+ const milestone = $derived(cat.milestone(issue.milestoneId))
26
+ const assignee = $derived(cat.person(issue.assigneeIds[0]))
27
+ const online = $derived(assignee ? realtime.online.has(assignee.id) : false)
28
+ </script>
29
+
30
+ <article class="kcard" data-testid="board-card" data-issue-key={issue.key}>
31
+ <button type="button" class="body" onclick={() => onopen(issue)}>
32
+ <span class="top">
33
+ <StatusIcon
34
+ category={issue.statusCategory}
35
+ statusId={issue.statusId}
36
+ name={status?.name}
37
+ size={15}
38
+ label={status?.name}
39
+ />
40
+ <span class="key">{issue.key}</span>
41
+ <span class="sp"></span>
42
+ {#if assignee}
43
+ <span class="dot" class:on={online}></span>
44
+ <Avatar name={assignee.name} src={assignee.avatarUrl} id={assignee.id} size={22} />
45
+ {/if}
46
+ </span>
47
+
48
+ <span class="title">{issue.title}</span>
49
+
50
+ <span class="chips">
51
+ {#if issue.priority !== 'none'}
52
+ <span class="chip pri" title={priorityLabel(issue.priority)}>
53
+ <PriorityGlyph priority={issue.priority} size={14} />
54
+ </span>
55
+ {/if}
56
+ {#if project}
57
+ <span class="chip">
58
+ <Icon name="diamond" size={12} strokeWidth={1.7} />
59
+ <span class="chip-t">{project.name}</span>
60
+ </span>
61
+ {/if}
62
+ {#if milestone}
63
+ <span class="chip">
64
+ <Icon name="flag" size={12} strokeWidth={1.7} />
65
+ <span class="chip-t">{milestone.name}</span>
66
+ </span>
67
+ {/if}
68
+ {#if issue.dueDate}
69
+ <span class="chip"><DueDate date={issue.dueDate} /></span>
70
+ {/if}
71
+ </span>
72
+ </button>
73
+
74
+ <footer class="foot">
75
+ <span class="stat"><Icon name="clock" size={13} strokeWidth={1.6} />{formatDuration(issue.timeSpentSec)}</span>
76
+ {#if issue.estimate !== null}
77
+ <span class="stat">{estimateLabel(issue.estimate, issue.estimateUnit)}</span>
78
+ {/if}
79
+ <span class="sp"></span>
80
+ {#if issue.commentCount > 0}
81
+ <span class="stat">
82
+ <Icon name="message-circle" size={13} strokeWidth={1.6} />{issue.commentCount}
83
+ </span>
84
+ {/if}
85
+ </footer>
86
+ </article>
87
+
88
+ <style>
89
+ .kcard {
90
+ background: var(--kern-surface-raised);
91
+ border: 1px solid var(--kern-border);
92
+ border-radius: var(--kern-r-card);
93
+ overflow: hidden;
94
+ transition: border-color 80ms;
95
+ }
96
+ .kcard:hover {
97
+ border-color: var(--kern-border-hover);
98
+ }
99
+ .body {
100
+ display: block;
101
+ width: 100%;
102
+ padding: 13px 14px 14px;
103
+ text-align: start;
104
+ }
105
+ .top {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 7px;
109
+ }
110
+ .key {
111
+ font-family: var(--kern-font-mono);
112
+ font-size: 11.5px;
113
+ letter-spacing: -0.01em;
114
+ color: var(--kern-ink-250);
115
+ }
116
+ .sp {
117
+ flex: 1;
118
+ }
119
+ .dot {
120
+ width: 7px;
121
+ height: 7px;
122
+ border-radius: var(--kern-r-full);
123
+ background: var(--kern-success);
124
+ opacity: 0;
125
+ flex: none;
126
+ }
127
+ .dot.on {
128
+ opacity: 1;
129
+ }
130
+ .title {
131
+ display: block;
132
+ margin-top: 9px;
133
+ font-size: 14px;
134
+ line-height: 1.42;
135
+ letter-spacing: -0.005em;
136
+ color: var(--kern-ink-900);
137
+ text-wrap: pretty;
138
+ }
139
+ .chips {
140
+ display: flex;
141
+ flex-wrap: wrap;
142
+ gap: 5px;
143
+ margin-top: 10px;
144
+ }
145
+ .chip {
146
+ display: inline-flex;
147
+ align-items: center;
148
+ gap: 6px;
149
+ height: 26px;
150
+ padding: 0 8px;
151
+ border-radius: var(--kern-r-md);
152
+ background: var(--kern-surface-chip);
153
+ color: var(--kern-ink-580);
154
+ font-size: 12.5px;
155
+ white-space: nowrap;
156
+ }
157
+ .chip.pri {
158
+ padding: 0 7px;
159
+ }
160
+ .chip-t {
161
+ max-width: 120px;
162
+ overflow: hidden;
163
+ text-overflow: ellipsis;
164
+ }
165
+ .foot {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 14px;
169
+ height: 38px;
170
+ padding: 0 14px;
171
+ border-top: 1px solid var(--kern-surface-chip);
172
+ font-family: var(--kern-font-mono);
173
+ font-size: 11.5px;
174
+ letter-spacing: -0.01em;
175
+ color: var(--kern-ink-300);
176
+ }
177
+ .stat {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ gap: 6px;
181
+ }
182
+ </style>
@@ -0,0 +1,248 @@
1
+ <script lang="ts">
2
+ import { Icon } from '@kernhq/ui'
3
+ import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
4
+ import { t } from '../i18n.js'
5
+ import { byRank, type Issue, type StatusInfo, statusStyle } from '../index.js'
6
+ import BoardCard from './BoardCard.svelte'
7
+ import StatusIcon from './StatusIcon.svelte'
8
+
9
+ /**
10
+ * The board (DESIGN.md 3.3).
11
+ *
12
+ * One column per value of whatever the board is grouped by — a workflow status by default, or a
13
+ * custom field's options — each headed by a 2px rule that starts as a stub of the column's colour
14
+ * and fades to a hairline. Cards are dragged between columns with svelte-dnd-action; dropping one
15
+ * reports the column it landed in and the neighbours it landed between, so the page can apply
16
+ * whatever that column means and the new rank in one go.
17
+ *
18
+ * The board does not know what a column *is*: the page says how to read a card's column and what a
19
+ * drop means. That is what lets the same board group by status and by Severity without a second
20
+ * implementation.
21
+ *
22
+ * A WIP limit is shown as `used/limit` and turns red when a column is over it. It warns rather than
23
+ * refuses: a hard block just makes people rename columns.
24
+ */
25
+ export interface BoardColumnInfo {
26
+ id: string
27
+ name: string
28
+ /** the colour of the header rule; a status derives one, a field option carries its own */
29
+ color: string
30
+ /** present only for a status column, which draws its glyph */
31
+ status?: StatusInfo
32
+ }
33
+
34
+ interface Props {
35
+ issues: Issue[]
36
+ columns: BoardColumnInfo[]
37
+ /** which column a card belongs in — the page decides, because it knows the grouping */
38
+ columnOf: (issue: Issue) => string[]
39
+ wipLimits?: Record<string, number | undefined>
40
+ /** false when the viewer may not move work between statuses; cards stay readable, just not draggable */
41
+ draggable?: boolean
42
+ onopen: (issue: Issue) => void
43
+ onmove: (issue: Issue, toColumnId: string, afterId: string | null, beforeId: string | null) => void
44
+ oncreate?: (columnId: string) => void
45
+ }
46
+ let {
47
+ issues,
48
+ columns,
49
+ columnOf,
50
+ wipLimits = {},
51
+ draggable = true,
52
+ onopen,
53
+ onmove,
54
+ oncreate,
55
+ }: Props = $props()
56
+
57
+ const FLIP = 160
58
+
59
+ type Lane = { column: BoardColumnInfo; items: Issue[] }
60
+
61
+ /**
62
+ * `$state.raw` matters here: the drag library tracks items by identity, and a deep-reactive proxy
63
+ * hands it a different object every read, which it reads as an endless stream of changes.
64
+ */
65
+ let lanes = $state.raw<Lane[]>([])
66
+ let dragging = $state(false)
67
+
68
+ const replaceLane = (index: number, items: Issue[]) => {
69
+ lanes = lanes.map((lane, i) => (i === index ? { column: lane.column, items } : lane))
70
+ }
71
+
72
+ // mirror the query into local lists the drag library can reorder, but leave them alone mid-drag
73
+ $effect(() => {
74
+ if (dragging) return
75
+ const sorted = [...issues].sort(byRank)
76
+ lanes = columns.map((column) => ({
77
+ column,
78
+ items: sorted.filter((issue) => columnOf(issue).includes(column.id)),
79
+ }))
80
+ })
81
+
82
+ const isShadow = (issue: Issue) =>
83
+ (issue as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
84
+
85
+ function consider(index: number, event: CustomEvent<{ items: Issue[] }>) {
86
+ dragging = true
87
+ replaceLane(index, event.detail.items)
88
+ }
89
+
90
+ function finalize(index: number, event: CustomEvent<{ items: Issue[]; info: { id: string } }>) {
91
+ dragging = false
92
+ const lane = lanes[index]
93
+ if (!lane) return
94
+ const items = event.detail.items
95
+ replaceLane(index, items)
96
+
97
+ const at = items.findIndex((i) => i.id === event.detail.info.id)
98
+ const moved = items[at]
99
+ // the drop reports on both the source and the target lane; only the target holds the card
100
+ if (!moved) return
101
+ onmove(moved, lane.column.id, items[at - 1]?.id ?? null, items[at + 1]?.id ?? null)
102
+ }
103
+
104
+ const laneRule = (column: BoardColumnInfo) =>
105
+ `linear-gradient(to var(--kern-lane-dir, right), ${column.color} 0 34px, var(--kern-border) 34px)`
106
+ </script>
107
+
108
+ <div class="kboard" data-testid="board">
109
+ {#each lanes as lane, index (lane.column.id)}
110
+ {@const limit = wipLimits[lane.column.id]}
111
+ {@const over = limit !== undefined && lane.items.length > limit}
112
+ <section class="col" data-column={lane.column.id}>
113
+ <header class="head">
114
+ {#if lane.column.status}
115
+ <StatusIcon
116
+ category={lane.column.status.category}
117
+ statusId={lane.column.status.id}
118
+ name={lane.column.status.name}
119
+ size={15}
120
+ />
121
+ {:else}
122
+ <!-- A field's value has no glyph of its own, so the column's colour is the whole cue. -->
123
+ <span class="swatch" style:background={lane.column.color}></span>
124
+ {/if}
125
+ <h2 class="name">{lane.column.name}</h2>
126
+ <span class="count" class:over title={over ? t('wip_exceeded') : undefined}>
127
+ {limit === undefined ? lane.items.length : `${lane.items.length}/${limit}`}
128
+ </span>
129
+ <span class="sp"></span>
130
+ {#if oncreate}
131
+ <button
132
+ type="button"
133
+ class="add"
134
+ aria-label={t('new_issue')}
135
+ onclick={() => oncreate(lane.column.id)}
136
+ >
137
+ <Icon name="plus" size={13} strokeWidth={1.9} />
138
+ </button>
139
+ {/if}
140
+ </header>
141
+ <div class="rule" style:background={laneRule(lane.column)}></div>
142
+
143
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
144
+ <div
145
+ class="stack"
146
+ use:dndzone={{
147
+ items: lane.items,
148
+ flipDurationMs: FLIP,
149
+ type: 'tracker-issues',
150
+ dragDisabled: !draggable,
151
+ dropTargetStyle: {},
152
+ }}
153
+ onconsider={(e) => consider(index, e)}
154
+ onfinalize={(e) => finalize(index, e)}
155
+ >
156
+ {#each lane.items as issue (issue.id)}
157
+ <div class="slot" class:shadow={isShadow(issue)}>
158
+ <BoardCard {issue} {onopen} />
159
+ </div>
160
+ {/each}
161
+ </div>
162
+ </section>
163
+ {/each}
164
+ </div>
165
+
166
+ <style>
167
+ .kboard {
168
+ display: flex;
169
+ align-items: flex-start;
170
+ gap: 20px;
171
+ padding: 22px 28px 30px;
172
+ overflow: auto;
173
+ height: 100%;
174
+ }
175
+ :global([dir='rtl']) .kboard {
176
+ --kern-lane-dir: left;
177
+ }
178
+ .col {
179
+ width: var(--kern-board-col-w);
180
+ flex: none;
181
+ }
182
+ .head {
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 9px;
186
+ height: 30px;
187
+ }
188
+ .name {
189
+ margin: 0;
190
+ font-size: 13.5px;
191
+ font-weight: 600;
192
+ letter-spacing: -0.01em;
193
+ color: var(--kern-ink-900);
194
+ white-space: nowrap;
195
+ }
196
+ .count {
197
+ font-family: var(--kern-font-mono);
198
+ font-size: 11.5px;
199
+ letter-spacing: -0.01em;
200
+ color: var(--kern-ink-250);
201
+ }
202
+ .count.over {
203
+ color: var(--kern-danger);
204
+ font-weight: 500;
205
+ }
206
+ .sp {
207
+ flex: 1;
208
+ }
209
+ .add {
210
+ width: 22px;
211
+ height: 22px;
212
+ border-radius: var(--kern-r-md);
213
+ display: grid;
214
+ place-items: center;
215
+ color: var(--kern-ink-250);
216
+ }
217
+ .add:hover {
218
+ background: var(--kern-surface-hover);
219
+ color: var(--kern-ink-900);
220
+ }
221
+ .swatch {
222
+ width: 9px;
223
+ height: 9px;
224
+ border-radius: 3px;
225
+ flex: none;
226
+ }
227
+ .rule {
228
+ height: 2px;
229
+ border-radius: 1px;
230
+ }
231
+ .stack {
232
+ display: flex;
233
+ flex-direction: column;
234
+ gap: 10px;
235
+ margin-top: 14px;
236
+ min-height: 80px;
237
+ padding-bottom: 4px;
238
+ }
239
+ .slot.shadow {
240
+ opacity: 0.35;
241
+ }
242
+ @media (max-width: 768px) {
243
+ .kboard {
244
+ padding: 16px;
245
+ gap: 14px;
246
+ }
247
+ }
248
+ </style>