@kernhq/module-hr 0.10.0 → 0.10.2
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/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { type CapabilityDef, capabilityDependents } from '@kernhq/contracts'
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
coreApi,
|
|
7
|
+
Dialog,
|
|
8
|
+
EmptyState,
|
|
9
|
+
keys,
|
|
10
|
+
messageLocale,
|
|
11
|
+
navigation,
|
|
12
|
+
SettingsPage,
|
|
13
|
+
Skeleton,
|
|
14
|
+
Switch,
|
|
15
|
+
session,
|
|
16
|
+
toast,
|
|
17
|
+
} from '@kernhq/ui'
|
|
3
18
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
19
|
+
import { capabilityDescriptionKey, capabilityLabelKey } from '../../contract/capabilities.js'
|
|
4
20
|
import type { CoreApi } from '../core-api.js'
|
|
5
21
|
import { t } from '../i18n.js'
|
|
6
22
|
|
|
@@ -19,6 +35,9 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
|
19
35
|
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
20
36
|
const workspaceId = $derived(workspace?.id ?? '')
|
|
21
37
|
|
|
38
|
+
/** Core gates `modules.updateSettings` on this, and every control here is a write to it. */
|
|
39
|
+
const canManage = $derived(session.can('core.modules.manage'))
|
|
40
|
+
|
|
22
41
|
const api = coreApi<CoreApi>()
|
|
23
42
|
const queryClient = useQueryClient()
|
|
24
43
|
|
|
@@ -29,9 +48,45 @@ const modulesQuery = createQuery(() => ({
|
|
|
29
48
|
}))
|
|
30
49
|
|
|
31
50
|
const hr = $derived(modulesQuery.data?.find((entry) => entry.manifest.id === 'hr'))
|
|
32
|
-
const definitions = $derived(hr?.manifest.capabilities ?? [])
|
|
51
|
+
const definitions = $derived<CapabilityDef[]>(hr?.manifest.capabilities ?? [])
|
|
33
52
|
const enabled = $derived(new Set(hr?.state.capabilities ?? []))
|
|
34
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The switch that is waiting for an answer, and the last refused write.
|
|
56
|
+
*
|
|
57
|
+
* `busy` rather than `setCapability.isPending`: a disabled attribute only reaches the control on
|
|
58
|
+
* the next render, and two quick clicks are one render apart — so the guard has to be set in the
|
|
59
|
+
* same tick as the first one.
|
|
60
|
+
*/
|
|
61
|
+
let confirming = $state<CapabilityDef | null>(null)
|
|
62
|
+
let writeError = $state<string | null>(null)
|
|
63
|
+
let busy = $state(false)
|
|
64
|
+
/**
|
|
65
|
+
* Which switch the write in flight belongs to, so that one alone stays live.
|
|
66
|
+
*
|
|
67
|
+
* `disabled` on all of them was the obvious way to hold the screen still, and it takes the focus
|
|
68
|
+
* off the control the person is standing on: Chrome blurs a focused element the moment it is
|
|
69
|
+
* disabled and gives it back to `<body>`, which nothing returns. Measured — `document.activeElement`
|
|
70
|
+
* is `BODY` for the whole write and after it. So the others go inert and this one does not; the
|
|
71
|
+
* second click on *this* one is caught by the guard in `toggle`, which is what was really stopping
|
|
72
|
+
* it anyway.
|
|
73
|
+
*/
|
|
74
|
+
let pending = $state<string | null>(null)
|
|
75
|
+
/**
|
|
76
|
+
* Bumped to put a switch back where the server has it.
|
|
77
|
+
*
|
|
78
|
+
* The track carries its own state — it moves the moment it is clicked, before anything is written
|
|
79
|
+
* — and `checked` here is derived from the query, which a refused write leaves untouched. Nothing
|
|
80
|
+
* would then re-assert it, so the switch would sit in a position the workspace is not in. Keying
|
|
81
|
+
* the control on this remounts it against the truth.
|
|
82
|
+
*
|
|
83
|
+
* On this alone, and not on the enabled set beside it. A remount takes the focus with it: keying on
|
|
84
|
+
* the value as well sent a keyboard user to the top of the page every time a switch answered them,
|
|
85
|
+
* measured as `document.activeElement` landing on `<body>`. A value that really changed reaches the
|
|
86
|
+
* track as a prop without a remount; only a write the server refused needs one, and that is this.
|
|
87
|
+
*/
|
|
88
|
+
let snapback = $state(0)
|
|
89
|
+
|
|
35
90
|
const setCapability = createMutation(() => ({
|
|
36
91
|
mutationFn: async (vars: { id: string; on: boolean }) => {
|
|
37
92
|
const stored = ((hr?.state.settings as Record<string, unknown>)?.$capabilities ?? {}) as Record<
|
|
@@ -47,55 +102,227 @@ const setCapability = createMutation(() => ({
|
|
|
47
102
|
})
|
|
48
103
|
},
|
|
49
104
|
onSuccess: () => {
|
|
105
|
+
confirming = null
|
|
106
|
+
writeError = null
|
|
50
107
|
// Navigation, widgets and routes are all derived from this, so the whole shell needs to re-read.
|
|
51
108
|
void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
|
|
52
109
|
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
53
110
|
},
|
|
111
|
+
onError: () => {
|
|
112
|
+
snapback++
|
|
113
|
+
writeError = t('capability_write_error')
|
|
114
|
+
// A refusal inside the dialog is answered in the dialog, where the button that failed still is.
|
|
115
|
+
// Turning one *on* has no dialog to carry the message, so that one gets a toast.
|
|
116
|
+
if (!confirming) toast.error(t('capability_write_error'))
|
|
117
|
+
},
|
|
118
|
+
onSettled: () => {
|
|
119
|
+
busy = false
|
|
120
|
+
pending = null
|
|
121
|
+
},
|
|
54
122
|
}))
|
|
55
123
|
|
|
56
|
-
|
|
57
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The label and description a person reads.
|
|
126
|
+
*
|
|
127
|
+
* The manifest carries English literals, because core's module admin and the shell's mock parse it
|
|
128
|
+
* with no HR bundle merged. The translation is looked up by convention from the capability's id,
|
|
129
|
+
* and `t()` answering with the key itself is what says there is no string for this reader yet — in
|
|
130
|
+
* which case the literal is what the manifest is for.
|
|
131
|
+
*/
|
|
132
|
+
function resolve(key: string, fallback: string): string {
|
|
133
|
+
const translated = t(key)
|
|
134
|
+
return translated === key ? fallback : translated
|
|
135
|
+
}
|
|
136
|
+
const labelOf = (def: CapabilityDef): string => resolve(capabilityLabelKey(def.id), def.label)
|
|
137
|
+
const describe = (def: CapabilityDef): string | undefined =>
|
|
138
|
+
def.description ? resolve(capabilityDescriptionKey(def.id), def.description) : undefined
|
|
139
|
+
|
|
140
|
+
const nameOf = (id: string): string => {
|
|
141
|
+
const def = definitions.find((d) => d.id === id)
|
|
142
|
+
return def ? labelOf(def) : id
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Arabic separates a list with '،' and German with 'und'; `join(', ')` gives every reader a comma. */
|
|
146
|
+
function listOf(parts: string[]): string {
|
|
147
|
+
try {
|
|
148
|
+
return new Intl.ListFormat(messageLocale(), { type: 'conjunction' }).format(parts)
|
|
149
|
+
} catch {
|
|
150
|
+
return parts.join(', ')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
58
153
|
|
|
59
154
|
/** A capability whose dependency is off cannot be switched on — the server would prune it anyway. */
|
|
60
155
|
const blockedBy = (deps: string[]) => deps.filter((d) => !enabled.has(d))
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What else goes out with the one being switched off.
|
|
159
|
+
*
|
|
160
|
+
* Only the dependants that are actually on: `calendars` carries leave, accrual and attendance in a
|
|
161
|
+
* workspace that uses them and carries nothing in one that does not, and naming a feature the
|
|
162
|
+
* workspace never had would read as a threat rather than a warning.
|
|
163
|
+
*/
|
|
164
|
+
const dependentsOf = (def: CapabilityDef): string[] =>
|
|
165
|
+
capabilityDependents(definitions, def.id)
|
|
166
|
+
.filter((id) => id !== def.id && enabled.has(id))
|
|
167
|
+
.map(nameOf)
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The question the dialog asked, kept as it was asked.
|
|
171
|
+
*
|
|
172
|
+
* Everything in the panel used to be derived from `confirming` and `enabled`, and a successful
|
|
173
|
+
* write clears the one and empties the other — a frame before the dialog has finished fading out,
|
|
174
|
+
* so the heading and the list of what goes with it blanked while the box was still on screen. It is
|
|
175
|
+
* only ever replaced by the next question, which is what carries it through the close.
|
|
176
|
+
*/
|
|
177
|
+
let asked = $state<{ name: string; losing: string[] } | null>(null)
|
|
178
|
+
|
|
179
|
+
function toggle(def: CapabilityDef, on: boolean) {
|
|
180
|
+
if (busy) {
|
|
181
|
+
// Swallowed — and the track moved itself on the way in, so it is now showing a position nobody
|
|
182
|
+
// is going to write. This is the same repair a refused write needs.
|
|
183
|
+
snapback++
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (!on) {
|
|
187
|
+
// Off is the direction that takes other features with it, so it is asked rather than written.
|
|
188
|
+
// The track has already moved itself; put it back until the dialog has an answer.
|
|
189
|
+
confirming = def
|
|
190
|
+
asked = { name: labelOf(def), losing: dependentsOf(def) }
|
|
191
|
+
writeError = null
|
|
192
|
+
snapback++
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
busy = true
|
|
196
|
+
pending = def.id
|
|
197
|
+
writeError = null
|
|
198
|
+
setCapability.mutate({ id: def.id, on: true })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function confirmOff() {
|
|
202
|
+
if (!confirming || busy) return
|
|
203
|
+
busy = true
|
|
204
|
+
pending = confirming.id
|
|
205
|
+
writeError = null
|
|
206
|
+
setCapability.mutate({ id: confirming.id, on: false })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Closes the dialog; it does not stop a write already on its way, which is why the Cancel button
|
|
211
|
+
* is disabled while one is. Escape is not, and cannot be — so this has to be safe to run mid-write:
|
|
212
|
+
* clearing `confirming` is what sends the answer to a toast instead of to a dialog nobody can see.
|
|
213
|
+
*/
|
|
214
|
+
function cancelOff() {
|
|
215
|
+
confirming = null
|
|
216
|
+
writeError = null
|
|
217
|
+
}
|
|
61
218
|
</script>
|
|
62
219
|
|
|
63
220
|
<SettingsPage title={t('settings_capabilities')} description={t('capabilities_desc')}>
|
|
64
221
|
|
|
222
|
+
<!--
|
|
223
|
+
Held definitions outrank the error. A background refetch fails on every core restart, and an error
|
|
224
|
+
branch above the list would answer that by blanking the switchboard — telling an administrator
|
|
225
|
+
that HR has no features, on a screen that had them a second ago. The error is only the whole frame
|
|
226
|
+
when there is nothing else to draw; when there is, it is the line above the list.
|
|
227
|
+
-->
|
|
65
228
|
{#if modulesQuery.isLoading}
|
|
66
|
-
<
|
|
67
|
-
{
|
|
229
|
+
<div class="list">
|
|
230
|
+
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="64px" radius="10px" />{/each}
|
|
231
|
+
</div>
|
|
232
|
+
{:else if definitions.length > 0}
|
|
233
|
+
{#if !canManage}
|
|
234
|
+
<p class="readonly">{t('general_readonly')}</p>
|
|
235
|
+
{/if}
|
|
236
|
+
|
|
237
|
+
{#if modulesQuery.isError}
|
|
238
|
+
<div class="stale" role="status">
|
|
239
|
+
<span>{t('capabilities_stale')}</span>
|
|
240
|
+
<Button size="sm" variant="secondary" onclick={() => void modulesQuery.refetch()}>
|
|
241
|
+
{t('retry')}
|
|
242
|
+
</Button>
|
|
243
|
+
</div>
|
|
244
|
+
{/if}
|
|
245
|
+
|
|
68
246
|
<div class="list">
|
|
69
247
|
{#each definitions as capability (capability.id)}
|
|
70
248
|
{@const missing = blockedBy(capability.dependsOn)}
|
|
249
|
+
{@const description = describe(capability)}
|
|
71
250
|
<Card>
|
|
72
251
|
<div class="row">
|
|
73
252
|
<div class="what">
|
|
74
|
-
<span class="name">{capability
|
|
75
|
-
{#if
|
|
76
|
-
<span class="meta">{
|
|
253
|
+
<span class="name">{labelOf(capability)}</span>
|
|
254
|
+
{#if description}
|
|
255
|
+
<span class="meta">{description}</span>
|
|
77
256
|
{/if}
|
|
78
|
-
|
|
257
|
+
<!-- Both reasons a switch is dead are written down. A control that cannot be moved and
|
|
258
|
+
does not say why reads as a broken screen. -->
|
|
259
|
+
{#if capability.required}
|
|
260
|
+
<span class="meta">{t('capability_always_on')}</span>
|
|
261
|
+
{:else if missing.length}
|
|
79
262
|
<span class="meta">
|
|
80
|
-
{t('capability_requires', { name: missing.map(nameOf)
|
|
263
|
+
{t('capability_requires', { name: listOf(missing.map(nameOf)) })}
|
|
81
264
|
</span>
|
|
82
265
|
{/if}
|
|
83
266
|
</div>
|
|
84
|
-
<!--
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
267
|
+
<!-- `ariaLabel`, not `label`: `Switch` renders a visible `label` beside its track, and
|
|
268
|
+
the name is already the first line of this row — so it was printed twice. A screen
|
|
269
|
+
reader still needs it, and this is the way to give it one without a second copy. -->
|
|
270
|
+
{#key snapback}
|
|
271
|
+
<Switch
|
|
272
|
+
checked={enabled.has(capability.id)}
|
|
273
|
+
disabled={!canManage ||
|
|
274
|
+
capability.required ||
|
|
275
|
+
missing.length > 0 ||
|
|
276
|
+
(busy && pending !== capability.id)}
|
|
277
|
+
onCheckedChange={(on) => toggle(capability, on)}
|
|
278
|
+
ariaLabel={labelOf(capability)}
|
|
279
|
+
/>
|
|
280
|
+
{/key}
|
|
92
281
|
</div>
|
|
93
282
|
</Card>
|
|
94
283
|
{/each}
|
|
95
284
|
</div>
|
|
285
|
+
{:else if modulesQuery.isError}
|
|
286
|
+
<EmptyState icon="triangle-alert" title={t('general_error')} description={t('general_error_desc')}>
|
|
287
|
+
{#snippet actions()}
|
|
288
|
+
<Button variant="secondary" onclick={() => void modulesQuery.refetch()}>{t('retry')}</Button>
|
|
289
|
+
{/snippet}
|
|
290
|
+
</EmptyState>
|
|
291
|
+
{:else if !hr}
|
|
292
|
+
<EmptyState icon="users" title={t('general_not_enabled')} description={t('general_not_enabled_desc')} />
|
|
293
|
+
{:else}
|
|
294
|
+
<EmptyState icon="toggle-left" title={t('capabilities_none')} description={t('capabilities_none_desc')} />
|
|
96
295
|
{/if}
|
|
97
296
|
</SettingsPage>
|
|
98
297
|
|
|
298
|
+
<!--
|
|
299
|
+
What turning one off actually does, before it is done.
|
|
300
|
+
The dependants are the half nobody expects: switching `calendars` off in a workspace that uses
|
|
301
|
+
leave takes leave, accrual and attendance with it, in one click, and the only clue afterwards is
|
|
302
|
+
three navigation rows that are gone.
|
|
303
|
+
-->
|
|
304
|
+
<Dialog
|
|
305
|
+
open={Boolean(confirming)}
|
|
306
|
+
size="sm"
|
|
307
|
+
title={asked ? t('capability_off_title', { name: asked.name }) : ''}
|
|
308
|
+
description={t('capability_off_body')}
|
|
309
|
+
onOpenChange={(open) => {
|
|
310
|
+
if (!open) cancelOff()
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{#if asked?.losing.length}
|
|
314
|
+
<p class="also">{t('capability_off_also', { names: listOf(asked.losing) })}</p>
|
|
315
|
+
{/if}
|
|
316
|
+
{#if writeError}
|
|
317
|
+
<p class="failed" role="alert">{writeError}</p>
|
|
318
|
+
{/if}
|
|
319
|
+
|
|
320
|
+
{#snippet footer()}
|
|
321
|
+
<Button variant="secondary" onclick={cancelOff} disabled={busy}>{t('cancel')}</Button>
|
|
322
|
+
<Button variant="danger" loading={busy} onclick={confirmOff}>{t('capability_off_confirm')}</Button>
|
|
323
|
+
{/snippet}
|
|
324
|
+
</Dialog>
|
|
325
|
+
|
|
99
326
|
<style>
|
|
100
327
|
.list {
|
|
101
328
|
display: grid;
|
|
@@ -119,4 +346,34 @@ const blockedBy = (deps: string[]) => deps.filter((d) => !enabled.has(d))
|
|
|
119
346
|
color: var(--kern-ink-500);
|
|
120
347
|
font-size: 12px;
|
|
121
348
|
}
|
|
349
|
+
.readonly {
|
|
350
|
+
margin: 0 0 12px;
|
|
351
|
+
font-size: 13px;
|
|
352
|
+
color: var(--kern-ink-500);
|
|
353
|
+
}
|
|
354
|
+
.stale {
|
|
355
|
+
display: flex;
|
|
356
|
+
align-items: center;
|
|
357
|
+
justify-content: space-between;
|
|
358
|
+
gap: 12px;
|
|
359
|
+
flex-wrap: wrap;
|
|
360
|
+
margin-block-end: 8px;
|
|
361
|
+
padding: 8px 12px;
|
|
362
|
+
border: 1px solid var(--kern-border);
|
|
363
|
+
border-radius: var(--kern-r-md);
|
|
364
|
+
font-size: 12.5px;
|
|
365
|
+
/* 5.23:1 on --kern-surface in light, 6.80:1 in dark — small text, so it has to clear 4.5. */
|
|
366
|
+
color: var(--kern-warning);
|
|
367
|
+
}
|
|
368
|
+
.also {
|
|
369
|
+
margin: 0 0 8px;
|
|
370
|
+
font-size: 13.5px;
|
|
371
|
+
font-weight: 500;
|
|
372
|
+
}
|
|
373
|
+
.failed {
|
|
374
|
+
margin: 0;
|
|
375
|
+
font-size: 13px;
|
|
376
|
+
/* 6.33:1 on the dialog surface in light, 5.04:1 in dark. */
|
|
377
|
+
color: var(--kern-danger);
|
|
378
|
+
}
|
|
122
379
|
</style>
|