@kernhq/module-quire 0.14.0 → 0.16.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.
- package/dist/contract/models.d.ts +209 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +151 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +3 -3
- package/dist/contract/router.d.ts +509 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +211 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +573 -1269
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +180 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/markdown.d.ts.map +1 -1
- package/dist/server/export/markdown.js +84 -1
- package/dist/server/export/markdown.js.map +1 -1
- package/dist/server/render.d.ts +190 -1
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +413 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +260 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +108 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/index.d.ts +6 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +27 -3
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/objects.d.ts +61 -0
- package/dist/server/services/objects.d.ts.map +1 -0
- package/dist/server/services/objects.js +110 -0
- package/dist/server/services/objects.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/unfurl.d.ts +154 -0
- package/dist/server/services/unfurl.d.ts.map +1 -0
- package/dist/server/services/unfurl.js +593 -0
- package/dist/server/services/unfurl.js.map +1 -0
- package/dist/server/services/versions.d.ts +12 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/client/components/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +33 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +327 -0
- package/src/client/index.ts +19 -0
- package/src/client/mock.ts +274 -0
- package/src/client/pages/PageView.svelte +50 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/query.ts +17 -0
- package/src/contract/models.ts +191 -0
- package/src/contract/permissions.ts +33 -0
- package/src/contract/router.ts +228 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* What comes up when somebody presses "New page".
|
|
4
|
+
*
|
|
5
|
+
* **A blank page has to stay the fastest thing to make**, and that is the constraint the whole
|
|
6
|
+
* component is arranged around. Putting a picker in front of "New page" is exactly the kind of
|
|
7
|
+
* change that makes a product feel slower — every page anybody ever writes now costs a dialog — so
|
|
8
|
+
* blank is the *first* row, it holds focus the moment the dialog opens, and Enter makes it. One
|
|
9
|
+
* keystroke, and Escape is the other one that also costs nothing.
|
|
10
|
+
*
|
|
11
|
+
* Two steps, and the second only appears when it has something to ask. A template with no variables
|
|
12
|
+
* makes its page on the first press; a template with variables asks for them, because a form that
|
|
13
|
+
* appears for every template would be the same tax the blank row exists to avoid.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
Button,
|
|
17
|
+
Dialog,
|
|
18
|
+
EmptyState,
|
|
19
|
+
Field,
|
|
20
|
+
formatDate,
|
|
21
|
+
Icon,
|
|
22
|
+
IconButton,
|
|
23
|
+
Input,
|
|
24
|
+
Select,
|
|
25
|
+
Skeleton,
|
|
26
|
+
} from '@kernhq/ui'
|
|
27
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
28
|
+
import { getQuireApi } from '../api-instance.js'
|
|
29
|
+
import { t } from '../i18n.js'
|
|
30
|
+
import type { TemplateChoice, TemplateResult, TemplateStarterKey } from '../index.js'
|
|
31
|
+
import { canQuire } from '../permissions.js'
|
|
32
|
+
import { quireKeys } from '../query.js'
|
|
33
|
+
import ConfirmDialog from './ConfirmDialog.svelte'
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
open: boolean
|
|
37
|
+
workspaceId: string
|
|
38
|
+
spaceId: string
|
|
39
|
+
/** where the new page hangs — null makes it a top-level page of the space */
|
|
40
|
+
parentId?: string | null
|
|
41
|
+
/** the sibling it lands behind; null puts it first */
|
|
42
|
+
afterId?: string | null
|
|
43
|
+
/** a page was made from a template */
|
|
44
|
+
onMade?: (result: TemplateResult) => void
|
|
45
|
+
/** the blank row, which does not go through `instantiate` at all */
|
|
46
|
+
onBlank?: () => void
|
|
47
|
+
}
|
|
48
|
+
let {
|
|
49
|
+
open = $bindable(false),
|
|
50
|
+
workspaceId,
|
|
51
|
+
spaceId,
|
|
52
|
+
parentId = null,
|
|
53
|
+
afterId = null,
|
|
54
|
+
onMade,
|
|
55
|
+
onBlank,
|
|
56
|
+
}: Props = $props()
|
|
57
|
+
|
|
58
|
+
const api = getQuireApi()
|
|
59
|
+
const client = useQueryClient()
|
|
60
|
+
|
|
61
|
+
const query = createQuery(() => ({
|
|
62
|
+
queryKey: quireKeys.templates(workspaceId, 'page', spaceId),
|
|
63
|
+
enabled: open && Boolean(workspaceId && spaceId),
|
|
64
|
+
queryFn: () => api.templates.list({ workspaceId, kind: 'page', spaceId }),
|
|
65
|
+
}))
|
|
66
|
+
const choices = $derived(query.data ?? [])
|
|
67
|
+
|
|
68
|
+
/** The template being filled in, or null while the list is showing. */
|
|
69
|
+
let asking = $state<TemplateChoice | null>(null)
|
|
70
|
+
let answers = $state<Record<string, string>>({})
|
|
71
|
+
let error = $state<string | null>(null)
|
|
72
|
+
/**
|
|
73
|
+
* A plain flag rather than `mutation.isPending`.
|
|
74
|
+
*
|
|
75
|
+
* `disabled={pending}` reaches the button on the next render, and two quick clicks are one render
|
|
76
|
+
* apart — so a double-click on a template would make two pages, both of which somebody then has to
|
|
77
|
+
* find and delete.
|
|
78
|
+
*/
|
|
79
|
+
let busy = $state(false)
|
|
80
|
+
/**
|
|
81
|
+
* Which row is being made, so `aria-busy` lands on that one rather than on all of them.
|
|
82
|
+
*
|
|
83
|
+
* `aria-busy` on every row would tell a screen-reader user that the whole list is working, which is
|
|
84
|
+
* both untrue and unhelpful — the one thing worth saying is that the row they just pressed is.
|
|
85
|
+
*/
|
|
86
|
+
let pending = $state<string | null>(null)
|
|
87
|
+
|
|
88
|
+
/** The blank row, so the dialog can hand it focus rather than the browser guessing. */
|
|
89
|
+
let blankRow = $state<HTMLButtonElement | null>(null)
|
|
90
|
+
|
|
91
|
+
/** A starter is addressed by key and a saved template by id, so a row's identity is one or other. */
|
|
92
|
+
const idOf = (choice: TemplateChoice) => choice.id ?? (choice.key as string)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The row a confirmation is open about.
|
|
96
|
+
*
|
|
97
|
+
* Deleting a template and resetting an overridden starter are **the same act** — the row goes — so
|
|
98
|
+
* they are one code path wearing whichever word is true. Confirmed rather than done on the press,
|
|
99
|
+
* because it is not undoable and because the thing people most need told is what it does *not* do:
|
|
100
|
+
* the pages already made from it stay exactly where they are.
|
|
101
|
+
*/
|
|
102
|
+
let removing = $state<TemplateChoice | null>(null)
|
|
103
|
+
|
|
104
|
+
async function removeTemplate(choice: TemplateChoice) {
|
|
105
|
+
if (!choice.id) return
|
|
106
|
+
try {
|
|
107
|
+
await api.templates.remove({ workspaceId, templateId: choice.id })
|
|
108
|
+
await client.invalidateQueries({ queryKey: ['quire', 'template', workspaceId] })
|
|
109
|
+
} catch (err) {
|
|
110
|
+
error = err instanceof Error ? err.message : String(err)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function reset() {
|
|
115
|
+
asking = null
|
|
116
|
+
answers = {}
|
|
117
|
+
error = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function choose(choice: TemplateChoice) {
|
|
121
|
+
error = null
|
|
122
|
+
if (choice.variables.length === 0) {
|
|
123
|
+
void make(choice)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
answers = Object.fromEntries(choice.variables.map((v) => [v.name, v.default ?? '']))
|
|
127
|
+
asking = choice
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const missing = $derived(
|
|
131
|
+
(asking?.variables ?? []).filter((v) => v.required && !(answers[v.name] ?? '').trim()),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async function make(choice: TemplateChoice) {
|
|
135
|
+
if (busy) return
|
|
136
|
+
busy = true
|
|
137
|
+
pending = idOf(choice)
|
|
138
|
+
error = null
|
|
139
|
+
try {
|
|
140
|
+
const result = await api.templates.instantiate({
|
|
141
|
+
workspaceId,
|
|
142
|
+
templateId: choice.id,
|
|
143
|
+
// A shipped starter has no row, so its key is what addresses it. Exactly one of the two.
|
|
144
|
+
starterKey: choice.id === null ? (choice.key as TemplateStarterKey) : null,
|
|
145
|
+
spaceId,
|
|
146
|
+
parentId,
|
|
147
|
+
afterId,
|
|
148
|
+
title: '',
|
|
149
|
+
values: answers,
|
|
150
|
+
})
|
|
151
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
152
|
+
open = false
|
|
153
|
+
reset()
|
|
154
|
+
onMade?.(result)
|
|
155
|
+
} catch (err) {
|
|
156
|
+
error = err instanceof Error ? err.message : String(err)
|
|
157
|
+
} finally {
|
|
158
|
+
busy = false
|
|
159
|
+
pending = null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function blank() {
|
|
164
|
+
if (busy) return
|
|
165
|
+
open = false
|
|
166
|
+
reset()
|
|
167
|
+
onBlank?.()
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<Dialog
|
|
172
|
+
bind:open
|
|
173
|
+
size="lg"
|
|
174
|
+
title={asking ? t('template_fill_title') : t('template_pick_title')}
|
|
175
|
+
description={asking ? t('template_fill_desc') : t('template_pick_desc')}
|
|
176
|
+
initialFocus={() => blankRow}
|
|
177
|
+
onOpenChange={(o: boolean) => {
|
|
178
|
+
if (!o) reset()
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{#if asking}
|
|
182
|
+
<div class="form">
|
|
183
|
+
{#each asking.variables as variable (variable.name)}
|
|
184
|
+
<Field
|
|
185
|
+
label={variable.label}
|
|
186
|
+
required={variable.required}
|
|
187
|
+
error={variable.required && !(answers[variable.name] ?? '').trim()
|
|
188
|
+
? t('template_required_missing')
|
|
189
|
+
: null}
|
|
190
|
+
>
|
|
191
|
+
{#snippet children(id: string)}
|
|
192
|
+
{#if variable.type === 'select'}
|
|
193
|
+
<Select
|
|
194
|
+
{id}
|
|
195
|
+
ariaLabel={variable.label}
|
|
196
|
+
placeholder={t('template_choose_option')}
|
|
197
|
+
value={answers[variable.name] ?? ''}
|
|
198
|
+
options={variable.options.map((option) => ({ value: option, label: option }))}
|
|
199
|
+
onValueChange={(v: string) => (answers = { ...answers, [variable.name]: v })}
|
|
200
|
+
/>
|
|
201
|
+
{:else}
|
|
202
|
+
<!--
|
|
203
|
+
The type picks the control and nothing else: every answer is substituted as text, so
|
|
204
|
+
a date field is a date input whose value is a string like any other.
|
|
205
|
+
-->
|
|
206
|
+
<Input
|
|
207
|
+
{id}
|
|
208
|
+
type={variable.type === 'number' ? 'number' : variable.type === 'date' ? 'date' : 'text'}
|
|
209
|
+
value={answers[variable.name] ?? ''}
|
|
210
|
+
oninput={(e: Event) =>
|
|
211
|
+
(answers = {
|
|
212
|
+
...answers,
|
|
213
|
+
[variable.name]: (e.currentTarget as HTMLInputElement).value,
|
|
214
|
+
})}
|
|
215
|
+
/>
|
|
216
|
+
{/if}
|
|
217
|
+
{/snippet}
|
|
218
|
+
</Field>
|
|
219
|
+
{/each}
|
|
220
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
221
|
+
</div>
|
|
222
|
+
{:else if query.isLoading}
|
|
223
|
+
<div class="list">
|
|
224
|
+
{#each [1, 2, 3, 4] as n (n)}
|
|
225
|
+
<Skeleton height="56px" />
|
|
226
|
+
{/each}
|
|
227
|
+
</div>
|
|
228
|
+
{:else if query.isError}
|
|
229
|
+
<EmptyState icon="triangle-alert" title={t('template_error')} description={t('template_error_desc')} />
|
|
230
|
+
{:else}
|
|
231
|
+
<div class="list">
|
|
232
|
+
<!--
|
|
233
|
+
First, focused, and visually the same weight as the rest: the point is that pressing Enter
|
|
234
|
+
the instant the dialog appears is still the fastest way to a page.
|
|
235
|
+
-->
|
|
236
|
+
<button class="row blank" type="button" bind:this={blankRow} onclick={blank}>
|
|
237
|
+
<span class="mark"><Icon name="file-text" size={18} /></span>
|
|
238
|
+
<span class="what">
|
|
239
|
+
<span class="name">{t('template_blank')}</span>
|
|
240
|
+
<span class="desc">{t('template_blank_desc')}</span>
|
|
241
|
+
</span>
|
|
242
|
+
</button>
|
|
243
|
+
|
|
244
|
+
<!--
|
|
245
|
+
A row is a `<div>` wrapping two controls rather than one big `<button>`, because a button
|
|
246
|
+
inside a button is not a thing a browser will render — the pick and the delete are siblings.
|
|
247
|
+
-->
|
|
248
|
+
{#each choices as choice (idOf(choice))}
|
|
249
|
+
<div class="row">
|
|
250
|
+
<button
|
|
251
|
+
class="pick"
|
|
252
|
+
type="button"
|
|
253
|
+
aria-busy={pending === idOf(choice)}
|
|
254
|
+
onclick={() => choose(choice)}
|
|
255
|
+
>
|
|
256
|
+
<span class="mark"><Icon name={choice.icon ?? 'file-text'} size={18} /></span>
|
|
257
|
+
<span class="what">
|
|
258
|
+
<span class="name">
|
|
259
|
+
{choice.name}
|
|
260
|
+
<!--
|
|
261
|
+
A starter this workspace has edited is a different thing from the shipped one, and
|
|
262
|
+
the only way to tell from the outside is that it has a row. Saying so is what makes
|
|
263
|
+
"delete it and the shipped one comes back" make sense.
|
|
264
|
+
-->
|
|
265
|
+
{#if choice.builtIn && choice.id}<span class="tag">{t('template_customised')}</span>{/if}
|
|
266
|
+
</span>
|
|
267
|
+
<span class="desc">{choice.description}</span>
|
|
268
|
+
</span>
|
|
269
|
+
{#if choice.updatedAt}
|
|
270
|
+
<span class="when">{formatDate(choice.updatedAt)}</span>
|
|
271
|
+
{/if}
|
|
272
|
+
</button>
|
|
273
|
+
|
|
274
|
+
<!--
|
|
275
|
+
The only way out of a template somebody no longer wants, and the only way back to a
|
|
276
|
+
shipped one that was replaced — the two are the same act on the row, so they are one
|
|
277
|
+
control wearing the right word. A shipped starter has no row and therefore no control.
|
|
278
|
+
|
|
279
|
+
`space.manage`, matching `templates.remove`: a template is the space's furniture.
|
|
280
|
+
-->
|
|
281
|
+
{#if choice.id && canQuire('spaceManage')}
|
|
282
|
+
<IconButton
|
|
283
|
+
icon={choice.builtIn ? 'rotate-ccw' : 'trash-2'}
|
|
284
|
+
variant="ghost"
|
|
285
|
+
size={30}
|
|
286
|
+
label={choice.builtIn
|
|
287
|
+
? t('template_reset', { name: choice.name })
|
|
288
|
+
: t('template_delete', { name: choice.name })}
|
|
289
|
+
onclick={() => (removing = choice)}
|
|
290
|
+
/>
|
|
291
|
+
{/if}
|
|
292
|
+
</div>
|
|
293
|
+
{/each}
|
|
294
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
295
|
+
</div>
|
|
296
|
+
{/if}
|
|
297
|
+
|
|
298
|
+
{#snippet footer()}
|
|
299
|
+
{#if asking}
|
|
300
|
+
<Button variant="secondary" onclick={reset}>{t('template_back')}</Button>
|
|
301
|
+
<Button
|
|
302
|
+
aria-busy={busy}
|
|
303
|
+
disabled={missing.length > 0}
|
|
304
|
+
onclick={() => asking && make(asking)}
|
|
305
|
+
>
|
|
306
|
+
{t('template_create')}
|
|
307
|
+
</Button>
|
|
308
|
+
{:else}
|
|
309
|
+
<Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
|
|
310
|
+
{/if}
|
|
311
|
+
{/snippet}
|
|
312
|
+
</Dialog>
|
|
313
|
+
|
|
314
|
+
{#if removing}
|
|
315
|
+
<ConfirmDialog
|
|
316
|
+
open={true}
|
|
317
|
+
danger={!removing.builtIn}
|
|
318
|
+
title={removing.builtIn ? t('template_reset_title') : t('template_delete_title')}
|
|
319
|
+
body={t('template_delete_body', { name: removing.name })}
|
|
320
|
+
confirmLabel={removing.builtIn ? t('template_reset_confirm') : t('template_delete_confirm')}
|
|
321
|
+
onConfirm={async () => {
|
|
322
|
+
if (removing) await removeTemplate(removing)
|
|
323
|
+
removing = null
|
|
324
|
+
}}
|
|
325
|
+
onCancel={() => (removing = null)}
|
|
326
|
+
/>
|
|
327
|
+
{/if}
|
|
328
|
+
|
|
329
|
+
<style>
|
|
330
|
+
.list {
|
|
331
|
+
display: flex;
|
|
332
|
+
flex-direction: column;
|
|
333
|
+
gap: 6px;
|
|
334
|
+
}
|
|
335
|
+
.form {
|
|
336
|
+
display: flex;
|
|
337
|
+
flex-direction: column;
|
|
338
|
+
gap: 14px;
|
|
339
|
+
}
|
|
340
|
+
/*
|
|
341
|
+
* `.row` is the frame — a `<div>` around a saved template, and the `<button>` itself for the blank
|
|
342
|
+
* one, which has nothing beside it. `.pick` is the part somebody presses in both cases.
|
|
343
|
+
*/
|
|
344
|
+
.row {
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: 4px;
|
|
348
|
+
width: 100%;
|
|
349
|
+
/* A row people press: 56px tall, so it is a comfortable target on a phone as well as a pointer. */
|
|
350
|
+
min-height: 56px;
|
|
351
|
+
padding: 6px 8px;
|
|
352
|
+
border: 1px solid var(--kern-border);
|
|
353
|
+
border-radius: var(--kern-r-md);
|
|
354
|
+
background: var(--kern-surface);
|
|
355
|
+
color: var(--kern-ink-800);
|
|
356
|
+
/* Logical, so the icon leads the row in Persian and Arabic rather than trailing it. */
|
|
357
|
+
text-align: start;
|
|
358
|
+
}
|
|
359
|
+
.row:hover {
|
|
360
|
+
background: var(--kern-surface-hover);
|
|
361
|
+
border-color: var(--kern-border-hover);
|
|
362
|
+
}
|
|
363
|
+
.pick {
|
|
364
|
+
display: flex;
|
|
365
|
+
align-items: center;
|
|
366
|
+
gap: 12px;
|
|
367
|
+
flex: 1;
|
|
368
|
+
min-width: 0;
|
|
369
|
+
min-height: 44px;
|
|
370
|
+
padding: 4px;
|
|
371
|
+
border: 0;
|
|
372
|
+
border-radius: var(--kern-r-sm);
|
|
373
|
+
background: none;
|
|
374
|
+
color: inherit;
|
|
375
|
+
text-align: start;
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
}
|
|
378
|
+
.row:focus-visible,
|
|
379
|
+
.pick:focus-visible {
|
|
380
|
+
outline: 2px solid var(--kern-ring);
|
|
381
|
+
outline-offset: 2px;
|
|
382
|
+
}
|
|
383
|
+
/* The blank row is the frame and the control at once, so it carries both sets of rules. */
|
|
384
|
+
.blank {
|
|
385
|
+
gap: 12px;
|
|
386
|
+
padding: 10px 12px;
|
|
387
|
+
border-color: var(--kern-border-strong);
|
|
388
|
+
cursor: pointer;
|
|
389
|
+
}
|
|
390
|
+
.mark {
|
|
391
|
+
display: grid;
|
|
392
|
+
place-items: center;
|
|
393
|
+
flex: none;
|
|
394
|
+
width: 34px;
|
|
395
|
+
height: 34px;
|
|
396
|
+
border-radius: var(--kern-r-sm);
|
|
397
|
+
background: var(--kern-surface-chip);
|
|
398
|
+
color: var(--kern-ink-600);
|
|
399
|
+
}
|
|
400
|
+
.what {
|
|
401
|
+
display: flex;
|
|
402
|
+
flex-direction: column;
|
|
403
|
+
gap: 2px;
|
|
404
|
+
min-width: 0;
|
|
405
|
+
}
|
|
406
|
+
.name {
|
|
407
|
+
display: flex;
|
|
408
|
+
align-items: center;
|
|
409
|
+
gap: 8px;
|
|
410
|
+
font-size: 14px;
|
|
411
|
+
font-weight: 500;
|
|
412
|
+
}
|
|
413
|
+
.desc {
|
|
414
|
+
/* Muted with a colour, never with opacity — opacity fades the text against the page. */
|
|
415
|
+
font-size: 12px;
|
|
416
|
+
color: var(--kern-ink-450);
|
|
417
|
+
}
|
|
418
|
+
.tag {
|
|
419
|
+
padding: 1px 6px;
|
|
420
|
+
border-radius: var(--kern-r-full);
|
|
421
|
+
background: var(--kern-accent-tint);
|
|
422
|
+
color: var(--kern-accent-text);
|
|
423
|
+
font-size: 11px;
|
|
424
|
+
font-weight: 500;
|
|
425
|
+
}
|
|
426
|
+
.when {
|
|
427
|
+
margin-inline-start: auto;
|
|
428
|
+
flex: none;
|
|
429
|
+
font-size: 11px;
|
|
430
|
+
color: var(--kern-ink-450);
|
|
431
|
+
}
|
|
432
|
+
.error {
|
|
433
|
+
margin: 0;
|
|
434
|
+
font-size: 13px;
|
|
435
|
+
color: var(--kern-danger);
|
|
436
|
+
}
|
|
437
|
+
</style>
|