@kernhq/module-hr 0.10.5 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.10.5",
3
+ "version": "0.12.0",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
46
46
  "@kernhq/contracts": "^0.7.0",
47
47
  "@kernhq/kernel": "^0.7.0",
48
48
  "@kernhq/tsconfig": "^0.1.0",
49
- "@kernhq/ui": "^0.10.0",
49
+ "@kernhq/ui": "^0.12.0",
50
50
  "@tanstack/svelte-query": "^6.1.0",
51
51
  "@types/node": "^24.0.0",
52
52
  "@types/pg": "^8.15.0",
@@ -60,7 +60,7 @@
60
60
  "peerDependencies": {
61
61
  "@kernhq/contracts": "^0.7.0",
62
62
  "@kernhq/kernel": "^0.7.0",
63
- "@kernhq/ui": "^0.10.0",
63
+ "@kernhq/ui": "^0.12.0",
64
64
  "@tanstack/svelte-query": "^6.1.0",
65
65
  "svelte": "^5.46.0"
66
66
  },
@@ -0,0 +1,641 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ EmptyState,
7
+ Field,
8
+ formatDateTime,
9
+ localTime,
10
+ messageLocale,
11
+ Skeleton,
12
+ Textarea,
13
+ toast,
14
+ } from '@kernhq/ui'
15
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
16
+ import { getHrApi } from '../api-instance.js'
17
+ import { t } from '../i18n.js'
18
+ import type { AttendanceDay, Punch, Regularization } from '../index.js'
19
+ import { canHr } from '../permissions.js'
20
+ import { formatDuration, isoDate } from '../query.js'
21
+ import RegularizationDialog from './RegularizationDialog.svelte'
22
+
23
+ /**
24
+ * One day of the sheet, opened up.
25
+ *
26
+ * The person reading this is arguing with a number on their timesheet, so everything here either
27
+ * helps them make that argument or helps them withdraw it: the punches the total was computed from,
28
+ * the anomalies named rather than counted, the corrections they have already asked for, and the two
29
+ * things somebody can actually do — void a wrong punch, or ask for one that is missing.
30
+ *
31
+ * **A void is a correcting row, not an edit.** The original keeps its place in the list, struck
32
+ * through, and the reason travels with it. That is the whole reason attendance records punches the
33
+ * way it does, and a list that quietly dropped the voided row would be claiming the opposite.
34
+ */
35
+ interface Props {
36
+ workspaceId: string
37
+ day: AttendanceDay
38
+ /**
39
+ * The corrections this person has waiting, from the page's one query — filtered to this day here.
40
+ * Its states travel with it, because a section that silently shows nothing when a fetch failed
41
+ * tells somebody they have asked for nothing.
42
+ */
43
+ corrections: { items: Regularization[]; loading: boolean; failed: boolean; retry: () => void }
44
+ }
45
+ const { workspaceId, day, corrections }: Props = $props()
46
+
47
+ const api = getHrApi()
48
+ const queryClient = useQueryClient()
49
+
50
+ const canManage = $derived(canHr('attendanceManage'))
51
+ const canRequest = $derived(canHr('attendancePunch'))
52
+
53
+ /**
54
+ * `includeVoided`, because a voided punch is the point.
55
+ *
56
+ * The confirmation below promises the original stays on the record; a list that then dropped it
57
+ * would make that promise a lie the first time somebody used it.
58
+ */
59
+ const punchesQuery = createQuery(() => ({
60
+ // The same literal shape `hrKeys` builds — `['hr', entity, workspace, …scope]` — so the module's
61
+ // blanket `['hr']` invalidation after a punch, a void or a correction reaches it.
62
+ queryKey: ['hr', 'punches', workspaceId, 'me', day.businessDate] as const,
63
+ enabled: Boolean(workspaceId),
64
+ queryFn: () =>
65
+ api.attendance.punches.list({
66
+ workspaceId,
67
+ from: day.businessDate,
68
+ to: day.businessDate,
69
+ includeVoided: true,
70
+ limit: 100,
71
+ }),
72
+ }))
73
+ const rows = $derived(punchesQuery.data?.items ?? [])
74
+
75
+ /**
76
+ * A void writes a correcting row that points at *itself*, and the server's own note beside it says
77
+ * it exists to carry the reason rather than to be counted as a punch. Drawn as one it would put a
78
+ * second "Clocked in 09:00" directly under the one it voided, which reads as a duplicate punch —
79
+ * so it is held back here and used for the reason it carries.
80
+ */
81
+ const voidNotes = $derived(
82
+ new Map(rows.filter((p) => p.voidedByPunchId === p.id).map((p) => [p.id, p.note] as const)),
83
+ )
84
+ const punches = $derived(rows.filter((p) => p.voidedByPunchId !== p.id))
85
+ const live = $derived(punches.filter((p) => p.voidedByPunchId === null))
86
+
87
+ /** No answer yet, or an answer that never arrived — the retained list is what decides, not the status. */
88
+ const loading = $derived(!workspaceId || punchesQuery.isLoading)
89
+
90
+ /**
91
+ * The reason a void was given, out of the note the correcting row carries.
92
+ *
93
+ * The server writes `Voids <id>: <reason>`, so the id is peeled off to leave the sentence somebody
94
+ * typed. A note that does not match is shown whole rather than guessed at — losing the reason
95
+ * would take away the one thing a disputed punch is read for.
96
+ */
97
+ const VOID_NOTE = /^Voids [0-9a-f-]{36}:\s*/i
98
+ const voidReason = (punch: Punch): string | null => {
99
+ const note = punch.voidedByPunchId ? (voidNotes.get(punch.voidedByPunchId) ?? null) : null
100
+ return note ? note.replace(VOID_NOTE, '') : null
101
+ }
102
+
103
+ const directionLabel = (direction: string) =>
104
+ direction === 'in'
105
+ ? t('att_punch_in')
106
+ : direction === 'out'
107
+ ? t('att_punch_out')
108
+ : direction === 'break_start'
109
+ ? t('att_punch_break_start')
110
+ : t('att_punch_break_end')
111
+
112
+ const methodLabel = (method: string) =>
113
+ method === 'web'
114
+ ? t('att_method_web')
115
+ : method === 'mobile'
116
+ ? t('att_method_mobile')
117
+ : method === 'kiosk'
118
+ ? t('att_method_kiosk')
119
+ : method === 'qr'
120
+ ? t('att_method_qr')
121
+ : method === 'device'
122
+ ? t('att_method_device')
123
+ : method === 'import'
124
+ ? t('att_method_import')
125
+ : t('att_method_manual')
126
+
127
+ /**
128
+ * The time, with the date added only when it is not this day's.
129
+ *
130
+ * A night shift files its punches on the date it started, so the clock-out of a 22:00–06:00 shift
131
+ * reads "06:00" on a row headed by the previous day — and a bare time there is the one number on
132
+ * this panel somebody would misread.
133
+ */
134
+ const timeLabel = (punch: Punch) =>
135
+ isoDate(new Date(punch.at)) === day.businessDate ? localTime(new Date(punch.at)) : formatDateTime(punch.at)
136
+
137
+ const words = {
138
+ hours: (n: string) => t('hours_short', { n }),
139
+ minutes: (n: string) => t('minutes_short', { n }),
140
+ }
141
+
142
+ /**
143
+ * How far the device's clock was out, when that is a fact worth stating.
144
+ *
145
+ * Under a minute is rounding, not disagreement, and "0 minutes out" beside a claimed punch reads
146
+ * as an accusation of nothing.
147
+ */
148
+ const skewLabel = (punch: Punch): string | null =>
149
+ punch.skewMs !== null && Math.abs(punch.skewMs) >= 60_000
150
+ ? t('att_punch_skew', {
151
+ amount: formatDuration(Math.round(Math.abs(punch.skewMs) / 60_000), words, messageLocale()),
152
+ })
153
+ : null
154
+
155
+ /**
156
+ * What an anomaly means, in words.
157
+ *
158
+ * Keyed by the code `computeDay` writes, never by its position in the array. A code with no string
159
+ * yet falls back to the code itself: unlovely, and still more than the number that was here before.
160
+ */
161
+ const ANOMALY_KEYS: Record<string, string> = {
162
+ double_clock_in: 'hr.att_anomaly_double_clock_in',
163
+ clock_out_without_in: 'hr.att_anomaly_clock_out_without_in',
164
+ missing_clock_out: 'hr.att_anomaly_missing_clock_out',
165
+ break_not_ended: 'hr.att_anomaly_break_not_ended',
166
+ double_break_start: 'hr.att_anomaly_double_break_start',
167
+ break_end_without_start: 'hr.att_anomaly_break_end_without_start',
168
+ overtime_beyond_cap: 'hr.att_anomaly_overtime_beyond_cap',
169
+ }
170
+ const anomalyLabel = (code: string): string => {
171
+ const key = ANOMALY_KEYS[code]
172
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
173
+ // a code nothing covers here, and a key whose string has not been merged yet — land on the code
174
+ // rather than on `hr.att_anomaly_…` in front of a person.
175
+ const text = key ? t(key) : undefined
176
+ return text && text !== key ? text : code
177
+ }
178
+
179
+ /** This day's corrections, newest first — the server orders by date, and one day is one date. */
180
+ const dayCorrections = $derived(corrections.items.filter((r) => r.businessDate === day.businessDate))
181
+
182
+ /**
183
+ * A closed month cannot move, so a correction to it would change nothing.
184
+ *
185
+ * `recomputeDay` leaves a locked day exactly where it is, which means voiding a punch on one writes
186
+ * the correcting row and the totals above it stay as they were. Better to say so than to offer a
187
+ * button whose effect is invisible.
188
+ */
189
+ const frozen = $derived(day.locked)
190
+
191
+ /** The punch waiting on a confirmation, and what the last attempt said. */
192
+ let voiding = $state<Punch | null>(null)
193
+ let voidReasonText = $state('')
194
+ let voidInFlight = $state(false)
195
+ let voidError = $state<string | null>(null)
196
+
197
+ let requestOpen = $state(false)
198
+ let requestPunch = $state<Punch | null>(null)
199
+
200
+ /** Reset between punches: yesterday's reason must not ride along on today's void. */
201
+ $effect(() => {
202
+ void voiding?.id
203
+ voidReasonText = ''
204
+ })
205
+
206
+ /**
207
+ * The void refusals this module has its own sentence for, keyed by the `reason` the router sends
208
+ * beside the refusal — never by the sentence, because a list of sentences is a list somebody has to
209
+ * keep in sync and the day it drifts the reader is told nothing.
210
+ *
211
+ * Empty on purpose: `voidPunch` refuses an already-voided punch through `KernError.conflict` with
212
+ * no reason argument today, so its own sentence is the only thing that says which punch and why.
213
+ * That is the fallback below doing its job, and a reason added to the server later reaches a reader
214
+ * here the moment somebody writes its string.
215
+ */
216
+ const voidRefusalMessages: Record<string, string> = {}
217
+
218
+ function voidFailure(error: unknown): string {
219
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
220
+ if (failure.code !== 'CONFLICT') return t('att_void_error')
221
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
222
+ const key = reason ? voidRefusalMessages[reason] : undefined
223
+ const translated = key ? t(key) : undefined
224
+ return (translated && translated !== key ? translated : failure.message) || t('att_void_error')
225
+ }
226
+
227
+ const voidPunch = createMutation(() => ({
228
+ mutationFn: (input: { punchId: string; reason: string }) =>
229
+ api.attendance.punches.void({ workspaceId, punchId: input.punchId, reason: input.reason }),
230
+ onSuccess: () => {
231
+ voiding = null
232
+ voidError = null
233
+ toast.success(t('att_void_done'))
234
+ // A void rewrites the day sheet, the totals above it and this list, so the whole module's cache
235
+ // is re-read rather than guessing which keys moved.
236
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
237
+ },
238
+ onError: (error) => {
239
+ voidError = voidFailure(error)
240
+ // A refusal is the server saying its picture of this day is not the one on screen — most often
241
+ // because the punch was already voided from somewhere else. Re-read exactly as a void that
242
+ // landed does; without this the same dead row sits in the list and every retry earns the same
243
+ // sentence.
244
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
245
+ },
246
+ onSettled: () => {
247
+ voidInFlight = false
248
+ },
249
+ }))
250
+
251
+ /**
252
+ * `voidInFlight` rather than `voidPunch.isPending`: the disabled attribute only reaches the button
253
+ * on the next render, so two quick clicks both fire — and the second arrives at a punch the first
254
+ * has already voided, which the server answers with a refusal the person did nothing to deserve.
255
+ */
256
+ const confirmVoid = () => {
257
+ if (!voiding || voidInFlight || !voidReasonText.trim()) return
258
+ voidInFlight = true
259
+ voidError = null
260
+ voidPunch.mutate({ punchId: voiding.id, reason: voidReasonText.trim() })
261
+ }
262
+
263
+ const closeVoid = () => {
264
+ if (voidInFlight) return
265
+ voiding = null
266
+ voidError = null
267
+ }
268
+
269
+ const openRequest = (punch: Punch | null) => {
270
+ requestPunch = punch
271
+ requestOpen = true
272
+ }
273
+ </script>
274
+
275
+ <div class="detail">
276
+ {#if day.anomalies.length}
277
+ <!--
278
+ The count that used to be here said a number and no noun. These are the sentences behind it,
279
+ and they are the reason the day needs a person at all.
280
+ -->
281
+ <section class="block anomalies" aria-label={t('att_anomalies')}>
282
+ <h3>{t('att_anomalies')}</h3>
283
+ <!--
284
+ Keyed by position, not by the code: `computeDay` pushes one entry per occurrence, so a day
285
+ with two unmatched clock-ins carries `double_clock_in` twice — and a keyed `{#each}` with a
286
+ repeated key throws at render rather than drawing the second one.
287
+ -->
288
+ <ul class="plain">
289
+ {#each day.anomalies as code, index (index)}
290
+ <li>{anomalyLabel(code)}</li>
291
+ {/each}
292
+ </ul>
293
+ </section>
294
+ {/if}
295
+
296
+ <section class="block" aria-label={t('att_punches')}>
297
+ <h3>{t('att_punches')}</h3>
298
+
299
+ {#if loading}
300
+ <div class="skel">
301
+ {#each [1, 2] as n (n)}<Skeleton height="34px" />{/each}
302
+ </div>
303
+ {:else if punches.length}
304
+ <!--
305
+ The punches a person already has outrank the failure: everything here is invalidated by a
306
+ punch, a void and a decision, so a refetch failing while the last good list is still in
307
+ `data` is the ordinary case. An error branch above this one would blank the day somebody
308
+ opened it to read.
309
+ -->
310
+ {#if punchesQuery.isError}
311
+ <p class="stale" role="status">
312
+ <span>{t('att_punches_stale')}</span>
313
+ <Button size="sm" variant="ghost" onclick={() => void punchesQuery.refetch()}>{t('retry')}</Button>
314
+ </p>
315
+ {/if}
316
+ <ul class="plain punches">
317
+ {#each punches as punch (punch.id)}
318
+ {@const voided = punch.voidedByPunchId !== null}
319
+ {@const reason = voidReason(punch)}
320
+ {@const skew = skewLabel(punch)}
321
+ <li class="punch" class:voided>
322
+ <div class="line">
323
+ <span class="dir">{directionLabel(punch.direction)}</span>
324
+ <span class="time">{timeLabel(punch)}</span>
325
+ <Badge tone="grey">{methodLabel(punch.method)}</Badge>
326
+ {#if punch.trust === 'disputed'}
327
+ <Badge tone="declined">{t('att_trust_disputed')}</Badge>
328
+ {:else if punch.trust === 'claimed'}
329
+ <Badge tone="warning">{t('att_trust_claimed')}</Badge>
330
+ {/if}
331
+ {#if voided}<Badge tone="grey">{t('att_punch_voided')}</Badge>{/if}
332
+ <span class="spacer"></span>
333
+ {#if canManage && !voided}
334
+ <!--
335
+ Hidden from somebody who may never void, disabled with the reason stated below
336
+ when the month is closed — a control that does nothing and does not say why is
337
+ the defect this screen was opened to fix.
338
+ -->
339
+ <Button size="sm" variant="ghost" disabled={frozen} onclick={() => (voiding = punch)}>
340
+ {t('att_void')}
341
+ </Button>
342
+ {/if}
343
+ {#if canRequest && !voided}
344
+ <Button size="sm" variant="ghost" disabled={frozen} onclick={() => openRequest(punch)}>
345
+ {t('att_correct_this')}
346
+ </Button>
347
+ {/if}
348
+ </div>
349
+ <!--
350
+ The honest state of an offline punch, and what a void did — both under the row they
351
+ belong to rather than in a tooltip nobody opens.
352
+ -->
353
+ {#if punch.trust !== 'trusted' && skew}
354
+ <p class="sub warn">{skew}</p>
355
+ {/if}
356
+ {#if voided && reason}
357
+ <p class="sub">{t('att_punch_void_reason', { reason })}</p>
358
+ {/if}
359
+ </li>
360
+ {/each}
361
+ </ul>
362
+ {#if frozen}
363
+ <p class="sub note">{t('att_locked_no_change')}</p>
364
+ {/if}
365
+ {:else if punchesQuery.isError}
366
+ <EmptyState compact icon="triangle-alert" title={t('att_punches_error')}>
367
+ {#snippet actions()}
368
+ <Button size="sm" variant="secondary" onclick={() => void punchesQuery.refetch()}>
369
+ {t('retry')}
370
+ </Button>
371
+ {/snippet}
372
+ </EmptyState>
373
+ {:else}
374
+ <!--
375
+ Nothing recorded is the commonest reason somebody opens a day: they worked it and the clock
376
+ has no idea. So the empty state offers the thing that fills it, and only promises it to
377
+ somebody who may actually ask.
378
+ -->
379
+ <EmptyState
380
+ compact
381
+ icon="timer"
382
+ title={t('att_punches_none')}
383
+ description={canRequest ? t('att_punches_none_desc') : undefined}
384
+ >
385
+ {#snippet actions()}
386
+ {#if canRequest}
387
+ <Button size="sm" variant="secondary" disabled={frozen} onclick={() => openRequest(null)}>
388
+ {t('att_correction_request')}
389
+ </Button>
390
+ {/if}
391
+ {/snippet}
392
+ </EmptyState>
393
+ {#if frozen && canRequest}
394
+ <p class="sub note">{t('att_locked_no_change')}</p>
395
+ {/if}
396
+ {/if}
397
+ </section>
398
+
399
+ {#if corrections.loading}
400
+ <div class="skel"><Skeleton height="34px" /></div>
401
+ {:else if dayCorrections.length}
402
+ <section class="block" aria-label={t('att_corrections_pending')}>
403
+ <h3>{t('att_corrections_pending')}</h3>
404
+ <ul class="plain">
405
+ {#each dayCorrections as correction (correction.id)}
406
+ <li class="correction">
407
+ <div class="line">
408
+ <span class="proposal">
409
+ {correction.proposed
410
+ .map((p) => `${directionLabel(p.direction)} ${localTime(new Date(p.at))}`)
411
+ .join(' · ')}
412
+ </span>
413
+ <Badge tone="upcoming">{t('att_correction_waiting')}</Badge>
414
+ </div>
415
+ <p class="sub">{correction.reason}</p>
416
+ </li>
417
+ {/each}
418
+ </ul>
419
+ </section>
420
+ {:else if corrections.failed}
421
+ <!--
422
+ A failed list of corrections must not read as "you have asked for nothing" — that is exactly
423
+ the sentence somebody would act on by asking a second time.
424
+ -->
425
+ <p class="stale" role="status">
426
+ <span>{t('att_corrections_error')}</span>
427
+ <Button size="sm" variant="ghost" onclick={corrections.retry}>{t('retry')}</Button>
428
+ </p>
429
+ {/if}
430
+
431
+ {#if canRequest && punches.length > 0}
432
+ <div class="foot">
433
+ <Button size="sm" variant="secondary" icon="square-pen" disabled={frozen} onclick={() => openRequest(null)}>
434
+ {t('att_correction_request')}
435
+ </Button>
436
+ </div>
437
+ {/if}
438
+ </div>
439
+
440
+ <!--
441
+ Voiding is not deleting, and the person pressing it has to know that before it happens rather than
442
+ after: the record keeps the punch and gains a row saying who struck it out and why. Somebody who
443
+ believes they erased a punch will type a different reason from somebody who knows the sentence is
444
+ permanent.
445
+ -->
446
+ <Dialog
447
+ open={voiding !== null}
448
+ size="sm"
449
+ title={t('att_void_title')}
450
+ onOpenChange={(next) => {
451
+ if (!next) closeVoid()
452
+ }}
453
+ >
454
+ {#if voiding}
455
+ <p class="body">
456
+ {t('att_void_body', { punch: directionLabel(voiding.direction), time: timeLabel(voiding) })}
457
+ </p>
458
+ <p class="body note">{t('att_void_kept')}</p>
459
+ {/if}
460
+
461
+ <Field label={t('att_void_reason_label')} hint={t('att_void_reason_hint')} required>
462
+ {#snippet children(fieldId)}
463
+ <Textarea id={fieldId} bind:value={voidReasonText} rows={3} />
464
+ {/snippet}
465
+ </Field>
466
+
467
+ {#if voidError}
468
+ <p class="body failed" role="alert">{voidError}</p>
469
+ {/if}
470
+
471
+ {#snippet footer()}
472
+ <!-- Says why the danger button is dead, rather than leaving somebody to guess at it. -->
473
+ {#if !voidReasonText.trim()}<span class="hint">{t('att_void_reason_missing')}</span>{/if}
474
+ <!--
475
+ Secondary, as in `DecisionDialog`: on a destructive confirmation the way out must not be the
476
+ faintest control on it.
477
+ -->
478
+ <Button variant="secondary" onclick={closeVoid} disabled={voidInFlight}>{t('att_void_keep')}</Button>
479
+ <Button
480
+ variant="danger"
481
+ loading={voidInFlight}
482
+ disabled={!voidReasonText.trim()}
483
+ onclick={confirmVoid}
484
+ >
485
+ {t('att_void')}
486
+ </Button>
487
+ {/snippet}
488
+ </Dialog>
489
+
490
+ <RegularizationDialog
491
+ open={requestOpen}
492
+ {workspaceId}
493
+ businessDate={day.businessDate}
494
+ punch={requestPunch}
495
+ punches={live}
496
+ onClose={() => {
497
+ requestOpen = false
498
+ requestPunch = null
499
+ }}
500
+ />
501
+
502
+ <style>
503
+ .detail {
504
+ display: grid;
505
+ gap: 16px;
506
+ margin-block: 4px 12px;
507
+ margin-inline-start: 12px;
508
+ padding: 14px;
509
+ border: 1px solid var(--kern-border);
510
+ border-radius: var(--kern-r-md);
511
+ background: var(--kern-surface);
512
+ }
513
+ .block {
514
+ display: grid;
515
+ gap: 8px;
516
+ }
517
+ h3 {
518
+ margin: 0;
519
+ font-size: 11px;
520
+ font-weight: 600;
521
+ letter-spacing: 0.06em;
522
+ text-transform: uppercase;
523
+ color: var(--kern-ink-500);
524
+ }
525
+ .plain {
526
+ display: grid;
527
+ gap: 6px;
528
+ list-style: none;
529
+ margin: 0;
530
+ padding: 0;
531
+ }
532
+ .skel {
533
+ display: grid;
534
+ gap: 6px;
535
+ }
536
+ /*
537
+ * 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
538
+ * the block read as a notice rather than as another list.
539
+ */
540
+ .anomalies ul {
541
+ padding: 8px 12px;
542
+ border-radius: var(--kern-r-md);
543
+ background: var(--kern-warning-tint);
544
+ color: var(--kern-warning);
545
+ font-size: 12.5px;
546
+ line-height: 1.5;
547
+ }
548
+ .punches {
549
+ gap: 2px;
550
+ }
551
+ .punch,
552
+ .correction {
553
+ display: grid;
554
+ gap: 2px;
555
+ padding-block: 6px;
556
+ border-block-end: 1px solid var(--kern-border-hairline);
557
+ }
558
+ .line {
559
+ display: flex;
560
+ align-items: center;
561
+ gap: 8px;
562
+ flex-wrap: wrap;
563
+ }
564
+ .spacer {
565
+ flex: 1;
566
+ }
567
+ .dir {
568
+ font-size: 13px;
569
+ font-weight: 500;
570
+ }
571
+ .time {
572
+ font-size: 13px;
573
+ font-variant-numeric: tabular-nums;
574
+ color: var(--kern-ink-700);
575
+ }
576
+ .proposal {
577
+ font-size: 13px;
578
+ font-variant-numeric: tabular-nums;
579
+ }
580
+ /*
581
+ * A voided punch is struck through and *still legible*: `opacity` would fade its text against the
582
+ * panel whatever token it names, and this row is the evidence that nothing was deleted.
583
+ */
584
+ .voided .dir,
585
+ .voided .time {
586
+ color: var(--kern-ink-500);
587
+ text-decoration: line-through;
588
+ }
589
+ .sub {
590
+ margin: 0;
591
+ font-size: 12px;
592
+ line-height: 1.45;
593
+ color: var(--kern-ink-500);
594
+ }
595
+ /* 5.23:1 on --kern-surface in light, 6.80:1 in dark — 12px text has to clear 4.5. */
596
+ .warn {
597
+ color: var(--kern-warning);
598
+ }
599
+ .note {
600
+ margin-block-start: 2px;
601
+ }
602
+ .stale {
603
+ display: flex;
604
+ align-items: center;
605
+ justify-content: space-between;
606
+ flex-wrap: wrap;
607
+ gap: 8px;
608
+ margin: 0;
609
+ padding-block: 6px;
610
+ padding-inline: 12px 8px;
611
+ border-radius: var(--kern-r-md);
612
+ background: var(--kern-warning-tint);
613
+ color: var(--kern-warning);
614
+ font-size: 12.5px;
615
+ }
616
+ .foot {
617
+ display: flex;
618
+ }
619
+ .body {
620
+ margin: 0;
621
+ font-size: 13.5px;
622
+ line-height: 1.55;
623
+ color: var(--kern-ink-700);
624
+ }
625
+ /* A colour, not opacity: opacity fades the text against the dialog whatever token it names. */
626
+ .body.note {
627
+ margin-block: 8px 12px;
628
+ color: var(--kern-ink-500);
629
+ }
630
+ /* A dialog body sits on --kern-surface-raised, not the page: 6.33:1 there in light, 5.04:1 in dark. */
631
+ .failed {
632
+ margin-block-start: 8px;
633
+ color: var(--kern-danger);
634
+ }
635
+ .hint {
636
+ margin-inline-end: auto;
637
+ align-self: center;
638
+ font-size: 12px;
639
+ color: var(--kern-ink-500);
640
+ }
641
+ </style>