@kernhq/module-hr 0.10.5 → 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.
- package/package.json +3 -3
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1417 -89
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Field,
|
|
8
|
+
formatCount,
|
|
9
|
+
formatDate,
|
|
10
|
+
Icon,
|
|
11
|
+
IconButton,
|
|
12
|
+
Input,
|
|
13
|
+
ProgressBar,
|
|
14
|
+
SectionLabel,
|
|
15
|
+
Select,
|
|
16
|
+
Skeleton,
|
|
17
|
+
session,
|
|
18
|
+
toast,
|
|
19
|
+
uploadFile,
|
|
20
|
+
} from '@kernhq/ui'
|
|
21
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
22
|
+
import { getHrApi } from '../api-instance.js'
|
|
23
|
+
import { HR_CAPABILITIES } from '../capabilities.js'
|
|
24
|
+
import { t } from '../i18n.js'
|
|
25
|
+
import type { PersonDocument } from '../index.js'
|
|
26
|
+
import { canHr } from '../permissions.js'
|
|
27
|
+
import { isoDate } from '../query.js'
|
|
28
|
+
import { explainRefusal } from './refusal.js'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The contracts, identity documents and certificates kept against one person.
|
|
32
|
+
*
|
|
33
|
+
* `documents` is a capability and `hr.document.view` / `hr.document.manage` are two permissions
|
|
34
|
+
* nobody holds by default, and all three led nowhere until this section existed —
|
|
35
|
+
* `src/contract/capabilities.ts` says out loud that a switch which changes nothing is worse than a
|
|
36
|
+
* missing switch, and this is the switch.
|
|
37
|
+
*
|
|
38
|
+
* Off means **absent**, not refused: the API answers 404 for a workspace that never enabled
|
|
39
|
+
* documents, so a section saying "you may not see this" would contradict both the server and a
|
|
40
|
+
* shell that already hid the feature.
|
|
41
|
+
*
|
|
42
|
+
* The bytes never pass through HR. `uploadFile` presigns against core, PUTs to object storage and
|
|
43
|
+
* marks the file ready; `documents.attach` then records the file id against the person. A file
|
|
44
|
+
* uploaded but never attached is a stray file, so the attach follows the upload in one action.
|
|
45
|
+
*/
|
|
46
|
+
interface Props {
|
|
47
|
+
personId: string
|
|
48
|
+
workspaceId: string
|
|
49
|
+
personName: string
|
|
50
|
+
}
|
|
51
|
+
const { personId, workspaceId, personName }: Props = $props()
|
|
52
|
+
|
|
53
|
+
const api = getHrApi()
|
|
54
|
+
const queryClient = useQueryClient()
|
|
55
|
+
|
|
56
|
+
const enabled = $derived(session.hasCapability('hr', HR_CAPABILITIES.documents))
|
|
57
|
+
const mayView = $derived(canHr('documentView'))
|
|
58
|
+
const mayManage = $derived(canHr('documentManage'))
|
|
59
|
+
|
|
60
|
+
const documentsQuery = createQuery(() => ({
|
|
61
|
+
queryKey: ['hr', 'documents', workspaceId, personId] as const,
|
|
62
|
+
enabled: enabled && mayView && Boolean(workspaceId && personId),
|
|
63
|
+
queryFn: () => api.documents.list({ workspaceId, personId }),
|
|
64
|
+
}))
|
|
65
|
+
const documents = $derived(documentsQuery.data ?? [])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The kinds offered, and the words for them.
|
|
69
|
+
*
|
|
70
|
+
* The contract takes any string up to 48 characters, so a kind written by an import or by a later
|
|
71
|
+
* version of this screen falls through to itself rather than to nothing.
|
|
72
|
+
*/
|
|
73
|
+
const KIND_KEYS: Record<string, string> = {
|
|
74
|
+
contract: 'doc_kind_contract',
|
|
75
|
+
id: 'doc_kind_id',
|
|
76
|
+
certificate: 'doc_kind_certificate',
|
|
77
|
+
payslip: 'doc_kind_payslip',
|
|
78
|
+
other: 'doc_kind_other',
|
|
79
|
+
}
|
|
80
|
+
const kindLabel = (kind: string) => (KIND_KEYS[kind] ? t(KIND_KEYS[kind]) : kind)
|
|
81
|
+
const kindOptions = Object.keys(KIND_KEYS).map((kind) => ({ value: kind, label: kindLabel(kind) }))
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A calendar date, read in the reader's language.
|
|
85
|
+
*
|
|
86
|
+
* The `T00:00:00` is not decoration: `new Date('2026-03-01')` is parsed as *UTC* midnight, so west
|
|
87
|
+
* of Greenwich a document issued on the first of March would be shown as expiring in February.
|
|
88
|
+
*/
|
|
89
|
+
const dateLabel = (iso: string): string => formatDate(`${iso}T00:00:00`)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a document has run out, or is about to.
|
|
93
|
+
*
|
|
94
|
+
* The reason a work permit or a right-to-work check is worth keeping here at all is that somebody
|
|
95
|
+
* has to notice before it lapses, and a list of names and dates does not make anybody notice.
|
|
96
|
+
*/
|
|
97
|
+
const SOON_DAYS = 60
|
|
98
|
+
function expiry(row: PersonDocument): 'expired' | 'soon' | null {
|
|
99
|
+
if (!row.expiresOn) return null
|
|
100
|
+
const today = isoDate()
|
|
101
|
+
if (row.expiresOn < today) return 'expired'
|
|
102
|
+
const limit = new Date(`${today}T00:00:00`)
|
|
103
|
+
limit.setDate(limit.getDate() + SOON_DAYS)
|
|
104
|
+
return row.expiresOn <= isoDate(limit) ? 'soon' : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------- attaching
|
|
108
|
+
|
|
109
|
+
let picker = $state<HTMLInputElement | null>(null)
|
|
110
|
+
let chosen = $state<File | null>(null)
|
|
111
|
+
let docName = $state('')
|
|
112
|
+
let docKind = $state('other')
|
|
113
|
+
let issuedOn = $state('')
|
|
114
|
+
let expiresOn = $state('')
|
|
115
|
+
/** Null while the browser is not reporting progress, which is what an unknown-length body does. */
|
|
116
|
+
let progress = $state<number | null>(null)
|
|
117
|
+
let attachError = $state<string | null>(null)
|
|
118
|
+
|
|
119
|
+
function pick(files: FileList | null) {
|
|
120
|
+
const file = files?.[0]
|
|
121
|
+
if (!file) return
|
|
122
|
+
chosen = file
|
|
123
|
+
// The file's own name is what somebody would type, so it is the default rather than an empty box.
|
|
124
|
+
docName = file.name.replace(/\.[^.]+$/, '').slice(0, 200)
|
|
125
|
+
docKind = 'other'
|
|
126
|
+
issuedOn = ''
|
|
127
|
+
expiresOn = ''
|
|
128
|
+
progress = null
|
|
129
|
+
attachError = null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `attaching` rather than `attach.isPending`: the disabled attribute only reaches the button on the
|
|
134
|
+
* next render, so two quick clicks are one render apart — and here that is the same file uploaded
|
|
135
|
+
* twice and attached twice.
|
|
136
|
+
*/
|
|
137
|
+
let attaching = $state(false)
|
|
138
|
+
|
|
139
|
+
const attach = createMutation(() => ({
|
|
140
|
+
mutationFn: async () => {
|
|
141
|
+
const file = chosen
|
|
142
|
+
if (!file) throw new Error('no file')
|
|
143
|
+
progress = 0
|
|
144
|
+
const uploaded = await uploadFile({
|
|
145
|
+
workspaceId,
|
|
146
|
+
file,
|
|
147
|
+
name: file.name,
|
|
148
|
+
mimeType: file.type || undefined,
|
|
149
|
+
// What the file belongs to, recorded with the file itself: core can then answer "what is this
|
|
150
|
+
// file" without HR, which is what makes an orphan findable.
|
|
151
|
+
attachedTo: { module: 'hr', type: 'person', id: personId },
|
|
152
|
+
onProgress: ({ ratio }) => {
|
|
153
|
+
progress = ratio
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
return api.documents.attach({
|
|
157
|
+
workspaceId,
|
|
158
|
+
personId,
|
|
159
|
+
fileId: uploaded.id,
|
|
160
|
+
name: docName.trim(),
|
|
161
|
+
kind: docKind,
|
|
162
|
+
issuedOn: issuedOn || null,
|
|
163
|
+
expiresOn: expiresOn || null,
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
onSuccess: (row) => {
|
|
167
|
+
toast.success(t('doc_attached', { name: row.name }))
|
|
168
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
169
|
+
chosen = null
|
|
170
|
+
},
|
|
171
|
+
onError: (error) => {
|
|
172
|
+
// The dialog stays open with the file still chosen: the failure is almost always the network or
|
|
173
|
+
// a size limit, and making somebody find the file again to retry is a punishment for both.
|
|
174
|
+
attachError = explainRefusal(error, t('doc_attach_error'))
|
|
175
|
+
progress = null
|
|
176
|
+
},
|
|
177
|
+
onSettled: () => {
|
|
178
|
+
attaching = false
|
|
179
|
+
},
|
|
180
|
+
}))
|
|
181
|
+
|
|
182
|
+
const submitAttach = () => {
|
|
183
|
+
if (attaching) return
|
|
184
|
+
attaching = true
|
|
185
|
+
attachError = null
|
|
186
|
+
attach.mutate()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------- removing
|
|
190
|
+
|
|
191
|
+
let removing = $state<PersonDocument | null>(null)
|
|
192
|
+
let deleting = $state(false)
|
|
193
|
+
|
|
194
|
+
const remove = createMutation(() => ({
|
|
195
|
+
mutationFn: (row: PersonDocument) => api.documents.remove({ workspaceId, personId, documentId: row.id }),
|
|
196
|
+
onSuccess: (_ok, row: PersonDocument) => {
|
|
197
|
+
toast.success(t('doc_removed', { name: row.name }))
|
|
198
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
199
|
+
removing = null
|
|
200
|
+
},
|
|
201
|
+
onError: (error) => toast.error(explainRefusal(error, t('doc_remove_error'))),
|
|
202
|
+
onSettled: () => {
|
|
203
|
+
deleting = false
|
|
204
|
+
},
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
const submitRemove = () => {
|
|
208
|
+
if (deleting || !removing) return
|
|
209
|
+
deleting = true
|
|
210
|
+
remove.mutate(removing)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const canAttach = $derived(Boolean(chosen) && docName.trim().length > 0 && mayManage && !attaching)
|
|
214
|
+
</script>
|
|
215
|
+
|
|
216
|
+
<!-- A capability that is off is not a locked door: nothing is drawn, and the API answers 404. -->
|
|
217
|
+
{#if enabled && mayView}
|
|
218
|
+
<section class="sec">
|
|
219
|
+
<SectionLabel label={t('docs_title')} count={documents.length ? formatCount(documents.length, 999) : null}>
|
|
220
|
+
{#snippet trailing()}
|
|
221
|
+
<!-- Hidden rather than disabled: `hr.document.manage` is a permission somebody either has
|
|
222
|
+
or will never have here, and a dead button explains nothing. -->
|
|
223
|
+
{#if mayManage}
|
|
224
|
+
<input
|
|
225
|
+
bind:this={picker}
|
|
226
|
+
type="file"
|
|
227
|
+
hidden
|
|
228
|
+
onchange={(e) => {
|
|
229
|
+
pick(e.currentTarget.files)
|
|
230
|
+
e.currentTarget.value = ''
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
<Button size="sm" variant="secondary" icon="paperclip" onclick={() => picker?.click()}>
|
|
234
|
+
{t('doc_attach')}
|
|
235
|
+
</Button>
|
|
236
|
+
{/if}
|
|
237
|
+
{/snippet}
|
|
238
|
+
</SectionLabel>
|
|
239
|
+
|
|
240
|
+
<!--
|
|
241
|
+
Held data outranks the error: every write in this module drops the whole HR cache, so a failed
|
|
242
|
+
background refetch leaves the query in `error` with a good list still in hand — and an error
|
|
243
|
+
branch above this one would blank it.
|
|
244
|
+
-->
|
|
245
|
+
{#if documentsQuery.isLoading}
|
|
246
|
+
<div class="rows">
|
|
247
|
+
{#each [1, 2] as n (n)}<Skeleton height="44px" />{/each}
|
|
248
|
+
</div>
|
|
249
|
+
{:else if documents.length}
|
|
250
|
+
<ul class="docs">
|
|
251
|
+
{#each documents as row (row.id)}
|
|
252
|
+
{@const lapse = expiry(row)}
|
|
253
|
+
<li>
|
|
254
|
+
<span class="ic"><Icon name="file-text" size={14} strokeWidth={1.7} /></span>
|
|
255
|
+
<span class="name" title={row.name}>{row.name}</span>
|
|
256
|
+
<span class="chips">
|
|
257
|
+
<Badge tone="grey">{kindLabel(row.kind)}</Badge>
|
|
258
|
+
{#if lapse === 'expired'}
|
|
259
|
+
<Badge tone="danger">{t('doc_expired')}</Badge>
|
|
260
|
+
{:else if lapse === 'soon'}
|
|
261
|
+
<Badge tone="warning">{t('doc_expiring')}</Badge>
|
|
262
|
+
{/if}
|
|
263
|
+
</span>
|
|
264
|
+
<span class="dates">
|
|
265
|
+
{#if row.expiresOn}
|
|
266
|
+
{t('doc_expires_on', { date: dateLabel(row.expiresOn) })}
|
|
267
|
+
{:else if row.issuedOn}
|
|
268
|
+
{t('doc_issued_on', { date: dateLabel(row.issuedOn) })}
|
|
269
|
+
{:else}
|
|
270
|
+
{t('doc_added_on', { date: dateLabel(row.createdAt) })}
|
|
271
|
+
{/if}
|
|
272
|
+
</span>
|
|
273
|
+
{#if mayManage}
|
|
274
|
+
<IconButton
|
|
275
|
+
icon="trash-2"
|
|
276
|
+
size={26}
|
|
277
|
+
label={t('doc_remove_label', { name: row.name })}
|
|
278
|
+
onclick={() => (removing = row)}
|
|
279
|
+
/>
|
|
280
|
+
{/if}
|
|
281
|
+
</li>
|
|
282
|
+
{/each}
|
|
283
|
+
</ul>
|
|
284
|
+
{:else if documentsQuery.isError}
|
|
285
|
+
<EmptyState compact icon="triangle-alert" title={t('docs_error')}>
|
|
286
|
+
{#snippet actions()}
|
|
287
|
+
<Button size="sm" variant="secondary" onclick={() => void documentsQuery.refetch()}>
|
|
288
|
+
{t('retry')}
|
|
289
|
+
</Button>
|
|
290
|
+
{/snippet}
|
|
291
|
+
</EmptyState>
|
|
292
|
+
{:else}
|
|
293
|
+
<EmptyState compact icon="file-text" title={t('docs_none')} description={t('docs_none_desc')}>
|
|
294
|
+
{#snippet actions()}
|
|
295
|
+
{#if mayManage}
|
|
296
|
+
<Button size="sm" icon="paperclip" onclick={() => picker?.click()}>{t('doc_attach')}</Button>
|
|
297
|
+
{/if}
|
|
298
|
+
{/snippet}
|
|
299
|
+
</EmptyState>
|
|
300
|
+
{/if}
|
|
301
|
+
</section>
|
|
302
|
+
|
|
303
|
+
<Dialog
|
|
304
|
+
open={chosen !== null}
|
|
305
|
+
size="sm"
|
|
306
|
+
title={t('doc_attach_title', { name: personName })}
|
|
307
|
+
description={t('doc_attach_body')}
|
|
308
|
+
onOpenChange={(open) => {
|
|
309
|
+
if (!open && !attaching) chosen = null
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
<div class="form">
|
|
313
|
+
{#if chosen}
|
|
314
|
+
<p class="file">
|
|
315
|
+
<Icon name="paperclip" size={13} strokeWidth={1.8} />
|
|
316
|
+
<span class="name" title={chosen.name}>{chosen.name}</span>
|
|
317
|
+
</p>
|
|
318
|
+
{/if}
|
|
319
|
+
|
|
320
|
+
<Field label={t('doc_name')} id="hr-doc-name" required>
|
|
321
|
+
{#snippet children(id)}
|
|
322
|
+
<Input {id} bind:value={docName} maxlength={200} />
|
|
323
|
+
{/snippet}
|
|
324
|
+
</Field>
|
|
325
|
+
|
|
326
|
+
<Field label={t('doc_kind')} id="hr-doc-kind">
|
|
327
|
+
{#snippet children(id)}
|
|
328
|
+
<Select
|
|
329
|
+
{id}
|
|
330
|
+
value={docKind}
|
|
331
|
+
onValueChange={(v) => (docKind = v)}
|
|
332
|
+
options={kindOptions}
|
|
333
|
+
ariaLabel={t('doc_kind')}
|
|
334
|
+
/>
|
|
335
|
+
{/snippet}
|
|
336
|
+
</Field>
|
|
337
|
+
|
|
338
|
+
<div class="pair">
|
|
339
|
+
<Field label={t('doc_issued')} hint={t('common.optional')} id="hr-doc-issued">
|
|
340
|
+
{#snippet children(id)}
|
|
341
|
+
<Input {id} type="date" value={issuedOn} oninput={(e) => (issuedOn = e.currentTarget.value)} />
|
|
342
|
+
{/snippet}
|
|
343
|
+
</Field>
|
|
344
|
+
<Field label={t('doc_expires')} hint={t('doc_expires_hint')} id="hr-doc-expires">
|
|
345
|
+
{#snippet children(id)}
|
|
346
|
+
<Input {id} type="date" value={expiresOn} oninput={(e) => (expiresOn = e.currentTarget.value)} />
|
|
347
|
+
{/snippet}
|
|
348
|
+
</Field>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{#if attaching}
|
|
352
|
+
<div class="progress">
|
|
353
|
+
<ProgressBar
|
|
354
|
+
value={progress === null ? 0 : progress * 100}
|
|
355
|
+
label={t('doc_uploading', { name: chosen?.name ?? '' })}
|
|
356
|
+
/>
|
|
357
|
+
<span class="muted">{t('doc_uploading', { name: chosen?.name ?? '' })}</span>
|
|
358
|
+
</div>
|
|
359
|
+
{/if}
|
|
360
|
+
{#if attachError}
|
|
361
|
+
<p class="err" role="alert">{attachError}</p>
|
|
362
|
+
{/if}
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
{#snippet footer()}
|
|
366
|
+
<Button variant="secondary" onclick={() => (chosen = null)} disabled={attaching}>
|
|
367
|
+
{t('common.cancel')}
|
|
368
|
+
</Button>
|
|
369
|
+
<Button loading={attach.isPending} disabled={!canAttach} onclick={submitAttach}>
|
|
370
|
+
{t('doc_attach')}
|
|
371
|
+
</Button>
|
|
372
|
+
{/snippet}
|
|
373
|
+
</Dialog>
|
|
374
|
+
|
|
375
|
+
<Dialog
|
|
376
|
+
open={removing !== null}
|
|
377
|
+
size="sm"
|
|
378
|
+
title={removing ? t('doc_remove_title', { name: removing.name }) : ''}
|
|
379
|
+
description={t('doc_remove_body', { person: personName })}
|
|
380
|
+
onOpenChange={(open) => {
|
|
381
|
+
if (!open) removing = null
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
{#if removing}
|
|
385
|
+
<p class="body">
|
|
386
|
+
<span class="strong">{removing.name}</span>
|
|
387
|
+
<span class="muted"> — {kindLabel(removing.kind)}</span>
|
|
388
|
+
</p>
|
|
389
|
+
{/if}
|
|
390
|
+
|
|
391
|
+
{#snippet footer()}
|
|
392
|
+
<Button variant="secondary" onclick={() => (removing = null)} disabled={remove.isPending}>
|
|
393
|
+
{t('common.cancel')}
|
|
394
|
+
</Button>
|
|
395
|
+
<Button variant="danger" loading={remove.isPending} disabled={deleting} onclick={submitRemove}>
|
|
396
|
+
{t('common.remove')}
|
|
397
|
+
</Button>
|
|
398
|
+
{/snippet}
|
|
399
|
+
</Dialog>
|
|
400
|
+
{/if}
|
|
401
|
+
|
|
402
|
+
<style>
|
|
403
|
+
.sec {
|
|
404
|
+
margin-block-start: 20px;
|
|
405
|
+
}
|
|
406
|
+
.rows {
|
|
407
|
+
display: grid;
|
|
408
|
+
gap: 6px;
|
|
409
|
+
padding-block: 8px;
|
|
410
|
+
}
|
|
411
|
+
.docs {
|
|
412
|
+
list-style: none;
|
|
413
|
+
margin: 8px 0 0;
|
|
414
|
+
padding: 0;
|
|
415
|
+
display: grid;
|
|
416
|
+
gap: 2px;
|
|
417
|
+
}
|
|
418
|
+
.docs li {
|
|
419
|
+
display: grid;
|
|
420
|
+
grid-template-columns: 16px minmax(0, 1fr) auto;
|
|
421
|
+
align-items: center;
|
|
422
|
+
gap: 4px 8px;
|
|
423
|
+
padding: 8px 6px 8px 8px;
|
|
424
|
+
border-radius: var(--kern-r-md);
|
|
425
|
+
font-size: 13px;
|
|
426
|
+
}
|
|
427
|
+
/* `surface-hover`, not `surface-raised`: the panel this list sits in is already raised, so a
|
|
428
|
+
raised hover is white on white and the row gives no feedback at all. */
|
|
429
|
+
.docs li:hover {
|
|
430
|
+
background: var(--kern-surface-hover);
|
|
431
|
+
}
|
|
432
|
+
.ic {
|
|
433
|
+
color: var(--kern-ink-500);
|
|
434
|
+
display: flex;
|
|
435
|
+
}
|
|
436
|
+
.name {
|
|
437
|
+
min-width: 0;
|
|
438
|
+
overflow: hidden;
|
|
439
|
+
text-overflow: ellipsis;
|
|
440
|
+
white-space: nowrap;
|
|
441
|
+
font-weight: 500;
|
|
442
|
+
}
|
|
443
|
+
.chips,
|
|
444
|
+
.dates {
|
|
445
|
+
grid-column: 2;
|
|
446
|
+
display: flex;
|
|
447
|
+
align-items: center;
|
|
448
|
+
gap: 6px;
|
|
449
|
+
flex-wrap: wrap;
|
|
450
|
+
min-width: 0;
|
|
451
|
+
}
|
|
452
|
+
/* A colour, not opacity: opacity fades text against the panel whatever token it names. */
|
|
453
|
+
.dates,
|
|
454
|
+
.muted {
|
|
455
|
+
color: var(--kern-ink-500);
|
|
456
|
+
font-size: 12px;
|
|
457
|
+
}
|
|
458
|
+
.form {
|
|
459
|
+
display: grid;
|
|
460
|
+
gap: 14px;
|
|
461
|
+
}
|
|
462
|
+
.pair {
|
|
463
|
+
display: grid;
|
|
464
|
+
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
465
|
+
gap: 14px;
|
|
466
|
+
}
|
|
467
|
+
.file {
|
|
468
|
+
display: flex;
|
|
469
|
+
align-items: center;
|
|
470
|
+
gap: 6px;
|
|
471
|
+
margin: 0;
|
|
472
|
+
min-width: 0;
|
|
473
|
+
color: var(--kern-ink-500);
|
|
474
|
+
font-size: 12.5px;
|
|
475
|
+
}
|
|
476
|
+
.progress {
|
|
477
|
+
display: grid;
|
|
478
|
+
gap: 6px;
|
|
479
|
+
}
|
|
480
|
+
.err {
|
|
481
|
+
margin: 0;
|
|
482
|
+
font-size: 12.5px;
|
|
483
|
+
color: var(--kern-danger);
|
|
484
|
+
}
|
|
485
|
+
.body {
|
|
486
|
+
margin: 0 0 4px;
|
|
487
|
+
font-size: 13.5px;
|
|
488
|
+
}
|
|
489
|
+
.strong {
|
|
490
|
+
font-weight: 500;
|
|
491
|
+
}
|
|
492
|
+
</style>
|