@kernhq/module-quire 0.8.0 → 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 (73) 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/services/databases.d.ts +73 -1
  19. package/dist/server/services/databases.d.ts.map +1 -1
  20. package/dist/server/services/databases.js +167 -10
  21. package/dist/server/services/databases.js.map +1 -1
  22. package/dist/server/services/pages.d.ts.map +1 -1
  23. package/dist/server/services/pages.js +11 -1
  24. package/dist/server/services/pages.js.map +1 -1
  25. package/dist/server/services/query.d.ts.map +1 -1
  26. package/dist/server/services/query.js +93 -37
  27. package/dist/server/services/query.js.map +1 -1
  28. package/migrations/0006_rank_collation.sql +14 -0
  29. package/migrations/meta/_journal.json +7 -0
  30. package/package.json +3 -2
  31. package/src/client/components/PageTreeRow.svelte +1 -1
  32. package/src/client/core-api.ts +38 -0
  33. package/src/client/database/BoardView.svelte +354 -0
  34. package/src/client/database/CalendarView.svelte +346 -0
  35. package/src/client/database/DatabaseView.svelte +785 -0
  36. package/src/client/database/FilterMenu.svelte +281 -0
  37. package/src/client/database/FilterValue.svelte +176 -0
  38. package/src/client/database/GalleryView.svelte +177 -0
  39. package/src/client/database/ListView.svelte +108 -0
  40. package/src/client/database/OptionChip.svelte +38 -0
  41. package/src/client/database/PropertyDialog.svelte +460 -0
  42. package/src/client/database/PropertyMenu.svelte +162 -0
  43. package/src/client/database/RowPanel.svelte +157 -0
  44. package/src/client/database/SortMenu.svelte +199 -0
  45. package/src/client/database/TableView.svelte +388 -0
  46. package/src/client/database/ViewDialog.svelte +229 -0
  47. package/src/client/database/cells/Cell.svelte +103 -0
  48. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  49. package/src/client/database/cells/ComputedCell.svelte +100 -0
  50. package/src/client/database/cells/DateCell.svelte +97 -0
  51. package/src/client/database/cells/LinkCell.svelte +143 -0
  52. package/src/client/database/cells/NumberCell.svelte +131 -0
  53. package/src/client/database/cells/PersonCell.svelte +116 -0
  54. package/src/client/database/cells/RelationCell.svelte +222 -0
  55. package/src/client/database/cells/SelectCell.svelte +133 -0
  56. package/src/client/database/cells/TextCell.svelte +85 -0
  57. package/src/client/database/colours.ts +27 -0
  58. package/src/client/database/property-types.test.ts +64 -0
  59. package/src/client/database/property-types.ts +294 -0
  60. package/src/client/database/view-config.test.ts +148 -0
  61. package/src/client/database/view-config.ts +102 -0
  62. package/src/client/formula.test.ts +139 -0
  63. package/src/client/formula.ts +430 -0
  64. package/src/client/i18n.ts +1176 -0
  65. package/src/client/index.ts +31 -0
  66. package/src/client/mock.ts +511 -1
  67. package/src/client/pages/PageView.svelte +47 -17
  68. package/src/client/pages/SpacePage.svelte +8 -2
  69. package/src/client/query.ts +17 -4
  70. package/src/contract/properties.ts +28 -0
  71. package/src/contract/router.ts +46 -0
  72. package/dist/server/formula.d.ts.map +0 -1
  73. package/dist/server/formula.js.map +0 -1
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ import { Avatar, DropdownMenu, type MenuItem } from '@kernhq/ui'
3
+ import type { PropertyConfig } from '../../../contract/index.js'
4
+ import type { Person } from '../../core-api.js'
5
+ import { t } from '../../i18n.js'
6
+
7
+ /**
8
+ * One or more people, stored as user ids.
9
+ *
10
+ * The face comes before the name in the menu, because a person is recognised by their picture
11
+ * faster than by their name — which is what `MenuItem`'s `avatar` field is for.
12
+ */
13
+ interface Props {
14
+ value: unknown
15
+ name: string
16
+ config: PropertyConfig
17
+ people: Person[]
18
+ editable: boolean
19
+ reason?: string
20
+ onchange: (value: unknown) => void
21
+ }
22
+ const { value, name, config, people, editable, reason, onchange }: Props = $props()
23
+
24
+ const many = $derived(config.multiple !== false)
25
+ const chosen = $derived(
26
+ value == null ? [] : (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== ''),
27
+ )
28
+ const personFor = (id: string) => people.find((p) => p.id === id) ?? null
29
+
30
+ const commit = (next: string[]) => onchange(many ? next : (next[0] ?? null))
31
+ const toggle = (id: string, on: boolean) => {
32
+ if (many) commit(on ? [...chosen.filter((v) => v !== id), id] : chosen.filter((v) => v !== id))
33
+ else commit(on ? [id] : [])
34
+ }
35
+
36
+ const items = $derived.by(() => {
37
+ const list: MenuItem[] = people.map((person) => ({
38
+ type: 'checkbox' as const,
39
+ id: person.id,
40
+ label: person.name,
41
+ avatar: { id: person.id, name: person.name, src: person.avatarUrl },
42
+ checked: chosen.includes(person.id),
43
+ onCheckedChange: (on: boolean) => toggle(person.id, on),
44
+ }))
45
+ if (list.length === 0) return [{ type: 'label' as const, label: t('db_people_none') }]
46
+ if (chosen.length > 0)
47
+ list.push(
48
+ { type: 'separator' },
49
+ { id: 'clear', label: t('db_select_clear'), icon: 'x', onSelect: () => commit([]) },
50
+ )
51
+ return list
52
+ })
53
+ </script>
54
+
55
+ {#snippet faces()}
56
+ {#if chosen.length === 0}
57
+ <span class="muted">{t('db_cell_empty')}</span>
58
+ {:else}
59
+ {#each chosen as id (id)}
60
+ <span class="who">
61
+ <Avatar id={id} name={personFor(id)?.name ?? null} src={personFor(id)?.avatarUrl ?? null} size={20} />
62
+ <span class="nm">{personFor(id)?.name ?? id}</span>
63
+ </span>
64
+ {/each}
65
+ {/if}
66
+ {/snippet}
67
+
68
+ {#if editable}
69
+ <DropdownMenu {items} align="start">
70
+ {#snippet trigger(props: Record<string, unknown>)}
71
+ <button {...props} type="button" class="cell-trigger" aria-label={name}>{@render faces()}</button>
72
+ {/snippet}
73
+ </DropdownMenu>
74
+ {:else}
75
+ <span class="cell-trigger static" title={reason}>{@render faces()}</span>
76
+ {/if}
77
+
78
+ <style>
79
+ .cell-trigger {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ max-width: 100%;
84
+ min-height: 26px;
85
+ padding: 2px 6px;
86
+ margin-inline-start: -6px;
87
+ border: 0;
88
+ border-radius: var(--kern-r-sm);
89
+ background: none;
90
+ color: inherit;
91
+ font: inherit;
92
+ font-size: 13px;
93
+ text-align: start;
94
+ overflow: hidden;
95
+ }
96
+ button.cell-trigger:hover {
97
+ background: var(--kern-surface-active);
98
+ }
99
+ .cell-trigger.static {
100
+ cursor: default;
101
+ }
102
+ .who {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ gap: 5px;
106
+ min-width: 0;
107
+ }
108
+ .nm {
109
+ overflow: hidden;
110
+ text-overflow: ellipsis;
111
+ white-space: nowrap;
112
+ }
113
+ .muted {
114
+ color: var(--kern-ink-450);
115
+ }
116
+ </style>
@@ -0,0 +1,222 @@
1
+ <script lang="ts">
2
+ import { Icon, IconButton, Popover, SearchBox, Spinner } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import type { PropertyConfig, RowRef } from '../../../contract/index.js'
5
+ import { getQuireApi } from '../../api-instance.js'
6
+ import { t } from '../../i18n.js'
7
+ import { quireKeys } from '../../query.js'
8
+
9
+ /**
10
+ * Rows linked from another database.
11
+ *
12
+ * A relation stores page ids and nothing else, so both jobs this cell has — drawing the links it
13
+ * already holds and finding the next one — are `databases.lookup` calls against the database on the
14
+ * other side. Without the first, the column is a list of uuids.
15
+ *
16
+ * The write goes through `updateRow`'s props like every other cell; the server splits relation keys
17
+ * out and routes them to `setRelation`, so the join table and the mirror in `props` cannot diverge
18
+ * whichever surface did the editing.
19
+ */
20
+ interface Props {
21
+ value: unknown
22
+ name: string
23
+ config: PropertyConfig
24
+ workspaceId: string
25
+ editable: boolean
26
+ reason?: string
27
+ onchange: (value: unknown) => void
28
+ }
29
+ const { value, name, config, workspaceId, editable, reason, onchange }: Props = $props()
30
+
31
+ const api = getQuireApi()
32
+
33
+ const ids = $derived(
34
+ value == null ? [] : (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== ''),
35
+ )
36
+ const targetId = $derived(config.relationDatabaseId ?? null)
37
+
38
+ let open = $state(false)
39
+ let term = $state('')
40
+
41
+ /** The names of what is already linked. Keyed by the ids so it re-resolves when they change. */
42
+ const linked = createQuery(() => ({
43
+ queryKey: [...quireKeys.lookup(workspaceId, targetId ?? '', 'ids'), ids.join(',')],
44
+ enabled: Boolean(workspaceId && targetId) && ids.length > 0,
45
+ queryFn: () => api.databases.lookup({ workspaceId, databaseId: targetId!, ids, query: '', limit: 100 }),
46
+ }))
47
+
48
+ const search = createQuery(() => ({
49
+ queryKey: quireKeys.lookup(workspaceId, targetId ?? '', term),
50
+ enabled: Boolean(workspaceId && targetId) && open,
51
+ queryFn: () =>
52
+ api.databases.lookup({ workspaceId, databaseId: targetId!, query: term, ids: [], limit: 25 }),
53
+ }))
54
+
55
+ /** An id whose row has been deleted still has to draw as something. */
56
+ const chips = $derived<RowRef[]>(
57
+ ids.map((id) => (linked.data ?? []).find((r) => r.id === id) ?? { id, title: t('untitled'), icon: null }),
58
+ )
59
+
60
+ const results = $derived((search.data ?? []).filter((r) => !ids.includes(r.id)))
61
+
62
+ const link = (id: string) => {
63
+ onchange([...ids, id])
64
+ term = ''
65
+ }
66
+ const unlink = (id: string) => onchange(ids.filter((v) => v !== id))
67
+ </script>
68
+
69
+ <span class="rel">
70
+ {#each chips as chip (chip.id)}
71
+ <span class="chip">
72
+ <span class="nm">{chip.title.trim() || t('untitled')}</span>
73
+ {#if editable}
74
+ <button
75
+ type="button"
76
+ class="x"
77
+ aria-label={t('db_relation_unlink', { title: chip.title.trim() || t('untitled') })}
78
+ onclick={() => unlink(chip.id)}
79
+ >
80
+ <Icon name="x" size={11} strokeWidth={2} />
81
+ </button>
82
+ {/if}
83
+ </span>
84
+ {/each}
85
+
86
+ {#if ids.length === 0 && !editable}
87
+ <span class="muted" title={reason}>{t('db_cell_empty')}</span>
88
+ {/if}
89
+
90
+ {#if editable}
91
+ {#if !targetId}
92
+ <!-- Not disabled-with-no-reason: the column has nothing on the other side to link to yet. -->
93
+ <span class="muted" title={t('db_relation_untargeted')}>{t('db_relation_untargeted')}</span>
94
+ {:else}
95
+ <Popover bind:open align="start" width="300px" onOpenChange={(o) => !o && (term = '')}>
96
+ {#snippet trigger(props: Record<string, unknown>)}
97
+ <button {...props} type="button" class="add" aria-label={t('db_relation_link')}>
98
+ <Icon name="plus" size={13} strokeWidth={1.9} />
99
+ </button>
100
+ {/snippet}
101
+ <div class="picker">
102
+ <SearchBox
103
+ bind:value={term}
104
+ placeholder={t('db_relation_search')}
105
+ label={t('db_relation_search')}
106
+ height={32}
107
+ />
108
+ {#if search.isLoading}
109
+ <div class="state"><Spinner size={16} /></div>
110
+ {:else if search.isError}
111
+ <p class="state">{t('common.error')}</p>
112
+ {:else if results.length === 0}
113
+ <p class="state">{t('db_relation_none')}</p>
114
+ {:else}
115
+ <ul class="results">
116
+ {#each results as row (row.id)}
117
+ <li>
118
+ <button type="button" class="result" onclick={() => link(row.id)}>
119
+ <Icon name="file-text" size={13} strokeWidth={1.7} />
120
+ <span class="nm">{row.title.trim() || t('untitled')}</span>
121
+ </button>
122
+ </li>
123
+ {/each}
124
+ </ul>
125
+ {/if}
126
+ </div>
127
+ </Popover>
128
+ {/if}
129
+ {/if}
130
+ </span>
131
+
132
+ <style>
133
+ .rel {
134
+ display: inline-flex;
135
+ align-items: center;
136
+ gap: 4px;
137
+ min-width: 0;
138
+ overflow: hidden;
139
+ }
140
+ .chip {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ gap: 2px;
144
+ max-width: 160px;
145
+ padding-inline: 7px 2px;
146
+ padding-block: 2px;
147
+ border-radius: var(--kern-r-md);
148
+ background: var(--kern-surface-chip);
149
+ color: var(--kern-ink-550);
150
+ font-size: 12px;
151
+ }
152
+ .nm {
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ white-space: nowrap;
156
+ }
157
+ .x,
158
+ .add {
159
+ flex: none;
160
+ display: inline-grid;
161
+ place-items: center;
162
+ width: 24px;
163
+ height: 24px;
164
+ border: 0;
165
+ border-radius: var(--kern-r-sm);
166
+ background: none;
167
+ color: var(--kern-ink-450);
168
+ }
169
+ .x:hover,
170
+ .add:hover {
171
+ background: var(--kern-surface-active);
172
+ color: var(--kern-ink-900);
173
+ }
174
+ .picker {
175
+ padding: 10px;
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 8px;
179
+ }
180
+ .results {
181
+ list-style: none;
182
+ margin: 0;
183
+ padding: 0;
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: 1px;
187
+ max-height: 240px;
188
+ overflow-y: auto;
189
+ }
190
+ .result {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 7px;
194
+ width: 100%;
195
+ min-height: 30px;
196
+ padding: 4px 8px;
197
+ border: 0;
198
+ border-radius: var(--kern-r-md);
199
+ background: none;
200
+ color: var(--kern-ink-700);
201
+ font: inherit;
202
+ font-size: 13px;
203
+ text-align: start;
204
+ }
205
+ .result:hover {
206
+ background: var(--kern-surface-popover-hover);
207
+ }
208
+ .state {
209
+ margin: 0;
210
+ padding: 12px 4px;
211
+ text-align: center;
212
+ font-size: 12.5px;
213
+ color: var(--kern-ink-450);
214
+ }
215
+ .muted {
216
+ color: var(--kern-ink-450);
217
+ font-size: 13px;
218
+ overflow: hidden;
219
+ text-overflow: ellipsis;
220
+ white-space: nowrap;
221
+ }
222
+ </style>
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, type MenuItem } from '@kernhq/ui'
3
+ import type { PropertyConfig, PropertyType } from '../../../contract/index.js'
4
+ import { t } from '../../i18n.js'
5
+ import OptionChip from '../OptionChip.svelte'
6
+ import { STATUS_GROUPS } from '../view-config.js'
7
+
8
+ /**
9
+ * select, multi_select and status.
10
+ *
11
+ * A menu rather than an inline list, because the table cell clips (`.ktd` is `overflow: hidden`)
12
+ * and `DropdownMenu` portals out of it. A status groups its options into the workflow bands it
13
+ * declares, so "Done" is not sitting between "Blocked" and "Doing" in alphabetical order.
14
+ */
15
+ interface Props {
16
+ value: unknown
17
+ name: string
18
+ type: PropertyType
19
+ config: PropertyConfig
20
+ editable: boolean
21
+ reason?: string
22
+ onchange: (value: unknown) => void
23
+ }
24
+ const { value, name, type, config, editable, reason, onchange }: Props = $props()
25
+
26
+ const many = $derived(type === 'multi_select')
27
+ const options = $derived(config.options ?? [])
28
+
29
+ const chosen = $derived.by(() => {
30
+ if (value == null) return [] as string[]
31
+ return (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== '')
32
+ })
33
+ const optionFor = (id: string) => options.find((o) => o.id === id) ?? null
34
+
35
+ const commit = (next: string[]) => onchange(many ? next : (next[0] ?? null))
36
+
37
+ const toggle = (id: string, on: boolean) => {
38
+ if (many) commit(on ? [...chosen.filter((v) => v !== id), id] : chosen.filter((v) => v !== id))
39
+ else commit(on ? [id] : [])
40
+ }
41
+
42
+ /** Status options in band order; everything else in the order the column declares them. */
43
+ const ordered = $derived.by(() => {
44
+ if (type !== 'status') return options.map((option) => ({ option, band: null as string | null }))
45
+ const out: { option: (typeof options)[number]; band: string | null }[] = []
46
+ for (const band of STATUS_GROUPS)
47
+ for (const option of options.filter((o) => o.group === band)) out.push({ option, band })
48
+ for (const option of options.filter((o) => !o.group || !STATUS_GROUPS.includes(o.group)))
49
+ out.push({ option, band: null })
50
+ return out
51
+ })
52
+
53
+ const items = $derived.by(() => {
54
+ const list: MenuItem[] = []
55
+ let band: string | null | undefined
56
+ for (const entry of ordered) {
57
+ if (type === 'status' && entry.band && entry.band !== band) {
58
+ list.push({ type: 'label', label: t(`db_status_${entry.band}`) })
59
+ band = entry.band
60
+ }
61
+ list.push({
62
+ type: 'checkbox',
63
+ id: entry.option.id,
64
+ label: entry.option.label,
65
+ checked: chosen.includes(entry.option.id),
66
+ onCheckedChange: (on: boolean) => toggle(entry.option.id, on),
67
+ })
68
+ }
69
+ if (list.length === 0) list.push({ type: 'label', label: t('db_options_none') })
70
+ else if (chosen.length > 0)
71
+ list.push(
72
+ { type: 'separator' },
73
+ { id: 'clear', label: t('db_select_clear'), icon: 'x', onSelect: () => commit([]) },
74
+ )
75
+ return list
76
+ })
77
+ </script>
78
+
79
+ {#if editable}
80
+ <DropdownMenu items={items} align="start">
81
+ {#snippet trigger(props: Record<string, unknown>)}
82
+ <button {...props} type="button" class="cell-trigger" aria-label={name}>
83
+ {#if chosen.length === 0}
84
+ <span class="muted">{t('db_cell_empty')}</span>
85
+ {:else}
86
+ {#each chosen as id (id)}
87
+ <OptionChip option={optionFor(id)} label={optionFor(id) ? undefined : id} compact />
88
+ {/each}
89
+ {/if}
90
+ </button>
91
+ {/snippet}
92
+ </DropdownMenu>
93
+ {:else}
94
+ <span class="cell-trigger static" title={reason}>
95
+ {#if chosen.length === 0}
96
+ <span class="muted">{t('db_cell_empty')}</span>
97
+ {:else}
98
+ {#each chosen as id (id)}
99
+ <OptionChip option={optionFor(id)} label={optionFor(id) ? undefined : id} compact />
100
+ {/each}
101
+ {/if}
102
+ </span>
103
+ {/if}
104
+
105
+ <style>
106
+ .cell-trigger {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ flex-wrap: nowrap;
110
+ gap: 4px;
111
+ max-width: 100%;
112
+ min-height: 26px;
113
+ padding: 2px 6px;
114
+ margin-inline-start: -6px;
115
+ border: 0;
116
+ border-radius: var(--kern-r-sm);
117
+ background: none;
118
+ color: inherit;
119
+ font: inherit;
120
+ font-size: 13px;
121
+ text-align: start;
122
+ overflow: hidden;
123
+ }
124
+ button.cell-trigger:hover {
125
+ background: var(--kern-surface-active);
126
+ }
127
+ .cell-trigger.static {
128
+ cursor: default;
129
+ }
130
+ .muted {
131
+ color: var(--kern-ink-450);
132
+ }
133
+ </style>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import { t } from '../../i18n.js'
3
+
4
+ /**
5
+ * A bare input that only looks like a control while somebody is in it (DESIGN.md §3.13).
6
+ *
7
+ * Committed on blur and on Enter, never per keystroke: each edit is its own mutation, and a request
8
+ * per character is both slow and impossible to reason about when one of them fails.
9
+ */
10
+ interface Props {
11
+ value: unknown
12
+ name: string
13
+ editable: boolean
14
+ reason?: string
15
+ onchange: (value: unknown) => void
16
+ }
17
+ const { value, name, editable, reason, onchange }: Props = $props()
18
+
19
+ const text = $derived(value == null ? '' : String(value))
20
+
21
+ /** An empty field is a cleared cell, and `null` is how the API says that. */
22
+ const commit = (next: string) => {
23
+ if (next === text) return
24
+ onchange(next === '' ? null : next)
25
+ }
26
+ </script>
27
+
28
+ {#if editable}
29
+ <input
30
+ class="cell-input"
31
+ value={text}
32
+ aria-label={name}
33
+ placeholder={t('db_cell_empty')}
34
+ onblur={(e) => commit(e.currentTarget.value)}
35
+ onkeydown={(e) => {
36
+ if (e.key === 'Enter') e.currentTarget.blur()
37
+ if (e.key === 'Escape') {
38
+ e.currentTarget.value = text
39
+ e.currentTarget.blur()
40
+ }
41
+ }}
42
+ />
43
+ {:else}
44
+ <span class="cell-static" title={reason}>
45
+ {#if text}{text}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
46
+ </span>
47
+ {/if}
48
+
49
+ <style>
50
+ .cell-input {
51
+ width: 100%;
52
+ min-width: 0;
53
+ min-height: 26px;
54
+ padding: 2px 6px;
55
+ margin-inline-start: -6px;
56
+ border: 1px solid transparent;
57
+ border-radius: var(--kern-r-sm);
58
+ background: none;
59
+ color: inherit;
60
+ font: inherit;
61
+ font-size: 13px;
62
+ }
63
+ .cell-input:hover {
64
+ background: var(--kern-surface-active);
65
+ }
66
+ .cell-input:focus {
67
+ border-color: var(--kern-border);
68
+ background: var(--kern-surface-raised);
69
+ outline: none;
70
+ }
71
+ .cell-input::placeholder {
72
+ color: var(--kern-ink-350);
73
+ }
74
+ .cell-static {
75
+ min-width: 0;
76
+ overflow: hidden;
77
+ text-overflow: ellipsis;
78
+ white-space: nowrap;
79
+ cursor: default;
80
+ font-size: 13px;
81
+ }
82
+ .muted {
83
+ color: var(--kern-ink-450);
84
+ }
85
+ </style>
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The colours a select option may wear.
3
+ *
4
+ * Closed on purpose. `SelectOption.colour` is free text in the contract, so painting it directly
5
+ * means one typo renders a chip with no background — and, worse, a colour pair nobody has measured.
6
+ * Every pair here is one the design tokens already tuned for contrast in light and dark; an unknown
7
+ * name falls back to grey rather than to nothing.
8
+ */
9
+ export interface Tone {
10
+ bg: string
11
+ fg: string
12
+ }
13
+
14
+ export const TONES: Record<string, Tone> = {
15
+ grey: { bg: 'var(--kern-surface-chip)', fg: 'var(--kern-ink-550)' },
16
+ slate: { bg: 'var(--kern-slate-tint)', fg: 'var(--kern-slate)' },
17
+ accent: { bg: 'var(--kern-accent-tint)', fg: 'var(--kern-accent-deep)' },
18
+ success: { bg: 'var(--kern-success-tint)', fg: 'var(--kern-success-chip)' },
19
+ warning: { bg: 'var(--kern-warning-tint)', fg: 'var(--kern-warning)' },
20
+ danger: { bg: 'var(--kern-danger-tint)', fg: 'var(--kern-danger)' },
21
+ info: { bg: 'var(--kern-info-tint)', fg: 'var(--kern-info)' },
22
+ purple: { bg: 'var(--kern-purple-tint)', fg: 'var(--kern-purple)' },
23
+ }
24
+
25
+ export const OPTION_COLOURS = Object.keys(TONES)
26
+
27
+ export const toneFor = (colour: string | undefined | null): Tone => TONES[colour ?? 'grey'] ?? TONES.grey!
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The property-type table is what every branch in the database interface reads instead of
3
+ * re-switching on the type. These assertions are about the two ways it can be wrong and nothing
4
+ * will say so: a type with no entry (which draws nothing), and an operator offered for a column
5
+ * that cannot answer it (which silently matches no rows).
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import { PropertyType } from '../../contract/index.js'
9
+ import {
10
+ CREATABLE_TYPES,
11
+ descriptorFor,
12
+ isReadOnly,
13
+ operatorsFor,
14
+ PROPERTY_TYPES,
15
+ VIEW_KINDS,
16
+ } from './property-types.js'
17
+
18
+ describe('the property type table', () => {
19
+ it('describes every type the contract declares', () => {
20
+ for (const type of PropertyType.options) {
21
+ const descriptor = descriptorFor(type)
22
+ expect(descriptor, `${type} has no descriptor, so its column draws nothing`).toBeDefined()
23
+ expect(descriptor.icon.length, `${type} icon`).toBeGreaterThan(0)
24
+ }
25
+ expect(Object.keys(PROPERTY_TYPES).sort()).toEqual([...PropertyType.options].sort())
26
+ })
27
+
28
+ it('marks exactly the server-written types read-only', () => {
29
+ const readOnly = PropertyType.options.filter((t) => isReadOnly(t)).sort()
30
+ expect(readOnly).toEqual(
31
+ ['created_by', 'created_time', 'edited_by', 'edited_time', 'files', 'formula', 'rollup'].sort(),
32
+ )
33
+ })
34
+
35
+ it('offers a checkbox equality and not a substring', () => {
36
+ expect(operatorsFor('checkbox')).toContain('equals')
37
+ expect(operatorsFor('checkbox')).not.toContain('contains')
38
+ })
39
+
40
+ it('offers a multi-select membership and not a prefix', () => {
41
+ expect(operatorsFor('multi_select')).toContain('is_any_of')
42
+ expect(operatorsFor('multi_select')).not.toContain('starts_with')
43
+ })
44
+
45
+ it('never offers an operator with no value editor for a type that needs one', () => {
46
+ for (const type of PropertyType.options)
47
+ expect(operatorsFor(type).length, `${type} can be filtered by nothing at all`).toBeGreaterThan(0)
48
+ })
49
+
50
+ it('keeps files out of the picker, because nothing can fill one', () => {
51
+ expect(CREATABLE_TYPES).not.toContain('files')
52
+ expect(CREATABLE_TYPES).toContain('text')
53
+ })
54
+
55
+ it('only groups a board by a column whose values are a closed set', () => {
56
+ const groupable = PropertyType.options.filter((t) => descriptorFor(t).canGroup).sort()
57
+ expect(groupable).toEqual(['checkbox', 'select', 'status'])
58
+ })
59
+
60
+ it('leaves timeline out of the kinds it offers, because none of it is built', () => {
61
+ expect(VIEW_KINDS.map((v) => v.kind)).not.toContain('timeline')
62
+ expect(VIEW_KINDS.map((v) => v.kind)).toEqual(['table', 'board', 'gallery', 'list', 'calendar'])
63
+ })
64
+ })