@kernhq/module-quire 0.7.4 → 0.9.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 (88) hide show
  1. package/README.md +93 -55
  2. package/dist/{server → client}/formula.d.ts +6 -0
  3. package/dist/client/formula.d.ts.map +1 -0
  4. package/dist/{server → client}/formula.js +6 -0
  5. package/dist/client/formula.js.map +1 -0
  6. package/dist/contract/properties.d.ts +26 -0
  7. package/dist/contract/properties.d.ts.map +1 -1
  8. package/dist/contract/properties.js +24 -0
  9. package/dist/contract/properties.js.map +1 -1
  10. package/dist/contract/router.d.ts +334 -0
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +43 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +341 -0
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +79 -10
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/document.d.ts +13 -0
  19. package/dist/server/document.d.ts.map +1 -0
  20. package/dist/server/document.js +58 -0
  21. package/dist/server/document.js.map +1 -0
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +26 -1
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/render.d.ts +53 -0
  26. package/dist/server/render.d.ts.map +1 -0
  27. package/dist/server/render.js +358 -0
  28. package/dist/server/render.js.map +1 -0
  29. package/dist/server/services/databases.d.ts +73 -1
  30. package/dist/server/services/databases.d.ts.map +1 -1
  31. package/dist/server/services/databases.js +167 -10
  32. package/dist/server/services/databases.js.map +1 -1
  33. package/dist/server/services/pages.d.ts.map +1 -1
  34. package/dist/server/services/pages.js +11 -1
  35. package/dist/server/services/pages.js.map +1 -1
  36. package/dist/server/services/query.d.ts.map +1 -1
  37. package/dist/server/services/query.js +93 -37
  38. package/dist/server/services/query.js.map +1 -1
  39. package/dist/server/services/versions.d.ts.map +1 -1
  40. package/dist/server/services/versions.js +13 -1
  41. package/dist/server/services/versions.js.map +1 -1
  42. package/migrations/0006_rank_collation.sql +14 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +12 -9
  45. package/src/client/components/PageEditor.svelte +7 -0
  46. package/src/client/components/PageTreeRow.svelte +1 -1
  47. package/src/client/core-api.ts +38 -0
  48. package/src/client/database/BoardView.svelte +354 -0
  49. package/src/client/database/CalendarView.svelte +346 -0
  50. package/src/client/database/DatabaseView.svelte +785 -0
  51. package/src/client/database/FilterMenu.svelte +281 -0
  52. package/src/client/database/FilterValue.svelte +176 -0
  53. package/src/client/database/GalleryView.svelte +177 -0
  54. package/src/client/database/ListView.svelte +108 -0
  55. package/src/client/database/OptionChip.svelte +38 -0
  56. package/src/client/database/PropertyDialog.svelte +460 -0
  57. package/src/client/database/PropertyMenu.svelte +162 -0
  58. package/src/client/database/RowPanel.svelte +157 -0
  59. package/src/client/database/SortMenu.svelte +199 -0
  60. package/src/client/database/TableView.svelte +388 -0
  61. package/src/client/database/ViewDialog.svelte +229 -0
  62. package/src/client/database/cells/Cell.svelte +103 -0
  63. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  64. package/src/client/database/cells/ComputedCell.svelte +100 -0
  65. package/src/client/database/cells/DateCell.svelte +97 -0
  66. package/src/client/database/cells/LinkCell.svelte +143 -0
  67. package/src/client/database/cells/NumberCell.svelte +131 -0
  68. package/src/client/database/cells/PersonCell.svelte +116 -0
  69. package/src/client/database/cells/RelationCell.svelte +222 -0
  70. package/src/client/database/cells/SelectCell.svelte +133 -0
  71. package/src/client/database/cells/TextCell.svelte +85 -0
  72. package/src/client/database/colours.ts +27 -0
  73. package/src/client/database/property-types.test.ts +64 -0
  74. package/src/client/database/property-types.ts +294 -0
  75. package/src/client/database/view-config.test.ts +148 -0
  76. package/src/client/database/view-config.ts +102 -0
  77. package/src/client/formula.test.ts +139 -0
  78. package/src/client/formula.ts +430 -0
  79. package/src/client/i18n.ts +1176 -0
  80. package/src/client/index.ts +31 -0
  81. package/src/client/mock.ts +511 -1
  82. package/src/client/pages/PageView.svelte +47 -17
  83. package/src/client/pages/SpacePage.svelte +8 -2
  84. package/src/client/query.ts +17 -4
  85. package/src/contract/properties.ts +28 -0
  86. package/src/contract/router.ts +46 -0
  87. package/dist/server/formula.d.ts.map +0 -1
  88. package/dist/server/formula.js.map +0 -1
@@ -0,0 +1,354 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, EmptyState, Icon, IconButton, type MenuItem } from '@kernhq/ui'
3
+ import { untrack } from 'svelte'
4
+ import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
5
+ import type { Database, Property, Row, View } from '../../contract/index.js'
6
+ import type { Person } from '../core-api.js'
7
+ import { t } from '../i18n.js'
8
+ import Cell from './cells/Cell.svelte'
9
+ import OptionChip from './OptionChip.svelte'
10
+ import { descriptorFor } from './property-types.js'
11
+ import { EMPTY_GROUP, groupsOf, type Lane, visiblePropertiesOf } from './view-config.js'
12
+
13
+ /**
14
+ * The board (DESIGN.md §3.3), grouped by a select, status or checkbox column.
15
+ *
16
+ * Three traps in `svelte-dnd-action`, all of which have already shipped once in this project:
17
+ *
18
+ * - it tracks items by an `id` property and nothing else, so the lanes hold `Row`s and never
19
+ * wrappers;
20
+ * - the lanes must be `$state.raw` — a deep-reactive proxy hands the library a different object on
21
+ * every read, which it reads as an endless stream of changes;
22
+ * - the seeding effect reads its `dragging` guard through `untrack`. Clearing the flag at the end of
23
+ * a drop otherwise re-runs the effect against the *old* query data and undoes the move on screen
24
+ * while the mutation is still in flight.
25
+ *
26
+ * Every card carries a **Move to** menu. The drag is unreachable by keyboard, so without it half
27
+ * the people using this board could not move a card at all.
28
+ */
29
+ interface Props {
30
+ database: Database
31
+ view: View | null
32
+ rows: Row[]
33
+ people: Person[]
34
+ workspaceId: string
35
+ canEdit: boolean
36
+ canCreate: boolean
37
+ onMove: (row: Row, laneId: string) => void
38
+ onOpenRow: (row: Row) => void
39
+ onAddRow: (laneId: string) => void
40
+ onConfigure: () => void
41
+ }
42
+ const {
43
+ database,
44
+ view,
45
+ rows,
46
+ people,
47
+ workspaceId,
48
+ canEdit,
49
+ canCreate,
50
+ onMove,
51
+ onOpenRow,
52
+ onAddRow,
53
+ onConfigure,
54
+ }: Props = $props()
55
+
56
+ const FLIP = 160
57
+
58
+ const groupKey = $derived(view?.config.groupBy ?? null)
59
+ const groupProperty = $derived(
60
+ groupKey ? (database.properties.find((p) => p.key === groupKey) ?? null) : null,
61
+ )
62
+ const groupable = $derived(groupProperty !== null && descriptorFor(groupProperty.type).canGroup)
63
+
64
+ /** Three properties on a card, not thirty: the card is a summary, the panel is the record. */
65
+ const cardProperties = $derived(
66
+ visiblePropertiesOf(database, view)
67
+ .filter((p) => p.key !== groupKey)
68
+ .slice(0, 3),
69
+ )
70
+
71
+ let lanes = $state.raw<Lane[]>([])
72
+ let dragging = $state(false)
73
+
74
+ const replaceLane = (index: number, items: Row[]) => {
75
+ lanes = lanes.map((lane, i) => (i === index ? { ...lane, rows: items } : lane))
76
+ }
77
+
78
+ $effect(() => {
79
+ const next = groupable ? groupsOf(groupProperty, rows) : []
80
+ if (untrack(() => dragging)) return
81
+ lanes = next
82
+ })
83
+
84
+ const isShadow = (row: Row) =>
85
+ (row as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
86
+
87
+ function consider(index: number, event: CustomEvent<{ items: Row[] }>) {
88
+ dragging = true
89
+ replaceLane(index, event.detail.items)
90
+ }
91
+
92
+ function finalize(index: number, event: CustomEvent<{ items: Row[]; info: { id: string } }>) {
93
+ dragging = false
94
+ const lane = lanes[index]
95
+ if (!lane) return
96
+ replaceLane(index, event.detail.items)
97
+ const moved = event.detail.items.find((r) => r.id === event.detail.info.id)
98
+ // Both the source and the target lane fire; only the target holds the card.
99
+ if (!moved) return
100
+ const already = groupProperty ? moved.props[groupProperty.key] : null
101
+ const current = Array.isArray(already) ? (already[0] ?? null) : (already ?? null)
102
+ // A drop inside the same lane re-seeds and does nothing: rows carry no order of their own, so
103
+ // there is nothing to persist and pretending otherwise would snap back a moment later anyway.
104
+ if ((current ?? EMPTY_GROUP) === lane.id) return
105
+ onMove(moved, lane.id)
106
+ }
107
+
108
+ const laneName = (lane: Lane) => lane.option?.label ?? t('db_board_uncategorised')
109
+
110
+ const cardMenu = (row: Row, from: Lane): MenuItem[] => [
111
+ { id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
112
+ { type: 'separator' },
113
+ { type: 'label', label: t('db_move_to') },
114
+ ...lanes
115
+ .filter((lane) => lane.id !== from.id)
116
+ .map((lane) => ({
117
+ id: `move-${lane.id}`,
118
+ label: laneName(lane),
119
+ icon: 'arrow-right',
120
+ disabled: !canEdit,
121
+ onSelect: () => onMove(row, lane.id),
122
+ })),
123
+ ]
124
+
125
+ const laneRule = (lane: Lane) =>
126
+ `linear-gradient(to var(--kern-quire-lane-dir, right), ${
127
+ lane.option ? 'var(--kern-accent)' : 'var(--kern-ink-300)'
128
+ } 0 34px, var(--kern-border) 34px)`
129
+ </script>
130
+
131
+ {#if !groupable}
132
+ <div class="pad">
133
+ <EmptyState
134
+ icon="kanban"
135
+ title={t('db_board_needs_group')}
136
+ description={t('db_board_needs_group_desc')}
137
+ >
138
+ {#snippet actions()}
139
+ {#if canEdit}
140
+ <button type="button" class="link" onclick={onConfigure}>{t('db_edit_view')}</button>
141
+ {/if}
142
+ {/snippet}
143
+ </EmptyState>
144
+ </div>
145
+ {:else}
146
+ <div class="board">
147
+ {#each lanes as lane, index (lane.id)}
148
+ <section class="col">
149
+ <header class="head">
150
+ {#if lane.option}
151
+ <OptionChip option={lane.option} compact />
152
+ {:else}
153
+ <span class="none">{t('db_board_uncategorised')}</span>
154
+ {/if}
155
+ <span class="count">{lane.rows.length}</span>
156
+ <span class="sp"></span>
157
+ {#if canCreate}
158
+ <IconButton
159
+ icon="plus"
160
+ label={t('db_new_row_in', { lane: laneName(lane) })}
161
+ size={26}
162
+ variant="ghost"
163
+ onclick={() => onAddRow(lane.id)}
164
+ />
165
+ {/if}
166
+ </header>
167
+ <div class="rule" style:background={laneRule(lane)}></div>
168
+
169
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
170
+ <div
171
+ class="stack"
172
+ use:dndzone={{
173
+ items: lane.rows,
174
+ flipDurationMs: FLIP,
175
+ type: 'quire-rows',
176
+ dragDisabled: !canEdit,
177
+ dropTargetStyle: {},
178
+ }}
179
+ onconsider={(e) => consider(index, e)}
180
+ onfinalize={(e) => finalize(index, e)}
181
+ >
182
+ {#each lane.rows as row (row.id)}
183
+ <article class="card" class:shadow={isShadow(row)}>
184
+ <div class="card-top">
185
+ <h3 class="card-title">{row.title.trim() || t('untitled')}</h3>
186
+ <DropdownMenu items={cardMenu(row, lane)}>
187
+ {#snippet trigger(props: Record<string, unknown>)}
188
+ <IconButton
189
+ {...props}
190
+ icon="ellipsis"
191
+ label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
192
+ size={26}
193
+ variant="ghost"
194
+ />
195
+ {/snippet}
196
+ </DropdownMenu>
197
+ </div>
198
+ {#if cardProperties.length > 0}
199
+ <dl class="fields">
200
+ {#each cardProperties as property (property.id)}
201
+ <div class="field">
202
+ <dt>{property.name}</dt>
203
+ <dd>
204
+ <Cell
205
+ {property}
206
+ {row}
207
+ {people}
208
+ {workspaceId}
209
+ canEdit={false}
210
+ onchange={() => {}}
211
+ />
212
+ </dd>
213
+ </div>
214
+ {/each}
215
+ </dl>
216
+ {/if}
217
+ </article>
218
+ {/each}
219
+ {#if lane.rows.length === 0}
220
+ <p class="lane-empty">{t('db_lane_empty')}</p>
221
+ {/if}
222
+ </div>
223
+ </section>
224
+ {/each}
225
+ </div>
226
+ {/if}
227
+
228
+ <style>
229
+ .board {
230
+ display: flex;
231
+ align-items: flex-start;
232
+ gap: 20px;
233
+ padding: 22px 28px 30px;
234
+ overflow-x: auto;
235
+ }
236
+ :global([dir='rtl']) .board {
237
+ --kern-quire-lane-dir: left;
238
+ }
239
+ .col {
240
+ width: var(--kern-board-col-w);
241
+ flex: none;
242
+ }
243
+ .head {
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 9px;
247
+ height: 30px;
248
+ }
249
+ .none {
250
+ font-size: 13px;
251
+ color: var(--kern-ink-450);
252
+ }
253
+ .count {
254
+ font-family: var(--kern-font-mono);
255
+ font-size: 11.5px;
256
+ color: var(--kern-ink-450);
257
+ }
258
+ .sp {
259
+ flex: 1;
260
+ }
261
+ .rule {
262
+ height: 2px;
263
+ border-radius: 1px;
264
+ }
265
+ .stack {
266
+ display: flex;
267
+ flex-direction: column;
268
+ gap: 10px;
269
+ margin-block-start: 14px;
270
+ min-height: 80px;
271
+ padding-block-end: 4px;
272
+ }
273
+ .card {
274
+ border: 1px solid var(--kern-border);
275
+ border-radius: var(--kern-r-xl);
276
+ background: var(--kern-surface-raised);
277
+ padding: 11px 8px 12px 13px;
278
+ }
279
+ .card:hover {
280
+ border-color: var(--kern-border-hover);
281
+ }
282
+ .card.shadow {
283
+ border-style: dashed;
284
+ background: var(--kern-surface-hover);
285
+ }
286
+ .card-top {
287
+ display: flex;
288
+ align-items: flex-start;
289
+ gap: 6px;
290
+ }
291
+ .card-title {
292
+ flex: 1;
293
+ min-width: 0;
294
+ margin: 0;
295
+ font-size: 14px;
296
+ font-weight: 500;
297
+ line-height: 1.42;
298
+ letter-spacing: -0.005em;
299
+ color: var(--kern-ink-900);
300
+ }
301
+ .fields {
302
+ margin: 10px 5px 0 0;
303
+ padding: 0;
304
+ display: flex;
305
+ flex-direction: column;
306
+ gap: 4px;
307
+ }
308
+ .field {
309
+ display: grid;
310
+ grid-template-columns: 84px minmax(0, 1fr);
311
+ align-items: center;
312
+ gap: 8px;
313
+ }
314
+ .field dt {
315
+ font-size: 11.5px;
316
+ color: var(--kern-ink-450);
317
+ overflow: hidden;
318
+ text-overflow: ellipsis;
319
+ white-space: nowrap;
320
+ }
321
+ .field dd {
322
+ margin: 0;
323
+ min-width: 0;
324
+ font-size: 12.5px;
325
+ color: var(--kern-ink-600);
326
+ overflow: hidden;
327
+ }
328
+ .lane-empty {
329
+ margin: 0;
330
+ padding: 14px 10px;
331
+ border: 1px dashed var(--kern-border);
332
+ border-radius: var(--kern-r-lg);
333
+ text-align: center;
334
+ font-size: 12.5px;
335
+ color: var(--kern-ink-450);
336
+ }
337
+ .pad {
338
+ padding: 28px;
339
+ }
340
+ .link {
341
+ border: 0;
342
+ background: none;
343
+ color: var(--kern-accent-text);
344
+ font: inherit;
345
+ font-size: 13px;
346
+ text-decoration: underline;
347
+ }
348
+ @media (max-width: 768px) {
349
+ .board {
350
+ padding: 16px;
351
+ gap: 14px;
352
+ }
353
+ }
354
+ </style>
@@ -0,0 +1,346 @@
1
+ <script lang="ts">
2
+ import { Button, DropdownMenu, EmptyState, IconButton, type MenuItem, messageLocale } from '@kernhq/ui'
3
+ import type { Database, Row, View } from '../../contract/index.js'
4
+ import { t } from '../i18n.js'
5
+ import { descriptorFor } from './property-types.js'
6
+
7
+ /**
8
+ * A month of rows, plotted on the view's date column.
9
+ *
10
+ * The month is component state rather than a URL parameter: which month somebody is looking at is
11
+ * not what the page is *about*, and putting it in the address makes every scroll through the year a
12
+ * history entry to walk back through.
13
+ *
14
+ * A day cell is a drop target, and every card carries **Move to a date** so the drag is never the
15
+ * only route — the same rule the board follows.
16
+ */
17
+ interface Props {
18
+ database: Database
19
+ view: View | null
20
+ rows: Row[]
21
+ canEdit: boolean
22
+ onOpenRow: (row: Row) => void
23
+ onSetDate: (row: Row, iso: string | null) => void
24
+ onConfigure: () => void
25
+ }
26
+ const { database, view, rows, canEdit, onOpenRow, onSetDate, onConfigure }: Props = $props()
27
+
28
+ const dateKey = $derived(view?.config.dateProperty ?? null)
29
+ const dateProperty = $derived(dateKey ? (database.properties.find((p) => p.key === dateKey) ?? null) : null)
30
+ const plottable = $derived(dateProperty !== null && descriptorFor(dateProperty.type).canDate)
31
+
32
+ const today = new Date()
33
+ let cursor = $state(new Date(today.getFullYear(), today.getMonth(), 1))
34
+
35
+ const monthLabel = $derived(
36
+ new Intl.DateTimeFormat(messageLocale(), { month: 'long', year: 'numeric' }).format(cursor),
37
+ )
38
+
39
+ /** Weekday names for the header, starting on the locale's first day. */
40
+ const weekdays = $derived.by(() => {
41
+ const format = new Intl.DateTimeFormat(messageLocale(), { weekday: 'short' })
42
+ // 2024-01-07 was a Sunday; the grid starts on Monday, which is what a working week means here.
43
+ return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 8 + i))))
44
+ })
45
+
46
+ const dayKey = (at: Date) =>
47
+ `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`
48
+
49
+ /** Six weeks from the Monday on or before the first of the month — a month never needs more. */
50
+ const days = $derived.by(() => {
51
+ const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1)
52
+ const offset = (first.getDay() + 6) % 7
53
+ const start = new Date(first.getFullYear(), first.getMonth(), 1 - offset)
54
+ return Array.from(
55
+ { length: 42 },
56
+ (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
57
+ )
58
+ })
59
+
60
+ const byDay = $derived.by(() => {
61
+ const map = new Map<string, Row[]>()
62
+ if (!dateProperty) return map
63
+ for (const row of rows) {
64
+ const raw = row.props[dateProperty.key]
65
+ if (typeof raw !== 'string' || raw === '') continue
66
+ const at = new Date(raw)
67
+ if (Number.isNaN(at.getTime())) continue
68
+ const key = dayKey(at)
69
+ map.set(key, [...(map.get(key) ?? []), row])
70
+ }
71
+ return map
72
+ })
73
+
74
+ const undated = $derived(
75
+ dateProperty
76
+ ? rows.filter((row) => {
77
+ const raw = row.props[dateProperty.key]
78
+ return typeof raw !== 'string' || raw === '' || Number.isNaN(new Date(raw).getTime())
79
+ })
80
+ : [],
81
+ )
82
+
83
+ const shift = (months: number) => {
84
+ cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1)
85
+ }
86
+
87
+ let dragged = $state<string | null>(null)
88
+
89
+ function dropOn(at: Date) {
90
+ const row = rows.find((r) => r.id === dragged)
91
+ dragged = null
92
+ if (!row) return
93
+ onSetDate(row, new Date(at.getFullYear(), at.getMonth(), at.getDate(), 9).toISOString())
94
+ }
95
+
96
+ const cardMenu = (row: Row): MenuItem[] => [
97
+ { id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
98
+ { type: 'separator' },
99
+ { type: 'label', label: t('db_move_to_date') },
100
+ ...[0, 1, 7].map((offset) => {
101
+ const at = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset, 9)
102
+ return {
103
+ id: `date-${offset}`,
104
+ label:
105
+ offset === 0 ? t('db_date_today') : offset === 1 ? t('db_date_tomorrow') : t('db_date_next_week'),
106
+ icon: 'calendar',
107
+ disabled: !canEdit,
108
+ onSelect: () => onSetDate(row, at.toISOString()),
109
+ }
110
+ }),
111
+ {
112
+ id: 'clear-date',
113
+ label: t('db_date_clear'),
114
+ icon: 'x',
115
+ disabled: !canEdit,
116
+ onSelect: () => onSetDate(row, null),
117
+ },
118
+ ]
119
+ </script>
120
+
121
+ {#if !plottable}
122
+ <div class="pad">
123
+ <EmptyState
124
+ icon="calendar-days"
125
+ title={t('db_calendar_needs_date')}
126
+ description={t('db_calendar_needs_date_desc')}
127
+ >
128
+ {#snippet actions()}
129
+ {#if canEdit}
130
+ <Button variant="secondary" onclick={onConfigure}>{t('db_edit_view')}</Button>
131
+ {/if}
132
+ {/snippet}
133
+ </EmptyState>
134
+ </div>
135
+ {:else}
136
+ <div class="cal">
137
+ <div class="bar">
138
+ <IconButton icon="chevron-left" label={t('db_prev_month')} size={28} variant="ghost" onclick={() => shift(-1)} />
139
+ <h2 class="month">{monthLabel}</h2>
140
+ <IconButton icon="chevron-right" label={t('db_next_month')} size={28} variant="ghost" onclick={() => shift(1)} />
141
+ <Button
142
+ size="sm"
143
+ variant="secondary"
144
+ onclick={() => (cursor = new Date(today.getFullYear(), today.getMonth(), 1))}
145
+ >
146
+ {t('db_this_month')}
147
+ </Button>
148
+ </div>
149
+
150
+ <!--
151
+ A plain layout, not `role="grid"`. A real grid promises arrow-key navigation between cells,
152
+ and promising that without implementing it is worse for a screen-reader user than not claiming
153
+ it at all — the keyboard route to every action here is the card's own menu.
154
+ -->
155
+ <div class="grid" aria-label={monthLabel}>
156
+ {#each weekdays as weekday (weekday)}
157
+ <div class="dow">{weekday}</div>
158
+ {/each}
159
+ {#each days as day (day.toISOString())}
160
+ {@const key = dayKey(day)}
161
+ {@const inMonth = day.getMonth() === cursor.getMonth()}
162
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
163
+ <div
164
+ class="day"
165
+ class:out={!inMonth}
166
+ class:today={key === dayKey(today)}
167
+ ondragover={(e) => canEdit && e.preventDefault()}
168
+ ondrop={(e) => {
169
+ e.preventDefault()
170
+ if (canEdit) dropOn(day)
171
+ }}
172
+ >
173
+ <span class="num">{day.getDate()}</span>
174
+ {#each byDay.get(key) ?? [] as row (row.id)}
175
+ <div
176
+ class="event"
177
+ draggable={canEdit}
178
+ ondragstart={() => (dragged = row.id)}
179
+ ondragend={() => (dragged = null)}
180
+ >
181
+ <span class="ev-title">{row.title.trim() || t('untitled')}</span>
182
+ <DropdownMenu items={cardMenu(row)}>
183
+ {#snippet trigger(props: Record<string, unknown>)}
184
+ <IconButton
185
+ {...props}
186
+ icon="ellipsis"
187
+ label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
188
+ size={26}
189
+ variant="ghost"
190
+ />
191
+ {/snippet}
192
+ </DropdownMenu>
193
+ </div>
194
+ {/each}
195
+ </div>
196
+ {/each}
197
+ </div>
198
+
199
+ {#if undated.length > 0}
200
+ <section class="undated">
201
+ <h3>{t('db_no_date_rows', { n: undated.length })}</h3>
202
+ <ul>
203
+ {#each undated as row (row.id)}
204
+ <li>
205
+ <span class="ev-title">{row.title.trim() || t('untitled')}</span>
206
+ <DropdownMenu items={cardMenu(row)}>
207
+ {#snippet trigger(props: Record<string, unknown>)}
208
+ <IconButton
209
+ {...props}
210
+ icon="ellipsis"
211
+ label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
212
+ size={26}
213
+ variant="ghost"
214
+ />
215
+ {/snippet}
216
+ </DropdownMenu>
217
+ </li>
218
+ {/each}
219
+ </ul>
220
+ </section>
221
+ {/if}
222
+ </div>
223
+ {/if}
224
+
225
+ <style>
226
+ .cal {
227
+ padding: 18px 24px 40px;
228
+ }
229
+ .bar {
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 8px;
233
+ margin-block-end: 14px;
234
+ }
235
+ .month {
236
+ margin: 0;
237
+ min-width: 180px;
238
+ font-size: 15px;
239
+ font-weight: 600;
240
+ color: var(--kern-ink-900);
241
+ }
242
+ .grid {
243
+ display: grid;
244
+ grid-template-columns: repeat(7, minmax(0, 1fr));
245
+ border: 1px solid var(--kern-border);
246
+ border-radius: var(--kern-r-lg);
247
+ overflow: hidden;
248
+ }
249
+ .dow {
250
+ padding: 7px 8px;
251
+ background: var(--kern-surface-header);
252
+ border-block-end: 1px solid var(--kern-border);
253
+ font-size: 11px;
254
+ font-weight: 600;
255
+ letter-spacing: 0.06em;
256
+ text-transform: uppercase;
257
+ color: var(--kern-ink-450);
258
+ }
259
+ :global([dir='rtl']) .dow {
260
+ letter-spacing: 0;
261
+ text-transform: none;
262
+ }
263
+ .day {
264
+ min-height: 96px;
265
+ padding: 5px 6px 8px;
266
+ border-block-end: 1px solid var(--kern-border-hairline);
267
+ border-inline-end: 1px solid var(--kern-border-hairline);
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 4px;
271
+ }
272
+ /* Muted with a colour, never with opacity — opacity fades the text against the page. */
273
+ .day.out .num {
274
+ color: var(--kern-ink-350);
275
+ }
276
+ .day.out {
277
+ background: var(--kern-surface-header);
278
+ }
279
+ .day.today .num {
280
+ background: var(--kern-ink-900);
281
+ color: var(--kern-ink-inverse);
282
+ }
283
+ .num {
284
+ align-self: flex-start;
285
+ min-width: 20px;
286
+ height: 20px;
287
+ padding: 0 5px;
288
+ border-radius: var(--kern-r-full);
289
+ display: inline-grid;
290
+ place-items: center;
291
+ font-size: 11.5px;
292
+ color: var(--kern-ink-550);
293
+ }
294
+ .event {
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 2px;
298
+ min-height: 28px;
299
+ padding-inline: 7px 1px;
300
+ padding-block: 1px;
301
+ border-radius: var(--kern-r-md);
302
+ background: var(--kern-accent-tint);
303
+ color: var(--kern-accent-deep);
304
+ font-size: 12px;
305
+ cursor: grab;
306
+ }
307
+ .ev-title {
308
+ flex: 1;
309
+ min-width: 0;
310
+ overflow: hidden;
311
+ text-overflow: ellipsis;
312
+ white-space: nowrap;
313
+ }
314
+ .undated {
315
+ margin-block-start: 18px;
316
+ }
317
+ .undated h3 {
318
+ margin: 0 0 8px;
319
+ font-size: 12px;
320
+ font-weight: 600;
321
+ color: var(--kern-ink-450);
322
+ }
323
+ .undated ul {
324
+ list-style: none;
325
+ margin: 0;
326
+ padding: 0;
327
+ display: flex;
328
+ flex-wrap: wrap;
329
+ gap: 6px;
330
+ }
331
+ .undated li {
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 2px;
335
+ max-width: 260px;
336
+ padding-inline: 9px 2px;
337
+ padding-block: 2px;
338
+ border-radius: var(--kern-r-md);
339
+ background: var(--kern-surface-chip);
340
+ color: var(--kern-ink-550);
341
+ font-size: 12.5px;
342
+ }
343
+ .pad {
344
+ padding: 28px;
345
+ }
346
+ </style>