@kernhq/module-quire 0.10.9 → 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 (53) hide show
  1. package/dist/contract/models.d.ts +87 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +73 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts.map +1 -1
  6. package/dist/contract/permissions.js +32 -0
  7. package/dist/contract/permissions.js.map +1 -1
  8. package/dist/contract/router.d.ts +645 -0
  9. package/dist/contract/router.d.ts.map +1 -1
  10. package/dist/contract/router.js +137 -2
  11. package/dist/contract/router.js.map +1 -1
  12. package/dist/server/_impl.d.ts +877 -0
  13. package/dist/server/_impl.d.ts.map +1 -1
  14. package/dist/server/_impl.js +127 -1
  15. package/dist/server/_impl.js.map +1 -1
  16. package/dist/server/schema.d.ts +482 -1
  17. package/dist/server/schema.d.ts.map +1 -1
  18. package/dist/server/schema.js +115 -1
  19. package/dist/server/schema.js.map +1 -1
  20. package/dist/server/services/index.d.ts +3 -0
  21. package/dist/server/services/index.d.ts.map +1 -1
  22. package/dist/server/services/index.js +3 -0
  23. package/dist/server/services/index.js.map +1 -1
  24. package/dist/server/services/organisation.d.ts +117 -0
  25. package/dist/server/services/organisation.d.ts.map +1 -0
  26. package/dist/server/services/organisation.js +319 -0
  27. package/dist/server/services/organisation.js.map +1 -0
  28. package/dist/server/services/pages.d.ts +16 -1
  29. package/dist/server/services/pages.d.ts.map +1 -1
  30. package/dist/server/services/pages.js +62 -3
  31. package/dist/server/services/pages.js.map +1 -1
  32. package/migrations/0007_organisation.sql +114 -0
  33. package/migrations/meta/0007_snapshot.json +1588 -0
  34. package/migrations/meta/_journal.json +7 -0
  35. package/package.json +1 -1
  36. package/src/client/components/ConfirmDialog.svelte +123 -0
  37. package/src/client/components/FavoriteStar.svelte +81 -0
  38. package/src/client/components/LabelChip.svelte +46 -0
  39. package/src/client/components/LabelManager.svelte +336 -0
  40. package/src/client/components/PageLabels.svelte +158 -0
  41. package/src/client/components/SidebarFavorites.svelte +262 -0
  42. package/src/client/components/SidebarRecents.svelte +98 -0
  43. package/src/client/components/SidebarSpaces.svelte +266 -1
  44. package/src/client/i18n.ts +387 -0
  45. package/src/client/index.ts +8 -0
  46. package/src/client/mock.ts +306 -0
  47. package/src/client/module.ts +18 -0
  48. package/src/client/pages/PageView.svelte +227 -4
  49. package/src/client/pages/TrashPage.svelte +378 -0
  50. package/src/client/query.ts +24 -0
  51. package/src/contract/models.ts +83 -0
  52. package/src/contract/permissions.ts +36 -0
  53. package/src/contract/router.ts +153 -1
@@ -50,6 +50,13 @@
50
50
  "when": 1787748000000,
51
51
  "tag": "0006_rank_collation",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1787855855860,
58
+ "tag": "0007_organisation",
59
+ "breakpoints": true
53
60
  }
54
61
  ]
55
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-quire",
3
- "version": "0.10.9",
3
+ "version": "0.11.0",
4
4
  "description": "Kern Quire: collaborative documents, spaces and page trees",
5
5
  "homepage": "https://github.com/KernAIO/module-quire#readme",
6
6
  "license": "AGPL-3.0-only",
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+
5
+ /**
6
+ * "Are you sure?", said once and properly.
7
+ *
8
+ * Three destructive actions in this module needed the same dialog — moving a page to the trash,
9
+ * emptying one out of it for good, and deleting a label off every page that wears it — and the
10
+ * shape they share is the part worth getting right rather than writing three times:
11
+ *
12
+ * - **The body says what will happen, in numbers.** "Move to trash" fired with no confirmation and
13
+ * no way back, and it takes the whole subtree: deleting "Working here" silently took "Your first
14
+ * week" and "Time off" with it. A confirmation that does not say *how many* pages is barely
15
+ * better than none, so the caller passes a sentence that has already counted them.
16
+ * - **The confirm button is guarded, not disabled.** `busy` reaches the button on the *next*
17
+ * render, so two quick clicks are one render apart and both get through — the flag is set in the
18
+ * same tick as the click and read before anything is called. Disabling it would also blur the
19
+ * control the person is standing on and hand their focus to `<body>`; `aria-busy` says the same
20
+ * thing to a screen reader without moving anything.
21
+ * - **`pending` holds the same door shut until the sentence is true.** A caller whose body is still
22
+ * counting passes it, and confirming does nothing until the number arrives. Without it the body
23
+ * read "Loading…" while the danger button was fully live, so the one dialog whose job is to say
24
+ * how much goes could be answered before it had said anything at all. It is guarded rather than
25
+ * disabled for the reason above.
26
+ * - **Cancel is the first thing focus lands on.** `Dialog` focuses the first control in the body
27
+ * and there is nothing focusable there, so it keeps the close button — which is the safe one.
28
+ */
29
+ interface Props {
30
+ open?: boolean
31
+ title: string
32
+ body: string
33
+ /** a second line for what cannot be undone — kept apart so it can be weighted differently */
34
+ note?: string | null
35
+ confirmLabel: string
36
+ danger?: boolean
37
+ /** the body does not know its numbers yet, so nothing may be confirmed against it */
38
+ pending?: boolean
39
+ onConfirm: () => Promise<void> | void
40
+ onCancel?: () => void
41
+ }
42
+ let {
43
+ open = $bindable(false),
44
+ title,
45
+ body,
46
+ note = null,
47
+ confirmLabel,
48
+ danger = false,
49
+ pending = false,
50
+ onConfirm,
51
+ onCancel,
52
+ }: Props = $props()
53
+
54
+ let busy = $state(false)
55
+
56
+ async function confirm() {
57
+ if (busy || pending) return
58
+ busy = true
59
+ try {
60
+ await onConfirm()
61
+ open = false
62
+ } finally {
63
+ busy = false
64
+ }
65
+ }
66
+
67
+ function cancel() {
68
+ if (busy) return
69
+ open = false
70
+ onCancel?.()
71
+ }
72
+ </script>
73
+
74
+ <Dialog
75
+ bind:open
76
+ {title}
77
+ size="sm"
78
+ onOpenChange={(next) => {
79
+ if (!next) onCancel?.()
80
+ }}
81
+ >
82
+ <p class="body">{body}</p>
83
+ {#if note}<p class="note">{note}</p>{/if}
84
+
85
+ {#snippet footer()}
86
+ <div class="foot">
87
+ <Button variant="secondary" onclick={cancel}>{t('cancel')}</Button>
88
+ <Button
89
+ variant={danger ? 'danger' : 'primary'}
90
+ aria-busy={busy || pending}
91
+ onclick={() => void confirm()}
92
+ >
93
+ {confirmLabel}
94
+ </Button>
95
+ </div>
96
+ {/snippet}
97
+ </Dialog>
98
+
99
+ <style>
100
+ .body {
101
+ margin: 0;
102
+ font-size: 13.5px;
103
+ line-height: 1.55;
104
+ color: var(--kern-ink-700);
105
+ text-wrap: pretty;
106
+ }
107
+ /*
108
+ * Muted with a colour, never with `opacity`: fading a paragraph against the page is how a line
109
+ * meant to read as secondary ends up unreadable, whatever its colour token says.
110
+ */
111
+ .note {
112
+ margin: 10px 0 0;
113
+ font-size: 13px;
114
+ line-height: 1.55;
115
+ color: var(--kern-ink-450);
116
+ text-wrap: pretty;
117
+ }
118
+ .foot {
119
+ display: flex;
120
+ justify-content: flex-end;
121
+ gap: 8px;
122
+ }
123
+ </style>
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ import { IconButton } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import type { FavoriteEntry } 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
+ * The star on a page, and the one place a favourite is made.
11
+ *
12
+ * `favorites.list` is one query for the whole workspace and the sidebar is already holding it, so
13
+ * this reads the same cache rather than asking whether this one page is starred — two screens that
14
+ * ask separately are two screens that can disagree about it. Both mutations answer with the whole
15
+ * ordered list, which goes straight into the cache: the sidebar redraws from the reply and nothing
16
+ * refetches.
17
+ *
18
+ * Guarded with a plain flag rather than `disabled`. The attribute lands a render later, so a
19
+ * double-click would star and unstar in one gesture; and disabling the button somebody has just
20
+ * pressed blurs it and hands their focus to `<body>`. `aria-busy` says the same thing without
21
+ * moving anything.
22
+ */
23
+ interface Props {
24
+ workspaceId: string
25
+ pageId: string
26
+ }
27
+ const { workspaceId, pageId }: Props = $props()
28
+
29
+ const api = getQuireApi()
30
+ const client = useQueryClient()
31
+
32
+ const query = createQuery(() => ({
33
+ queryKey: quireKeys.favorites(workspaceId),
34
+ enabled: Boolean(workspaceId),
35
+ queryFn: () => api.favorites.list({ workspaceId }),
36
+ }))
37
+
38
+ const starred = $derived((query.data ?? []).some((f) => f.pageId === pageId))
39
+ let busy = $state(false)
40
+
41
+ async function toggle() {
42
+ if (busy || !workspaceId || !pageId) return
43
+ busy = true
44
+ try {
45
+ const list: FavoriteEntry[] = starred
46
+ ? await api.favorites.remove({ workspaceId, pageId })
47
+ : await api.favorites.add({ workspaceId, pageId })
48
+ client.setQueryData(quireKeys.favorites(workspaceId), list)
49
+ } finally {
50
+ busy = false
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <span class="star" class:on={starred}>
56
+ <IconButton
57
+ icon="star"
58
+ variant="ghost"
59
+ label={starred ? t('favorite_remove') : t('favorite_add')}
60
+ aria-pressed={starred}
61
+ aria-busy={busy}
62
+ onclick={() => void toggle()}
63
+ />
64
+ </span>
65
+
66
+ <style>
67
+ .star {
68
+ display: inline-flex;
69
+ }
70
+ /*
71
+ * A filled star, not a highlighted one. `Icon` draws a stroked outline, so "on" is the same glyph
72
+ * flooded with its own colour — which is what makes the two states tell apart at a glance and in
73
+ * a screenshot, where a background tint at this size does not.
74
+ */
75
+ .star.on :global(.kib) {
76
+ color: var(--kern-warning);
77
+ }
78
+ .star.on :global(.kib svg) {
79
+ fill: currentColor;
80
+ }
81
+ </style>
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import type { Label } from '../../contract/index.js'
3
+ import { toneFor } from '../database/colours.js'
4
+
5
+ /**
6
+ * One label, painted.
7
+ *
8
+ * `LabelColour` is a closed enum whose members are exactly the keys of `TONES`, so the lookup can
9
+ * never miss — but it still goes through `toneFor`, which falls back to grey, because the same
10
+ * chips are drawn from data a server sent and a value nobody has seen before should be a grey chip
11
+ * rather than an unpainted one. Every pair in `TONES` is one the design tokens tuned for contrast
12
+ * in both themes, which is the whole reason a label's colour is a menu rather than a text field.
13
+ */
14
+ interface Props {
15
+ label: Label
16
+ size?: 'sm' | 'md'
17
+ }
18
+ const { label, size = 'md' }: Props = $props()
19
+ const tone = $derived(toneFor(label.colour))
20
+ </script>
21
+
22
+ <span class="chip" class:sm={size === 'sm'} style:background={tone.bg} style:color={tone.fg}>
23
+ {label.name}
24
+ </span>
25
+
26
+ <style>
27
+ .chip {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ max-width: 180px;
31
+ overflow: hidden;
32
+ text-overflow: ellipsis;
33
+ white-space: nowrap;
34
+ padding: 3px 9px;
35
+ border-radius: var(--kern-r-md);
36
+ font-size: 12px;
37
+ font-weight: 500;
38
+ letter-spacing: -0.005em;
39
+ line-height: 1.4;
40
+ }
41
+ .chip.sm {
42
+ padding: 1px 6px;
43
+ font-size: 11px;
44
+ border-radius: var(--kern-r-sm);
45
+ }
46
+ </style>
@@ -0,0 +1,336 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, EmptyState, Icon, IconButton, Input, Skeleton } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import type { Label, LabelColour } from '../../contract/index.js'
5
+ import { getQuireApi } from '../api-instance.js'
6
+ import { OPTION_COLOURS, toneFor } from '../database/colours.js'
7
+ import { t } from '../i18n.js'
8
+ import { quireKeys } from '../query.js'
9
+ import ConfirmDialog from './ConfirmDialog.svelte'
10
+ import LabelChip from './LabelChip.svelte'
11
+
12
+ /**
13
+ * A space's vocabulary, where it belongs to whoever runs the space.
14
+ *
15
+ * Reading a label is `quire.space.view` and writing one is `quire.space.manage`, because renaming
16
+ * "Draft" changes what it means on every page wearing it — that is not something somebody who may
17
+ * edit one page should be able to do to everyone else's. This dialog is only offered to the second
18
+ * group; the picker on a page offers a way in here, and simply omits it for the first.
19
+ *
20
+ * One row is in edit mode at a time rather than every row carrying its own draft. Svelte state
21
+ * lives per component instance, not per `{#each}` iteration, so a draft per row would mean either
22
+ * a component per row or a map that has to be reconciled against the query on every refetch — and
23
+ * a map like that is exactly the sort of state an `$effect` ends up both reading and writing.
24
+ */
25
+ interface Props {
26
+ open?: boolean
27
+ workspaceId: string
28
+ spaceId: string
29
+ }
30
+ let { open = $bindable(false), workspaceId, spaceId }: Props = $props()
31
+
32
+ const api = getQuireApi()
33
+ const client = useQueryClient()
34
+
35
+ const query = createQuery(() => ({
36
+ queryKey: quireKeys.labels(workspaceId, spaceId),
37
+ enabled: open && Boolean(workspaceId && spaceId),
38
+ queryFn: () => api.labels.list({ workspaceId, spaceId }),
39
+ }))
40
+ const labels = $derived(query.data ?? [])
41
+
42
+ /** The one row being edited, and what is being typed into it. */
43
+ let editingId = $state<string | null>(null)
44
+ let draftName = $state('')
45
+ let draftColour = $state<LabelColour>('grey')
46
+
47
+ let newName = $state('')
48
+ let newColour = $state<LabelColour>('grey')
49
+ let error = $state<string | null>(null)
50
+ let busy = $state(false)
51
+
52
+ let confirming = $state<Label | null>(null)
53
+ const confirmOpen = $derived(confirming !== null)
54
+
55
+ /**
56
+ * "Draft" beside "draft" in one picker is broken data, so the clash is case-insensitive here for
57
+ * the same reason it is in the database. Caught locally as well as on the server: the server's
58
+ * answer is right and arrives after a round trip, and a name field should say so as you type.
59
+ */
60
+ const clashes = (name: string, exceptId: string | null) =>
61
+ labels.some((l) => l.id !== exceptId && l.name.toLowerCase() === name.trim().toLowerCase())
62
+
63
+ const refresh = () => client.invalidateQueries({ queryKey: quireKeys.labels(workspaceId, spaceId) })
64
+
65
+ function startEditing(label: Label) {
66
+ editingId = label.id
67
+ draftName = label.name
68
+ draftColour = label.colour
69
+ error = null
70
+ }
71
+
72
+ function stopEditing() {
73
+ editingId = null
74
+ error = null
75
+ }
76
+
77
+ async function create() {
78
+ const name = newName.trim()
79
+ if (busy || !name) return
80
+ if (clashes(name, null)) {
81
+ error = t('label_taken')
82
+ return
83
+ }
84
+ busy = true
85
+ error = null
86
+ try {
87
+ await api.labels.create({ workspaceId, spaceId, name, colour: newColour })
88
+ newName = ''
89
+ newColour = 'grey'
90
+ await refresh()
91
+ } catch (err) {
92
+ error = messageFor(err)
93
+ } finally {
94
+ busy = false
95
+ }
96
+ }
97
+
98
+ async function save() {
99
+ const name = draftName.trim()
100
+ if (busy || !editingId || !name) return
101
+ if (clashes(name, editingId)) {
102
+ error = t('label_taken')
103
+ return
104
+ }
105
+ busy = true
106
+ error = null
107
+ try {
108
+ await api.labels.update({ workspaceId, labelId: editingId, name, colour: draftColour })
109
+ editingId = null
110
+ await refresh()
111
+ } catch (err) {
112
+ error = messageFor(err)
113
+ } finally {
114
+ busy = false
115
+ }
116
+ }
117
+
118
+ async function remove(label: Label) {
119
+ await api.labels.remove({ workspaceId, labelId: label.id })
120
+ confirming = null
121
+ await refresh()
122
+ /*
123
+ * A label coming off pages is a change to those pages, and the chips drawing it are keyed per
124
+ * page. Nothing else would clear them: the server announces the label's own deletion, which
125
+ * refreshes this list and not the page that was wearing it.
126
+ */
127
+ await client.invalidateQueries({ queryKey: ['quire', 'label', workspaceId] })
128
+ }
129
+
130
+ /** A conflict from the server is the same sentence the field already knows how to say. */
131
+ function messageFor(err: unknown): string {
132
+ const code = (err as { code?: string } | null)?.code
133
+ if (code === 'CONFLICT') return t('label_taken')
134
+ return err instanceof Error ? err.message : t('error')
135
+ }
136
+ </script>
137
+
138
+ <Dialog bind:open title={t('labels_manage')} size="md">
139
+ {#if query.isLoading}
140
+ <div class="rows">
141
+ {#each [1, 2, 3] as n (n)}<Skeleton height="38px" />{/each}
142
+ </div>
143
+ {:else if query.isError}
144
+ <EmptyState icon="triangle-alert" title={t('labels_error')} description={t('retry')}>
145
+ {#snippet actions()}
146
+ <Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
147
+ {/snippet}
148
+ </EmptyState>
149
+ {:else}
150
+ {#if labels.length === 0}
151
+ <EmptyState
152
+ icon="tag"
153
+ compact
154
+ title={t('labels_empty')}
155
+ description={t('labels_empty_desc')}
156
+ />
157
+ {:else}
158
+ <ul class="rows">
159
+ {#each labels as label (label.id)}
160
+ <li class="row">
161
+ {#if editingId === label.id}
162
+ <div class="edit">
163
+ <Input
164
+ bind:value={draftName}
165
+ size="sm"
166
+ aria-label={t('label_name')}
167
+ maxlength={60}
168
+ onkeydown={(e: KeyboardEvent) => {
169
+ if (e.key === 'Enter') {
170
+ e.preventDefault()
171
+ void save()
172
+ }
173
+ if (e.key === 'Escape') stopEditing()
174
+ }}
175
+ />
176
+ <div class="swatches" role="radiogroup" aria-label={t('label_colour')}>
177
+ {#each OPTION_COLOURS as colour (colour)}
178
+ <button
179
+ type="button"
180
+ class="swatch"
181
+ role="radio"
182
+ aria-checked={draftColour === colour}
183
+ aria-label={t(`db_colour_${colour}`)}
184
+ style:background={toneFor(colour).bg}
185
+ style:color={toneFor(colour).fg}
186
+ onclick={() => (draftColour = colour as LabelColour)}
187
+ >
188
+ {#if draftColour === colour}<Icon name="check" size={13} strokeWidth={2.2} />{/if}
189
+ </button>
190
+ {/each}
191
+ </div>
192
+ <Button size="sm" aria-busy={busy} onclick={() => void save()}>{t('save')}</Button>
193
+ <Button size="sm" variant="ghost" onclick={stopEditing}>{t('cancel')}</Button>
194
+ </div>
195
+ {:else}
196
+ <LabelChip {label} />
197
+ <span class="spacer"></span>
198
+ <IconButton
199
+ icon="pencil"
200
+ size={26}
201
+ variant="ghost"
202
+ label={t('label_rename', { name: label.name })}
203
+ onclick={() => startEditing(label)}
204
+ />
205
+ <IconButton
206
+ icon="trash-2"
207
+ size={26}
208
+ variant="ghost"
209
+ label={t('label_delete_title', { name: label.name })}
210
+ onclick={() => (confirming = label)}
211
+ />
212
+ {/if}
213
+ </li>
214
+ {/each}
215
+ </ul>
216
+ {/if}
217
+
218
+ <form
219
+ class="new"
220
+ onsubmit={(e) => {
221
+ e.preventDefault()
222
+ void create()
223
+ }}
224
+ >
225
+ <Input
226
+ bind:value={newName}
227
+ size="sm"
228
+ placeholder={t('label_new')}
229
+ aria-label={t('label_name')}
230
+ maxlength={60}
231
+ />
232
+ <div class="swatches" role="radiogroup" aria-label={t('label_colour')}>
233
+ {#each OPTION_COLOURS as colour (colour)}
234
+ <button
235
+ type="button"
236
+ class="swatch"
237
+ role="radio"
238
+ aria-checked={newColour === colour}
239
+ aria-label={t(`db_colour_${colour}`)}
240
+ style:background={toneFor(colour).bg}
241
+ style:color={toneFor(colour).fg}
242
+ onclick={() => (newColour = colour as LabelColour)}
243
+ >
244
+ {#if newColour === colour}<Icon name="check" size={13} strokeWidth={2.2} />{/if}
245
+ </button>
246
+ {/each}
247
+ </div>
248
+ <Button size="sm" type="submit" icon="plus" aria-busy={busy} disabled={newName.trim() === ''}>
249
+ {t('add')}
250
+ </Button>
251
+ </form>
252
+
253
+ {#if error}<p class="err" role="alert">{error}</p>{/if}
254
+ {/if}
255
+ </Dialog>
256
+
257
+ <ConfirmDialog
258
+ open={confirmOpen}
259
+ title={t('label_delete_title', { name: confirming?.name ?? '' })}
260
+ body={t('label_delete_body')}
261
+ confirmLabel={t('delete')}
262
+ danger
263
+ onCancel={() => (confirming = null)}
264
+ onConfirm={async () => {
265
+ if (confirming) await remove(confirming)
266
+ }}
267
+ />
268
+
269
+ <style>
270
+ .rows {
271
+ display: flex;
272
+ flex-direction: column;
273
+ gap: 4px;
274
+ margin: 0;
275
+ padding: 0;
276
+ list-style: none;
277
+ }
278
+ .row {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 8px;
282
+ min-height: 38px;
283
+ padding-inline: 2px;
284
+ border-radius: var(--kern-r-md);
285
+ }
286
+ .row:hover {
287
+ background: var(--kern-surface-hover);
288
+ }
289
+ .spacer {
290
+ flex: 1;
291
+ }
292
+ .edit {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 8px;
296
+ flex-wrap: wrap;
297
+ width: 100%;
298
+ padding-block: 4px;
299
+ }
300
+ .new {
301
+ display: flex;
302
+ align-items: center;
303
+ gap: 8px;
304
+ flex-wrap: wrap;
305
+ margin-block-start: 14px;
306
+ padding-block-start: 14px;
307
+ border-block-start: 1px solid var(--kern-border);
308
+ }
309
+ .swatches {
310
+ display: flex;
311
+ gap: 4px;
312
+ flex-wrap: wrap;
313
+ }
314
+ /*
315
+ * 26px, because a control under 24px with another beside it is a target the audit fails — and a
316
+ * row of eight colours is the most crowded thing in this dialog.
317
+ */
318
+ .swatch {
319
+ width: 26px;
320
+ height: 26px;
321
+ display: inline-grid;
322
+ place-items: center;
323
+ border: 1px solid var(--kern-border);
324
+ border-radius: var(--kern-r-md);
325
+ cursor: pointer;
326
+ padding: 0;
327
+ }
328
+ .swatch[aria-checked='true'] {
329
+ border-color: var(--kern-ink-900);
330
+ }
331
+ .err {
332
+ margin: 10px 0 0;
333
+ font-size: 12.5px;
334
+ color: var(--kern-danger);
335
+ }
336
+ </style>