@7365admin1/layer-common 3.2.2-staging.120 → 3.2.2-staging.122

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.
@@ -64,6 +64,10 @@
64
64
  </v-col>
65
65
 
66
66
  <v-col cols="6" class="pa-0">
67
+ <!-- Was `color="black"`: a fixed slab that stayed black on the dark
68
+ card. `primary-button` is the theme's FILL token, and its pair
69
+ `on-primary-button` supplies the white label - unlike `primary`,
70
+ which is a FOREGROUND here (links, icons, text buttons). -->
67
71
  <v-btn
68
72
  block
69
73
  tile
@@ -71,7 +75,7 @@
71
75
  class="text-none"
72
76
  size="large"
73
77
  height="48"
74
- color="black"
78
+ color="primary-button"
75
79
  @click="submit"
76
80
  :loading="loading"
77
81
  :disabled="!valid || loading || !hasChanges"
@@ -19,6 +19,22 @@
19
19
  word - a padlock on "Closed - View Only" - so adopting the design's
20
20
  pill does not quietly drop it. -->
21
21
  <slot>{{ label ?? status }}</slot>
22
+ <!--
23
+ The remove control. NEW design, approved by the owner on 2026-08-14 - the
24
+ handoff draws no removable chip. It is an affordance ON the existing pill,
25
+ not a second shape, so a removable "KEY-309" and a read-only "Issued" stay
26
+ visibly the same family: same 999px pill, same 4px 10px, same 12px/700,
27
+ same 24px height. A chip that cannot be removed is byte-for-byte unchanged.
28
+ -->
29
+ <button
30
+ v-if="removable"
31
+ type="button"
32
+ class="status-chip__x"
33
+ :aria-label="removeLabel || `Remove ${label ?? status}`"
34
+ @click="emit('remove')"
35
+ >
36
+ <v-icon icon="mdi-close" :size="12" />
37
+ </button>
22
38
  </span>
23
39
  </template>
24
40
 
@@ -47,8 +63,19 @@ const props = defineProps({
47
63
  type: String as PropType<"ok" | "warn" | "err" | "info" | "neutral" | "">,
48
64
  default: "",
49
65
  },
66
+ /** Draws the × and emits `remove` when it is pressed. */
67
+ removable: { type: Boolean, default: false },
68
+ /**
69
+ * The × 's accessible name. Defaults to `Remove <the printed label>`, which
70
+ * is right for an identifier chip ("Remove KEY-309"); give it explicitly
71
+ * whenever the printed text alone would not tell a screen-reader user what
72
+ * is about to be taken away.
73
+ */
74
+ removeLabel: { type: String, default: "" },
50
75
  });
51
76
 
77
+ const emit = defineEmits<{ (e: "remove"): void }>();
78
+
52
79
  const toneClass = computed(() =>
53
80
  props.tone ? `tone-${props.tone}` : statusToneClass(props.status)
54
81
  );
@@ -83,6 +110,42 @@ const toneClass = computed(() =>
83
110
  font-size: var(--fs-chip);
84
111
  }
85
112
 
113
+ /*
114
+ * A 12px glyph needs a 24px target to stay pressable at 360px, but the chip is
115
+ * only 24px tall - so the hit area is INSET into the pill's own padding rather
116
+ * than added to it. The negative margin is what keeps the × from making a
117
+ * removable chip taller than the read-only one beside it.
118
+ */
119
+ .status-chip__x {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ width: 24px;
124
+ height: 24px;
125
+ margin: -6px -6px -6px 0;
126
+ padding: 0;
127
+ border: none;
128
+ border-radius: var(--r-pill);
129
+ background: transparent;
130
+ /* Chips read as labels first and controls second, so the × sits back from
131
+ the word until it is reached for. */
132
+ color: inherit;
133
+ opacity: 0.62;
134
+ cursor: pointer;
135
+ transition: opacity var(--motion) ease, background var(--motion) ease;
136
+ }
137
+
138
+ .status-chip__x:hover,
139
+ .status-chip__x:focus-visible {
140
+ opacity: 1;
141
+ background: rgba(0, 0, 0, 0.06);
142
+ }
143
+
144
+ .v-theme--dark .status-chip__x:hover,
145
+ .v-theme--dark .status-chip__x:focus-visible {
146
+ background: rgba(255, 255, 255, 0.08);
147
+ }
148
+
86
149
  .status-chip__dot {
87
150
  width: 6px;
88
151
  height: 6px;
@@ -155,8 +155,14 @@
155
155
  </div>
156
156
 
157
157
  <!-- Data Table -->
158
+ <!--
159
+ `tableHeaders`, not `headers`, and the two are the SAME ARRAY unless a
160
+ caller has opted into fixed proportions by putting `weight` on a
161
+ column. See `columnsAreWeighted` below.
162
+ -->
158
163
  <v-data-table
159
- :headers="headers"
164
+ :class="columnsAreWeighted ? 'table-card__fixed' : undefined"
165
+ :headers="tableHeaders"
160
166
  :items="items"
161
167
  :item-value="itemValue"
162
168
  :items-per-page="itemsPerPage"
@@ -257,6 +263,49 @@ const props = defineProps({
257
263
  },
258
264
  });
259
265
 
266
+ /**
267
+ * THE DESIGN'S FIXED COLUMN PROPORTIONS, AND WHY THEY ARE OPT-IN.
268
+ *
269
+ * Every table in the prototype is a CSS grid with an explicit, per-table
270
+ * `grid-template-columns` - `0.9fr 1.5fr 1.7fr 0.8fr 1fr 1.1fr` on Feedbacks,
271
+ * `2fr 1fr 1fr` on Units, `1.6fr 1fr 1.3fr 1.6fr 48px` on Pass & Key. Those
272
+ * ratios are a per-screen design decision. Nothing in a caller's `headers`
273
+ * array carries that information, so this shared component cannot derive them,
274
+ * and inventing one set of proportions for 21 components and 18 app pages would
275
+ * be worse than the automatic layout they have now.
276
+ *
277
+ * So it is stated, not guessed: a header may carry `weight: 1.5` and this turns
278
+ * the weights into percentage widths and switches the table to
279
+ * `table-layout: fixed`, which is what makes a width binding rather than a hint.
280
+ * A column with NO weight is left exactly as the caller wrote it, so an
281
+ * existing `width` (an action column pinned at 48px, say) survives untouched
282
+ * and only the weighted columns share out what is left.
283
+ *
284
+ * NO caller changes. With no weights anywhere, `tableHeaders` IS `props.headers`
285
+ * - the same array reference - and the table renders exactly what it renders
286
+ * today, automatic widths and all.
287
+ */
288
+ const columnsAreWeighted = computed(() =>
289
+ props.headers.some((h) => Number(h?.weight) > 0)
290
+ );
291
+
292
+ const tableHeaders = computed(() => {
293
+ if (!columnsAreWeighted.value) return props.headers;
294
+
295
+ // Columns with no weight keep whatever width they already had (often none,
296
+ // e.g. a 48px action column), and only the weighted ones share what is left.
297
+ const total = props.headers.reduce(
298
+ (sum, h) => sum + (Number(h?.weight) > 0 ? Number(h.weight) : 0),
299
+ 0
300
+ );
301
+
302
+ return props.headers.map((h) =>
303
+ Number(h?.weight) > 0
304
+ ? { ...h, width: `${((Number(h.weight) / total) * 100).toFixed(4)}%` }
305
+ : h
306
+ );
307
+ });
308
+
260
309
  const emits = defineEmits([
261
310
  "create",
262
311
  "scan",
@@ -613,15 +613,21 @@
613
613
  class="d-flex align-center"
614
614
  >
615
615
  <strong>{{ label }}:</strong>
616
- <v-btn
617
- size="x-small"
618
- class="ml-3 text-capitalize"
619
- color="success"
620
- text="Checkin"
616
+ <!-- The design's check-in / check-out pair. Was Vuetify's
617
+ `color="success"` beside a hardcoded `color="red"` - two
618
+ different systems on one field list, and neither of them a
619
+ token. `:loading` is dropped rather than re-invented: the
620
+ same flag already drives `:disabled`, which is the
621
+ precedent set when the scanned-visitor form converted. -->
622
+ <AppButton
623
+ variant="ok"
624
+ icon="mdi-check"
625
+ class="ml-3"
621
626
  :disabled="isVisitorDataFromScannedQRCodeCheckingInOut"
622
627
  @click="handleCheckin(selectedVisitorDataObject)"
623
- :loading="isVisitorDataFromScannedQRCodeCheckingInOut"
624
- />
628
+ >
629
+ Check In
630
+ </AppButton>
625
631
  </span>
626
632
  <span
627
633
  v-if="
@@ -633,14 +639,15 @@
633
639
  class="d-flex align-center"
634
640
  >
635
641
  <strong>{{ label }}:</strong>
636
- <v-btn
637
- size="x-small"
638
- class="ml-3 text-capitalize"
639
- color="red"
640
- text="Checkout"
642
+ <AppButton
643
+ variant="err"
644
+ icon="mdi-close"
645
+ class="ml-3"
641
646
  :disabled="loading.checkingOut"
642
647
  @click="handleCheckout(selectedVisitorId as string)"
643
- />
648
+ >
649
+ Check Out
650
+ </AppButton>
644
651
  </span>
645
652
 
646
653
  <span
@@ -335,7 +335,10 @@ onMounted(() => {
335
335
  }
336
336
 
337
337
  .snapshot-label--out {
338
- color: var(--error);
338
+ /* Was `var(--error)`, which is not a token here - the name is `--err`. An
339
+ undefined custom property invalidates the declaration silently, so the
340
+ label inherited instead of turning red. */
341
+ color: var(--err);
339
342
  }
340
343
 
341
344
  :deep(.v-card) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.120",
5
+ "version": "3.2.2-staging.122",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -18,5 +18,100 @@
18
18
  "els": 3,
19
19
  "textChars": 2,
20
20
  "recordedFrom": "0642279"
21
+ },
22
+ "AccessCardAssignToUnitForm": {
23
+ "els": 177,
24
+ "textChars": 131,
25
+ "recordedFrom": "178ebf0"
26
+ },
27
+ "AccessCardReplaceForm": {
28
+ "els": 68,
29
+ "textChars": 83,
30
+ "recordedFrom": "178ebf0"
31
+ },
32
+ "AddEqupmentForm": {
33
+ "els": 90,
34
+ "textChars": 99,
35
+ "recordedFrom": "178ebf0"
36
+ },
37
+ "BuildingForm": {
38
+ "els": 126,
39
+ "textChars": 69,
40
+ "recordedFrom": "178ebf0"
41
+ },
42
+ "BulletinBoardForm": {
43
+ "els": 172,
44
+ "textChars": 165,
45
+ "recordedFrom": "178ebf0"
46
+ },
47
+ "CameraForm": {
48
+ "els": 127,
49
+ "textChars": 80,
50
+ "recordedFrom": "178ebf0"
51
+ },
52
+ "DocumentForm": {
53
+ "els": 52,
54
+ "textChars": 57,
55
+ "recordedFrom": "178ebf0"
56
+ },
57
+ "InvitationClientForm": {
58
+ "els": 101,
59
+ "textChars": 47,
60
+ "recordedFrom": "178ebf0"
61
+ },
62
+ "InvitationForm": {
63
+ "els": 86,
64
+ "textChars": 43,
65
+ "recordedFrom": "178ebf0"
66
+ },
67
+ "ServiceProviderFormCreate": {
68
+ "els": 51,
69
+ "textChars": 38,
70
+ "recordedFrom": "178ebf0"
71
+ },
72
+ "AcceptDialog": {
73
+ "els": 24,
74
+ "textChars": 106,
75
+ "recordedFrom": "178ebf0"
76
+ },
77
+ "Card/DeleteConfirmation": {
78
+ "els": 21,
79
+ "textChars": 129,
80
+ "recordedFrom": "178ebf0"
81
+ },
82
+ "Dialog/DeleteConfirmation": {
83
+ "els": 21,
84
+ "textChars": 129,
85
+ "recordedFrom": "178ebf0"
86
+ },
87
+ "HidAccessLogDashboard": {
88
+ "els": 172,
89
+ "textChars": 437,
90
+ "recordedFrom": "178ebf0"
91
+ },
92
+ "HidIntercomManagement": {
93
+ "els": 148,
94
+ "textChars": 167,
95
+ "recordedFrom": "178ebf0"
96
+ },
97
+ "HidUserEnrollment": {
98
+ "els": 142,
99
+ "textChars": 287,
100
+ "recordedFrom": "178ebf0"
101
+ },
102
+ "HidReaderManagement": {
103
+ "els": 112,
104
+ "textChars": 377,
105
+ "recordedFrom": "178ebf0"
106
+ },
107
+ "HidQrCodeConfiguration": {
108
+ "els": 106,
109
+ "textChars": 453,
110
+ "recordedFrom": "178ebf0"
111
+ },
112
+ "HidServiceSettingsPanel": {
113
+ "els": 18,
114
+ "textChars": 164,
115
+ "recordedFrom": "178ebf0"
21
116
  }
22
117
  }
@@ -78,6 +78,10 @@ if (-not (Test-Path "$fork\vite.config.mjs")) {
78
78
  # The hardened runner lives in the repo, next to this script, so a fork always
79
79
  # gets the CURRENT one rather than whatever the canonical harness was carrying.
80
80
  Copy-Item (Join-Path $PSScriptRoot "render-check.mjs") -Destination $fork -Force
81
+ # `probe.mjs` is imported by the runner, so a fork without it dies on
82
+ # ERR_MODULE_NOT_FOUND before it measures anything. It was missing from this
83
+ # list, which every fork made before now had to work around by hand.
84
+ Copy-Item (Join-Path $PSScriptRoot "probe.mjs") -Destination $fork -Force
81
85
  Copy-Item (Join-Path $PSScriptRoot "baselines.json") -Destination $fork -Force -ErrorAction SilentlyContinue
82
86
 
83
87
  # A junctioned `node_modules` SHARES `node_modules/.vite` with the harness it
@@ -62,6 +62,39 @@ test("the product's own status words keep the tone the screens already gave them
62
62
  }
63
63
  });
64
64
 
65
+ /**
66
+ * HID enrolment. These two are the whole point of the Status column on
67
+ * `HidUserEnrollment` - if they were left unlisted they would BOTH be neutral
68
+ * and the column would stop distinguishing anything, so they are pinned.
69
+ */
70
+ test("Mapped and Unmapped keep the tones the enrolment screen already gave them", () => {
71
+ assert.equal(statusTone("Mapped"), "ok");
72
+ assert.equal(statusTone("Unmapped"), "warn");
73
+ });
74
+
75
+ /**
76
+ * HID intercom SIP state. "Disabled" and "Not connected" must stay NEUTRAL -
77
+ * painting an intentionally-off intercom red would read as a fault.
78
+ */
79
+ /**
80
+ * HID access authorization, landing on `staging` in b0667a7. Pinned before that
81
+ * merge so the words cannot arrive and quietly render neutral - the difference
82
+ * between a granted and a denied door is the whole point of the column.
83
+ */
84
+ test("HID authorization states are known to the shared map before b0667a7 merges", () => {
85
+ assert.equal(statusTone("Authorized"), "ok");
86
+ assert.equal(statusTone("Not Authorized"), "err");
87
+ assert.equal(statusTone("Unknown"), "warn");
88
+ });
89
+
90
+ test("intercom SIP states keep the tones the intercom screen already gave them", () => {
91
+ assert.equal(statusTone("Connected"), "ok");
92
+ assert.equal(statusTone("Connecting"), "warn");
93
+ assert.equal(statusTone("Not connected"), "neutral");
94
+ assert.equal(statusTone("Disabled"), "neutral");
95
+ assert.equal(statusTone("Failed"), "err");
96
+ });
97
+
65
98
  /**
66
99
  * The one place the handoff and the old per-screen colour disagree: `Open` was
67
100
  * grey on the cleaning schedules and the design names it `ok`. Pinned so the
package/utils/status.ts CHANGED
@@ -70,6 +70,42 @@ const TONES: Record<string, TStatusTone> = {
70
70
  expired: "neutral", // was `grey`
71
71
 
72
72
  "awaiting approval": "warn", // a waiting state, like Pending
73
+
74
+ /**
75
+ * HID enrolment: is this reader identity tied to a real person record yet?
76
+ * Same rule as the Phase 4 block above - not a new opinion, but the tone
77
+ * `HidUserEnrollment` already painted these two words, moved into the one
78
+ * list. Left unlisted they would BOTH come out neutral, which would erase
79
+ * the only distinction the column exists to draw.
80
+ */
81
+ mapped: "ok", // was `status-success`, #1b7f3a on #dff7e7
82
+ unmapped: "warn", // was `status-warning`, #9a6700 on #fff0cc
83
+
84
+ /**
85
+ * HID intercom SIP state, from `HidIntercomManagement`'s own
86
+ * `getStatusClass`. Same rule again: the tone the screen already gave the
87
+ * word, moved into the one list. "Disabled" and "Not connected" are NOT
88
+ * failures - the operator turned SIP off, or never set it up - so they stay
89
+ * neutral, which is what `status-muted` painted them.
90
+ */
91
+ /**
92
+ * HID access authorization. These arrive with `b0667a7` on `staging`
93
+ * ("Call the Authorized status API from Amico via Access Logs"), which
94
+ * replaces the Access Logs' Mapped/Unmapped column with these three words.
95
+ * Listed here AHEAD of that merge so the shared chip already knows them -
96
+ * unlisted they would all come out neutral, silently erasing the difference
97
+ * between a granted and a denied door. Tones are that commit's own:
98
+ * `mapped-chip` green, `unmapped-chip` red, `unknown-chip` amber.
99
+ */
100
+ authorized: "ok",
101
+ "not authorized": "err",
102
+ unknown: "warn",
103
+
104
+ connected: "ok", // was `status-success`
105
+ connecting: "warn", // was `status-warning`, the fall-through case
106
+ "not connected": "neutral", // was `status-muted`
107
+ disabled: "neutral", // was `status-muted`
108
+ failed: "err", // was `status-error`
73
109
  };
74
110
 
75
111
  /**
@@ -0,0 +1,142 @@
1
+ /**
2
+ * THE LIGHT THEME'S KNOWN AA FAILURES, MEASURED.
3
+ *
4
+ * The owner's 2026-08-14 decision is 100% design fidelity over WCAG AA, so the
5
+ * six light values that had been darkened are back on the handoff and the light
6
+ * theme no longer clears 4.5:1 everywhere. This file is the ledger of exactly
7
+ * what that costs: every pair, its measured ratio, and what it needs.
8
+ *
9
+ * It is data, not prose - `theme.test.ts` asserts each entry still measures what
10
+ * it says, so a later change that drifts a colour (in either direction) fails
11
+ * the build and has to come back here and say so.
12
+ *
13
+ * `run` prints the table: `node --experimental-strip-types utils/theme-aa-ledger.ts`
14
+ */
15
+ import { PALETTE, LIGHT_THEME, DARK_THEME } from "./theme.ts";
16
+
17
+ const luminance = (hex: string): number => {
18
+ const c = [0, 2, 4]
19
+ .map((i) => parseInt(hex.slice(1 + i, 3 + i), 16) / 255)
20
+ .map((v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));
21
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
22
+ };
23
+
24
+ export const contrast = (a: string, b: string): number => {
25
+ const [x, y] = [luminance(a), luminance(b)];
26
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
27
+ };
28
+
29
+ export const over = (fg: string, bg: string, alpha: number): string =>
30
+ "#" +
31
+ [0, 2, 4]
32
+ .map((i) =>
33
+ Math.round(
34
+ parseInt(fg.slice(1 + i, 3 + i), 16) * alpha +
35
+ parseInt(bg.slice(1 + i, 3 + i), 16) * (1 - alpha),
36
+ )
37
+ .toString(16)
38
+ .padStart(2, "0"),
39
+ )
40
+ .join("");
41
+
42
+ const L = PALETTE.light;
43
+
44
+ /** `--thead` is a 2% ink wash over the card, not a colour of its own. */
45
+ export const thead = over("#14161a", L.card, 0.02);
46
+
47
+ /**
48
+ * `--hover` is a 5% ink wash. On the page background it is the fill a DISABLED
49
+ * control sits on (the HID reader identification methods, greyed until Online
50
+ * Mode is on), so `--muted` label on `--hover` is a pair a person actually reads.
51
+ */
52
+ export const hoverOnBg = over("#14161a", L.bg, 0.05);
53
+
54
+
55
+ /** The soft chip backgrounds, exactly as the design states them. */
56
+ export const chip = {
57
+ ok: over("#0f8a62", L.card, 0.1),
58
+ warn: over("#be8c19", L.card, 0.13),
59
+ err: over("#cf4b4b", L.card, 0.1),
60
+ info: over("#3b6fd4", L.card, 0.1),
61
+ };
62
+
63
+ /** `--accent-soft` is transparent, so it takes the colour of what is behind it. */
64
+ export const accentSoft = {
65
+ sidebar: over(L.accent, L.sidebar, 0.12),
66
+ card: over(L.accent, L.card, 0.12),
67
+ };
68
+
69
+ /**
70
+ * Every LIGHT pair a person actually reads. `need` is 4.5 for body text and 3
71
+ * for large text (>=18.66px, or >=14px at 700+) and for non-text edges.
72
+ */
73
+ export const LIGHT_PAIRS: Array<{
74
+ what: string;
75
+ fg: string;
76
+ bg: string;
77
+ need: number;
78
+ }> = [
79
+ { what: "muted on a card (table header text, sub-lines)", fg: L.muted, bg: L.card, need: 4.5 },
80
+ { what: "muted on the page background", fg: L.muted, bg: L.bg, need: 4.5 },
81
+ { what: "muted on a table header band", fg: L.muted, bg: thead, need: 4.5 },
82
+ { what: "muted on the sidebar", fg: L.muted, bg: L.sidebar, need: 4.5 },
83
+ { what: "muted on a disabled control's hover fill", fg: L.muted, bg: hoverOnBg, need: 4.5 },
84
+ { what: "ok label on its own chip", fg: L.ok, bg: chip.ok, need: 4.5 },
85
+ { what: "warn label on its own chip", fg: L.warn, bg: chip.warn, need: 4.5 },
86
+ { what: "err label on its own chip", fg: L.err, bg: chip.err, need: 4.5 },
87
+ { what: "info label on its own chip", fg: L.info, bg: chip.info, need: 4.5 },
88
+ { what: "ok as plain text on a card", fg: L.ok, bg: L.card, need: 4.5 },
89
+ { what: "warn as plain text on a card", fg: L.warn, bg: L.card, need: 4.5 },
90
+ { what: "err as plain text on a card (the Logout row)", fg: L.err, bg: L.card, need: 4.5 },
91
+ { what: "info as plain text on a card", fg: L.info, bg: L.card, need: 4.5 },
92
+ { what: "accent-text as a link on a card", fg: L.accentText, bg: L.card, need: 4.5 },
93
+ { what: "accent-text on accent-soft over the sidebar (active nav item)", fg: L.accentText, bg: accentSoft.sidebar, need: 4.5 },
94
+ { what: "accent-text on accent-soft over a card (active tab, avatar initials)", fg: L.accentText, bg: accentSoft.card, need: 4.5 },
95
+ { what: "white on the filled success colour", fg: "#ffffff", bg: L.ok, need: 4.5 },
96
+ { what: "white on the filled warning colour", fg: "#ffffff", bg: L.warn, need: 4.5 },
97
+ { what: "white on the filled error colour", fg: "#ffffff", bg: L.err, need: 4.5 },
98
+ { what: "white on the filled info colour", fg: "#ffffff", bg: L.info, need: 4.5 },
99
+ { what: "white on primary-button (accent-strong) - NOT reverted", fg: "#ffffff", bg: L.accentStrong, need: 4.5 },
100
+ { what: "text on a card", fg: L.text, bg: L.card, need: 4.5 },
101
+ { what: "text2 on a card", fg: L.text2, bg: L.card, need: 4.5 },
102
+ ];
103
+
104
+ export const measure = () =>
105
+ LIGHT_PAIRS.map((p) => ({
106
+ ...p,
107
+ ratio: Math.round(contrast(p.fg, p.bg) * 100) / 100,
108
+ passes: contrast(p.fg, p.bg) >= p.need,
109
+ }));
110
+
111
+ /** The dark theme was exact as handed over and clears AA - this proves it still does. */
112
+ export const DARK_PAIRS = () => {
113
+ const D = PALETTE.dark;
114
+ const dthead = over("#ffffff", D.card, 0.02);
115
+ return [
116
+ { what: "muted on a card", fg: D.muted, bg: D.card, need: 4.5 },
117
+ { what: "muted on the page background", fg: D.muted, bg: D.bg, need: 4.5 },
118
+ { what: "muted on a table header band", fg: D.muted, bg: dthead, need: 4.5 },
119
+ { what: "muted on the sidebar", fg: D.muted, bg: D.sidebar, need: 4.5 },
120
+ { what: "text on a card", fg: D.text, bg: D.card, need: 4.5 },
121
+ { what: "text2 on a card", fg: D.text2, bg: D.card, need: 4.5 },
122
+ { what: "ok on its chip", fg: D.ok, bg: over("#41c795", D.card, 0.12), need: 4.5 },
123
+ { what: "warn on its chip", fg: D.warn, bg: over("#dcaf4e", D.card, 0.13), need: 4.5 },
124
+ { what: "err on its chip", fg: D.err, bg: over("#e57373", D.card, 0.12), need: 4.5 },
125
+ { what: "info on its chip", fg: D.info, bg: over("#7da6ec", D.card, 0.12), need: 4.5 },
126
+ { what: "accent-text on accent-soft over the sidebar", fg: D.accentText, bg: over(D.accent, D.sidebar, 0.12), need: 4.5 },
127
+ { what: "accent-text on accent-soft over a card", fg: D.accentText, bg: over(D.accent, D.card, 0.12), need: 4.5 },
128
+ ].map((p) => ({ ...p, ratio: Math.round(contrast(p.fg, p.bg) * 100) / 100, passes: contrast(p.fg, p.bg) >= p.need }));
129
+ };
130
+
131
+ if (process.argv[1]?.endsWith("theme-aa-ledger.ts")) {
132
+ const row = (r: { what: string; fg: string; bg: string; ratio: number; need: number; passes: boolean }) =>
133
+ `${r.passes ? "PASS" : "FAIL"} ${String(r.ratio).padStart(5)}:1 / ${r.need} ${r.fg} on ${r.bg} ${r.what}`;
134
+ console.log("LIGHT");
135
+ for (const r of measure()) console.log(" " + row(r));
136
+ console.log("\nDARK");
137
+ for (const r of DARK_PAIRS()) console.log(" " + row(r));
138
+ const f = measure().filter((r) => !r.passes).length;
139
+ console.log(`\n${f} of ${LIGHT_PAIRS.length} light pairs below AA; ${DARK_PAIRS().filter((r) => !r.passes).length} dark.`);
140
+ void LIGHT_THEME;
141
+ void DARK_THEME;
142
+ }