@kernhq/module-hr 0.10.4 → 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.
@@ -0,0 +1,654 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ EmptyState,
7
+ Field,
8
+ formatDate,
9
+ Input,
10
+ messageLocale,
11
+ SegmentedControl,
12
+ Sheet,
13
+ Skeleton,
14
+ Textarea,
15
+ toast,
16
+ } from '@kernhq/ui'
17
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
18
+ // `LedgerKind` is not one of the models the client barrel re-exports, and widening a barrel every
19
+ // screen shares to name one union is the wrong direction. Straight from the contract.
20
+ import type { LedgerKind } from '../../contract/leave.js'
21
+ import { getHrApi } from '../api-instance.js'
22
+ import { t } from '../i18n.js'
23
+ import type { LeaveBalance, LeaveLedgerEntry } from '../index.js'
24
+ import { canHr } from '../permissions.js'
25
+ import { formatDays, hrKeys, isoDate } from '../query.js'
26
+
27
+ /**
28
+ * How a balance adds up — and the one place it can be changed by hand.
29
+ *
30
+ * The ledger is append-only, and that is the whole point of showing it: a cancelled booking is a
31
+ * **reversal beside the consumption it reverses**, not a gap where a row used to be. So a row that
32
+ * has been undone is marked rather than removed, and the row that undid it says which one it was.
33
+ * Somebody reconciling reads down the running balance; somebody arguing about a number reads the
34
+ * reason column.
35
+ *
36
+ * The running balance is anchored at the *top*, not built up from zero at the bottom. The server
37
+ * returns the newest entries first and caps the page, so counting up from the oldest row on screen
38
+ * would be counting up from an arbitrary point in the year and every figure would be wrong by
39
+ * whatever was cut off. Anchored at the balance the server already computed for this year, each row
40
+ * shows what the balance was immediately after it — which stays true however much is missing below.
41
+ *
42
+ * No `hasCapability` check here: the ledger lives behind `leave`, exactly the capability the route
43
+ * carrying this page already declares. A second check would be the only one of its kind in this
44
+ * module and would say nothing new.
45
+ */
46
+ interface Props {
47
+ /** The tile that was opened. `null` closes the panel — and keeps it live: it is the parent's own
48
+ * query row, so an adjustment moves the figure at the top of this panel without a second fetch. */
49
+ balance: LeaveBalance | null
50
+ workspaceId: string
51
+ /** Whose balance this is. Named in the confirmation, because an adjustment is done *to* somebody. */
52
+ personName: string
53
+ onClose: () => void
54
+ }
55
+ const { balance, workspaceId, personName, onClose }: Props = $props()
56
+
57
+ const api = getHrApi()
58
+ const queryClient = useQueryClient()
59
+
60
+ const open = $derived(balance !== null)
61
+ const personId = $derived(balance?.personId ?? '')
62
+ const leaveTypeId = $derived(balance?.leaveTypeId ?? '')
63
+ const periodYear = $derived(balance?.periodYear ?? new Date().getFullYear())
64
+ const typeName = $derived(balance?.leaveTypeName ?? '')
65
+ /** Half-days are still counted in days; only an hourly type is read in hours. */
66
+ const hourly = $derived(balance?.unit === 'hour')
67
+ const minutesPerUnit = $derived(hourly ? 60 : 8 * 60)
68
+
69
+ /**
70
+ * The cap the contract allows, asked for in full.
71
+ *
72
+ * `leave.ledger.list` answers `nextCursor: null` whatever it returns, so there is no second page to
73
+ * fetch: a year longer than this is truncated, and the note under the table says so rather than
74
+ * letting somebody reconcile against a list that quietly stops.
75
+ */
76
+ const LIMIT = 200
77
+
78
+ const ledgerQuery = createQuery(() => ({
79
+ queryKey: hrKeys.leaveLedger(workspaceId, personId, leaveTypeId, periodYear),
80
+ enabled: open && Boolean(workspaceId) && Boolean(personId),
81
+ queryFn: () => api.leave.ledger.list({ workspaceId, personId, leaveTypeId, periodYear, limit: LIMIT }),
82
+ }))
83
+ const entries = $derived(ledgerQuery.data?.items ?? [])
84
+
85
+ /**
86
+ * A disabled query is `pending` and not fetching, so a closed panel is not "loading" — without the
87
+ * `open` test the first frame after every close would draw skeletons behind the closing animation.
88
+ */
89
+ const loading = $derived(open && ledgerQuery.isLoading)
90
+ const empty = $derived(open && !loading && entries.length === 0 && !ledgerQuery.isError)
91
+
92
+ /**
93
+ * A failed refetch that still has an answer to show. Every adjustment and every decision invalidates
94
+ * the whole module, so a refetch failing while the last good list is still in `data` is the ordinary
95
+ * case — an error branch above the data would blank a table somebody is reading numbers off.
96
+ */
97
+ const stale = $derived(ledgerQuery.isError && entries.length > 0)
98
+
99
+ type LedgerRow = {
100
+ entry: LeaveLedgerEntry
101
+ /** The balance immediately after this entry. */
102
+ afterMinutes: number
103
+ /** The entry this one undoes, when it is on screen. */
104
+ reverses: LeaveLedgerEntry | null
105
+ /** Whether a later entry undid this one. */
106
+ reversed: boolean
107
+ }
108
+
109
+ const rows = $derived.by<LedgerRow[]>(() => {
110
+ const items = entries
111
+ const byId = new Map(items.map((e) => [e.id, e]))
112
+ const reversedIds = new Set(items.map((e) => e.reversesEntryId).filter((id): id is string => id !== null))
113
+ let running = balance?.balanceMinutes ?? 0
114
+ return items.map((entry) => {
115
+ const afterMinutes = running
116
+ running -= entry.amountMinutes
117
+ return {
118
+ entry,
119
+ afterMinutes,
120
+ reverses: entry.reversesEntryId ? (byId.get(entry.reversesEntryId) ?? null) : null,
121
+ reversed: reversedIds.has(entry.id),
122
+ }
123
+ })
124
+ })
125
+
126
+ /** The same arithmetic the server does on the way out, so the two never disagree by a rounding step. */
127
+ const inUnit = (minutes: number) =>
128
+ hourly ? Math.round((minutes / 60) * 10) / 10 : Math.round((minutes / (60 * 8)) * 100) / 100
129
+
130
+ /**
131
+ * `signDisplay: 'always'` rather than a '+' glued in front of a formatted number: a ledger is read
132
+ * for its signs, and which glyph a sign is — and which side of the digits it goes — belongs to the
133
+ * locale. The interface language is passed explicitly for the same reason every formatter in
134
+ * `@kernhq/ui` takes it: the browser's own language is not the one this screen is written in.
135
+ */
136
+ const signedNumber = $derived(
137
+ new Intl.NumberFormat(messageLocale(), { signDisplay: 'always', maximumFractionDigits: 2 }),
138
+ )
139
+ /** A year, in the reader's digits — `formatCount` would group it into "2,026". */
140
+ const yearLabel = $derived(new Intl.NumberFormat(messageLocale(), { useGrouping: false }).format(periodYear))
141
+
142
+ const unitWord = (value: number) => t(hourly ? 'hours' : 'days', { count: Math.abs(value) })
143
+ const change = (minutes: number) => `${signedNumber.format(inUnit(minutes))} ${unitWord(inUnit(minutes))}`
144
+ const plain = (minutes: number) => formatDays(inUnit(minutes), messageLocale())
145
+ const amountWithUnit = (value: number) => `${formatDays(value, messageLocale())} ${unitWord(value)}`
146
+
147
+ /** A `YYYY-MM-DD` read as a local day. `new Date('2026-03-01')` is UTC midnight, which is the day
148
+ * before in every zone west of Greenwich. */
149
+ const day = (iso: string) => formatDate(`${iso}T00:00:00`)
150
+
151
+ /**
152
+ * Every kind the contract can produce, named the way somebody reconciling would say it.
153
+ *
154
+ * The set is closed in `LedgerKind`, so this switch is exhaustive by construction — a kind added to
155
+ * the contract stops compiling here rather than rendering a raw `carry_out` at a person.
156
+ */
157
+ const kindLabel = (kind: LedgerKind): string =>
158
+ kind === 'grant'
159
+ ? t('leave_kind_grant')
160
+ : kind === 'accrual'
161
+ ? t('leave_kind_accrual')
162
+ : kind === 'consumption'
163
+ ? t('leave_kind_consumption')
164
+ : kind === 'reversal'
165
+ ? t('leave_kind_reversal')
166
+ : kind === 'expiry'
167
+ ? t('leave_kind_expiry')
168
+ : kind === 'adjustment'
169
+ ? t('leave_kind_adjustment')
170
+ : kind === 'carry_in'
171
+ ? t('leave_kind_carry_in')
172
+ : kind === 'carry_out'
173
+ ? t('leave_kind_carry_out')
174
+ : t('leave_kind_encashment')
175
+
176
+ // ---------------------------------------------------------------- adjusting by hand
177
+
178
+ const canAdjust = $derived(canHr('leaveAdjust'))
179
+
180
+ let adjusting = $state(false)
181
+ /** A plain string, because that is what `SegmentedControl` binds; only 'remove' is ever tested. */
182
+ let direction = $state('add')
183
+ let amountText = $state('')
184
+ let effectiveOn = $state(isoDate())
185
+ let reason = $state('')
186
+ /**
187
+ * Errors appear once somebody has pressed the button, and the button is never disabled.
188
+ *
189
+ * A greyed-out control that does not say what is missing is the defect this avoids: the reason is
190
+ * required by the contract, so an empty form would otherwise be a dead button with no explanation.
191
+ * Pressing it names every field that is not ready instead.
192
+ */
193
+ let attempted = $state(false)
194
+ let adjustError = $state<string | null>(null)
195
+ /** Not `adjust.isPending`: the disabled attribute reaches the button on the next render, so two
196
+ * quick clicks both fire — and both would be recorded, which for a ledger means two adjustments. */
197
+ let adjustInFlight = $state(false)
198
+
199
+ const amountValue = $derived(Number(amountText))
200
+ const amountOk = $derived(amountText.trim().length > 0 && Number.isFinite(amountValue) && amountValue > 0)
201
+ const reasonOk = $derived(reason.trim().length > 0)
202
+ const dateOk = $derived(/^\d{4}-\d{2}-\d{2}$/.test(effectiveOn))
203
+ const signedMinutes = $derived(Math.round(amountValue * minutesPerUnit) * (direction === 'remove' ? -1 : 1))
204
+
205
+ /**
206
+ * Which entitlement year the entry lands in is decided by `effectiveOn`, server-side.
207
+ *
208
+ * So an adjustment dated in another year moves a balance this panel is not showing, and the list
209
+ * behind the dialog would not move at all — which reads as a write that failed. Said out loud
210
+ * instead, before it happens.
211
+ */
212
+ const otherYear = $derived(dateOk ? Number(effectiveOn.slice(0, 4)) : periodYear)
213
+ const yearMismatch = $derived(dateOk && otherYear !== periodYear)
214
+ const otherYearLabel = $derived(
215
+ new Intl.NumberFormat(messageLocale(), { useGrouping: false }).format(otherYear),
216
+ )
217
+
218
+ const openAdjust = () => {
219
+ direction = 'add'
220
+ amountText = ''
221
+ effectiveOn = isoDate()
222
+ reason = ''
223
+ attempted = false
224
+ adjustError = null
225
+ adjusting = true
226
+ }
227
+
228
+ const closeAdjust = () => {
229
+ if (adjustInFlight) return
230
+ adjusting = false
231
+ }
232
+
233
+ const adjust = createMutation(() => ({
234
+ mutationFn: () =>
235
+ api.leave.adjust({
236
+ workspaceId,
237
+ personId,
238
+ leaveTypeId,
239
+ // `kind` is left at the contract's default, `adjustment`. The other eight are the engine's
240
+ // own words for what it did — an accrual it computed, an expiry it swept — and letting a
241
+ // person write one by hand would put a sentence in the ledger that nothing else agrees with.
242
+ amountMinutes: signedMinutes,
243
+ effectiveOn,
244
+ reason: reason.trim(),
245
+ }),
246
+ onSuccess: () => {
247
+ adjusting = false
248
+ adjustError = null
249
+ toast.success(t('leave_adjusted_toast'))
250
+ // An adjustment moves a balance, this ledger, and anything showing days left — the widget on
251
+ // the dashboard included. The whole module's cache goes rather than a guess at which keys moved.
252
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
253
+ },
254
+ onError: (error) => {
255
+ adjustError = adjustFailure(error)
256
+ // A refusal means the server's picture is not the one on screen. Re-read exactly as a write
257
+ // that landed does, so the figures behind the dialog are the ones a retry would act on.
258
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
259
+ },
260
+ onSettled: () => {
261
+ adjustInFlight = false
262
+ },
263
+ }))
264
+
265
+ /**
266
+ * The refusals this screen has its own sentence for, keyed by the `reason` beside the refusal —
267
+ * never by the sentence, because a list of sentences is a list somebody has to keep in sync and the
268
+ * day it drifts the reader is told nothing.
269
+ *
270
+ * Empty on purpose: `leave.adjust` refuses nothing by name today — it appends, and the only things
271
+ * that can stop it are the permission and the capability, neither of which this button is offered
272
+ * without. The shape is here so that a refusal added to the router later (a locked payroll period is
273
+ * the obvious one) reaches a reader by having a key written, not by this file being restructured.
274
+ */
275
+ const adjustRefusalMessages: Record<string, string> = {}
276
+
277
+ /**
278
+ * What a refused adjustment says to the person who asked for it.
279
+ *
280
+ * The test is the transport's `code`, never the sentence: `KernError.conflict` arrives as CONFLICT
281
+ * with its `reason` at `data.reason`. Anything else — a network drop, a 500, a gateway — carries
282
+ * machine text in English, which is the last thing to paste in front of somebody. The same shape as
283
+ * `ClockControls.svelte` and the cancellation on `LeavePage.svelte`; there is no third.
284
+ */
285
+ function adjustFailure(error: unknown): string {
286
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
287
+ if (failure.code !== 'CONFLICT') return t('leave_adjust_error')
288
+ const reason_ = typeof failure.data?.reason === 'string' ? failure.data.reason : null
289
+ const key = reason_ ? adjustRefusalMessages[reason_] : undefined
290
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
291
+ // a reason no key covers, and a key whose string has not been merged — land on the router's
292
+ // sentence rather than putting `hr.leave_adjust_refused_…` in front of somebody.
293
+ const translated = key ? t(key) : undefined
294
+ return (translated && translated !== key ? translated : failure.message) || t('leave_adjust_error')
295
+ }
296
+
297
+ const submitAdjust = () => {
298
+ attempted = true
299
+ if (adjustInFlight || !amountOk || !reasonOk || !dateOk) return
300
+ adjustInFlight = true
301
+ adjustError = null
302
+ adjust.mutate()
303
+ }
304
+ </script>
305
+
306
+ <Sheet
307
+ {open}
308
+ width={560}
309
+ title={t('leave_ledger_title', { name: typeName })}
310
+ onOpenChange={(next) => {
311
+ if (!next) onClose()
312
+ }}
313
+ >
314
+ {#snippet actions()}
315
+ <!--
316
+ The way in to an adjustment, except when the ledger is empty — there it is the empty state's
317
+ own action, so the offer sits where somebody is already looking and never twice on one panel.
318
+ -->
319
+ {#if canAdjust && !empty}
320
+ <Button size="sm" variant="secondary" icon="sliders-vertical" onclick={openAdjust}>
321
+ {t('leave_adjust')}
322
+ </Button>
323
+ {/if}
324
+ {/snippet}
325
+
326
+ <p class="anchor">
327
+ <span>{t('leave_ledger_year', { year: yearLabel })}</span>
328
+ <span class="figure">
329
+ <span>{t('leave_ledger_balance')}</span>
330
+ <strong>{amountWithUnit(inUnit(balance?.balanceMinutes ?? 0))}</strong>
331
+ </span>
332
+ </p>
333
+
334
+ {#if stale}
335
+ <p class="stale" role="status">
336
+ <span>{t('leave_stale')}</span>
337
+ <Button size="sm" variant="ghost" onclick={() => void ledgerQuery.refetch()}>{t('retry')}</Button>
338
+ </p>
339
+ {/if}
340
+
341
+ {#if loading}
342
+ <div class="rows">
343
+ {#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="44px" />{/each}
344
+ </div>
345
+ {:else if rows.length}
346
+ <div class="table" role="table" aria-label={t('leave_ledger_title', { name: typeName })}>
347
+ <div class="thead" role="row">
348
+ <span role="columnheader">{t('leave_ledger_when')}</span>
349
+ <span role="columnheader">{t('leave_ledger_what')}</span>
350
+ <span role="columnheader" class="num">{t('leave_ledger_change')}</span>
351
+ <span role="columnheader" class="num">{t('leave_ledger_after')}</span>
352
+ </div>
353
+ {#each rows as row (row.entry.id)}
354
+ <div class="trow" role="row">
355
+ <span class="cell when" role="cell">{day(row.entry.effectiveOn)}</span>
356
+ <span class="cell what" role="cell">
357
+ <span class="kind">
358
+ {kindLabel(row.entry.kind)}
359
+ {#if row.reversed}<Badge tone="grey">{t('leave_ledger_reversed')}</Badge>{/if}
360
+ </span>
361
+ {#if row.reverses}
362
+ <span class="note">{t('leave_ledger_reverses', { date: day(row.reverses.effectiveOn) })}</span>
363
+ {/if}
364
+ {#if row.entry.reason}<span class="note">{row.entry.reason}</span>{/if}
365
+ </span>
366
+ <span class="cell num" class:credit={row.entry.amountMinutes > 0} class:debit={row.entry.amountMinutes < 0} role="cell">
367
+ {change(row.entry.amountMinutes)}
368
+ </span>
369
+ <span class="cell num after" role="cell">{plain(row.afterMinutes)}</span>
370
+ </div>
371
+ {/each}
372
+ </div>
373
+ {#if entries.length >= LIMIT}
374
+ <p class="capped">{t('leave_ledger_capped', { count: LIMIT })}</p>
375
+ {/if}
376
+ {:else if ledgerQuery.isError}
377
+ <EmptyState compact icon="triangle-alert" title={t('leave_ledger_error')}>
378
+ {#snippet actions()}
379
+ <Button size="sm" variant="secondary" onclick={() => void ledgerQuery.refetch()}>{t('retry')}</Button>
380
+ {/snippet}
381
+ </EmptyState>
382
+ {:else}
383
+ <!--
384
+ Nothing has moved in this entitlement year. That is a real state — a type added in March has
385
+ an empty ledger until the first grant — so it names what would fill it, and offers the one
386
+ thing a person on this panel can do about it.
387
+ -->
388
+ <EmptyState
389
+ compact
390
+ icon="scroll-text"
391
+ title={t('leave_ledger_none')}
392
+ description={t('leave_ledger_none_desc')}
393
+ >
394
+ {#snippet actions()}
395
+ {#if canAdjust}
396
+ <Button size="sm" variant="secondary" icon="sliders-vertical" onclick={openAdjust}>
397
+ {t('leave_adjust')}
398
+ </Button>
399
+ {/if}
400
+ {/snippet}
401
+ </EmptyState>
402
+ {/if}
403
+ </Sheet>
404
+
405
+ <!--
406
+ Adjusting is writing a row nobody can take out again, so the dialog says whose balance moves, by
407
+ how much and in which direction — live, as the amount is typed, because the mistake this catches
408
+ is a 20 where a 2 was meant and no amount of "are you sure" catches that.
409
+ -->
410
+ <Dialog
411
+ open={adjusting}
412
+ title={t('leave_adjust_title')}
413
+ description={t('leave_adjust_desc')}
414
+ onOpenChange={(next) => {
415
+ if (!next) closeAdjust()
416
+ }}
417
+ >
418
+ <div class="form">
419
+ <!--
420
+ No `Field` around this one: `Field` renders a `<label for>`, and a segmented control is a
421
+ radiogroup rather than one focusable input for a label to point at. Its own `label` names the
422
+ group for a screen reader, and the two buttons say what they do on the face of them.
423
+ -->
424
+ <SegmentedControl
425
+ label={t('leave_adjust_direction')}
426
+ bind:value={direction}
427
+ items={[
428
+ { value: 'add', label: t('leave_adjust_add'), icon: 'plus' },
429
+ { value: 'remove', label: t('leave_adjust_remove'), icon: 'minus' },
430
+ ]}
431
+ />
432
+
433
+ <Field
434
+ label={hourly ? t('leave_adjust_amount_hours') : t('leave_adjust_amount_days')}
435
+ required
436
+ error={attempted && !amountOk ? t('leave_adjust_amount_invalid') : null}
437
+ >
438
+ {#snippet children(id)}
439
+ <Input {id} type="number" min="0" step="0.5" inputmode="decimal" bind:value={amountText} />
440
+ {/snippet}
441
+ </Field>
442
+
443
+ <Field
444
+ label={t('leave_adjust_effective')}
445
+ required
446
+ hint={t('leave_adjust_effective_hint')}
447
+ error={attempted && !dateOk ? t('leave_adjust_effective_invalid') : null}
448
+ >
449
+ {#snippet children(id)}
450
+ <Input {id} type="date" bind:value={effectiveOn} />
451
+ {/snippet}
452
+ </Field>
453
+
454
+ <Field
455
+ label={t('leave_reason')}
456
+ required
457
+ hint={t('leave_adjust_reason_hint')}
458
+ error={attempted && !reasonOk ? t('leave_adjust_reason_required') : null}
459
+ >
460
+ {#snippet children(id)}
461
+ <Textarea {id} rows={3} bind:value={reason} />
462
+ {/snippet}
463
+ </Field>
464
+ </div>
465
+
466
+ {#if amountOk && dateOk}
467
+ <p class="consequence">
468
+ {direction === 'add'
469
+ ? t('leave_adjust_confirm_add', {
470
+ name: personName,
471
+ type: typeName,
472
+ amount: amountWithUnit(amountValue),
473
+ date: day(effectiveOn),
474
+ })
475
+ : t('leave_adjust_confirm_remove', {
476
+ name: personName,
477
+ type: typeName,
478
+ amount: amountWithUnit(amountValue),
479
+ date: day(effectiveOn),
480
+ })}
481
+ </p>
482
+ {#if yearMismatch}
483
+ <p class="warn">{t('leave_adjust_other_year', { year: otherYearLabel })}</p>
484
+ {/if}
485
+ {/if}
486
+ <p class="permanent">{t('leave_adjust_irreversible')}</p>
487
+ {#if adjustError}<p class="failed" role="alert">{adjustError}</p>{/if}
488
+
489
+ {#snippet footer()}
490
+ <!-- Secondary, as in `DecisionDialog`: on a dialog that writes something permanent the way out
491
+ must not be the faintest control on it. -->
492
+ <Button variant="secondary" onclick={closeAdjust} disabled={adjustInFlight}>{t('cancel')}</Button>
493
+ <Button variant="danger" loading={adjustInFlight} onclick={submitAdjust}>{t('leave_adjust')}</Button>
494
+ {/snippet}
495
+ </Dialog>
496
+
497
+ <style>
498
+ .anchor {
499
+ display: flex;
500
+ align-items: baseline;
501
+ justify-content: space-between;
502
+ flex-wrap: wrap;
503
+ gap: 8px;
504
+ margin: 0 0 14px;
505
+ font-size: 12.5px;
506
+ color: var(--kern-ink-500);
507
+ }
508
+ .figure {
509
+ display: flex;
510
+ align-items: baseline;
511
+ gap: 8px;
512
+ }
513
+ .figure strong {
514
+ font-size: 15px;
515
+ color: var(--kern-ink-900);
516
+ font-variant-numeric: tabular-nums;
517
+ }
518
+ /*
519
+ * The warning ink is 4.58:1 on its own tint in light and 5.28:1 in dark, and the tint is what makes
520
+ * the strip read as a notice rather than as another row of the table.
521
+ */
522
+ .stale {
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: space-between;
526
+ flex-wrap: wrap;
527
+ gap: 8px;
528
+ margin-block: 0 12px;
529
+ padding-block: 6px;
530
+ padding-inline: 12px 8px;
531
+ border-radius: var(--kern-r-md);
532
+ background: var(--kern-warning-tint);
533
+ color: var(--kern-warning);
534
+ font-size: 12.5px;
535
+ }
536
+ .rows {
537
+ display: grid;
538
+ gap: 4px;
539
+ }
540
+
541
+ /* One grid for the header and every row, so the two number columns line up down the panel. */
542
+ .table {
543
+ --hr-ledger-cols: 96px minmax(0, 1fr) 92px 76px;
544
+ width: 100%;
545
+ }
546
+ .thead,
547
+ .trow {
548
+ display: grid;
549
+ grid-template-columns: var(--hr-ledger-cols);
550
+ gap: 10px;
551
+ align-items: start;
552
+ }
553
+ .thead {
554
+ height: 30px;
555
+ align-items: center;
556
+ border-block-end: 1px solid var(--kern-border);
557
+ font-size: 11px;
558
+ font-weight: 600;
559
+ letter-spacing: 0.06em;
560
+ text-transform: uppercase;
561
+ color: var(--kern-ink-500);
562
+ }
563
+ .trow {
564
+ padding-block: 9px;
565
+ border-block-end: 1px solid var(--kern-border-hairline);
566
+ }
567
+ .cell {
568
+ min-width: 0;
569
+ font-size: 13px;
570
+ }
571
+ .when {
572
+ color: var(--kern-ink-500);
573
+ }
574
+ .what {
575
+ display: flex;
576
+ flex-direction: column;
577
+ gap: 3px;
578
+ }
579
+ .kind {
580
+ display: flex;
581
+ align-items: center;
582
+ gap: 6px;
583
+ font-weight: 500;
584
+ color: var(--kern-ink-900);
585
+ }
586
+ /* A colour, not opacity: opacity fades the text against the panel whatever token it names. */
587
+ .note {
588
+ font-size: 12px;
589
+ color: var(--kern-ink-500);
590
+ overflow-wrap: anywhere;
591
+ }
592
+ .num {
593
+ text-align: end;
594
+ font-variant-numeric: tabular-nums;
595
+ white-space: nowrap;
596
+ }
597
+ /* 7.00:1 in light and 8.50:1 in dark on --kern-surface-raised, which is what a sheet sits on. */
598
+ .credit {
599
+ color: var(--kern-success-ink);
600
+ }
601
+ /* 6.33:1 in light, 5.04:1 in dark on the same surface. */
602
+ .debit {
603
+ color: var(--kern-danger);
604
+ }
605
+ .after {
606
+ color: var(--kern-ink-700);
607
+ }
608
+ .capped {
609
+ margin: 10px 0 0;
610
+ font-size: 12px;
611
+ color: var(--kern-ink-500);
612
+ }
613
+
614
+ .form {
615
+ display: grid;
616
+ gap: 14px;
617
+ }
618
+ .consequence {
619
+ margin: 14px 0 0;
620
+ padding: 10px 12px;
621
+ border-radius: var(--kern-r-md);
622
+ background: var(--kern-surface-chip);
623
+ font-size: 13.5px;
624
+ line-height: 1.55;
625
+ color: var(--kern-ink-900);
626
+ }
627
+ .warn {
628
+ margin: 8px 0 0;
629
+ padding: 8px 12px;
630
+ border-radius: var(--kern-r-md);
631
+ background: var(--kern-warning-tint);
632
+ color: var(--kern-warning);
633
+ font-size: 12.5px;
634
+ line-height: 1.5;
635
+ }
636
+ .permanent {
637
+ margin: 10px 0 0;
638
+ font-size: 12.5px;
639
+ line-height: 1.5;
640
+ color: var(--kern-ink-500);
641
+ }
642
+ /* A dialog body sits on --kern-surface-raised, not the page: 6.33:1 there in light, 5.04:1 in dark. */
643
+ .failed {
644
+ margin: 8px 0 0;
645
+ font-size: 13px;
646
+ color: var(--kern-danger);
647
+ }
648
+
649
+ @media (max-width: 768px) {
650
+ .table {
651
+ --hr-ledger-cols: 84px minmax(0, 1fr) 82px 66px;
652
+ }
653
+ }
654
+ </style>