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

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"
@@ -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",
@@ -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.121",
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
+ }
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
4
  import { DARK_THEME, LIGHT_THEME, PALETTE, type TPalette } from "./theme.ts";
5
+ import { DARK_PAIRS, measure } from "./theme-aa-ledger.ts";
5
6
 
6
7
  /**
7
8
  * The dark theme was shipped once with `primary` chosen for the one place it
@@ -262,13 +263,19 @@ test("the dark divider clears the 3:1 a boundary wants", () => {
262
263
  /* ==================================================================== */
263
264
  /* THE REDESIGN PALETTE. */
264
265
  /* */
265
- /* Measured against the design as handed over, the light theme failed */
266
- /* AA on 27 text pairs and the dark theme on 6. The owner's instruction */
267
- /* was "minimal darkening, keep the look", so what these tests hold in */
268
- /* place is BOTH halves of that: every pair a person reads clears AA, */
269
- /* AND the values that were not the problem are still exactly the ones */
270
- /* the design specifies. A later phase that "tidies" a colour back to */
271
- /* its handoff value, or drifts one away from it, fails here. */
266
+ /* The owner's decision on 2026-08-14 is 100% DESIGN FIDELITY over WCAG */
267
+ /* AA, reversing 2026-08-13's "minimal darkening". So the two themes */
268
+ /* are now held to two different standards, on purpose: */
269
+ /* */
270
+ /* DARK - exact as handed over AND fully AA. Asserted below. */
271
+ /* LIGHT - exact as handed over, 16 of 22 read pairs BELOW AA. Not */
272
+ /* asserted as passing; every ratio is PINNED to its measured */
273
+ /* value in `utils/theme-aa-ledger.ts` instead, so the cost is */
274
+ /* recorded rather than hidden and any drift - lighter OR */
275
+ /* darker - still fails the build and has to be declared. */
276
+ /* */
277
+ /* A later phase that "tidies" a light colour to make a contrast test */
278
+ /* green is exactly what the ledger test is here to catch. */
272
279
  /* ==================================================================== */
273
280
 
274
281
  const L = PALETTE.light;
@@ -301,15 +308,16 @@ const soft = (p: TPalette, key: "ok" | "warn" | "err" | "info", on: string) => {
301
308
 
302
309
  const accentSoft = (p: TPalette, on: string) => over(p.accent, on, 0.12);
303
310
 
304
- for (const [name, p] of [
305
- ["light", L],
306
- ["dark", D],
307
- ] as const) {
311
+ /*
312
+ * DARK ONLY. The light theme is measured by the ledger test further down, not
313
+ * asserted here - see the section note above.
314
+ */
315
+ for (const [name, p] of [["dark", D]] as const) {
308
316
  /**
309
317
  * `--muted` is the table header of every table in the product, the
310
- * breadcrumb, and every KPI sub-line. It failed on all four of its surfaces
311
- * in the light theme at 2.99-3.24:1, which is the single most-read text in
312
- * the design.
318
+ * breadcrumb, and every KPI sub-line. The dark theme's `#868c98` clears AA
319
+ * on all four of its surfaces as designed; the light theme's `#8b8f98` does
320
+ * not, at 2.99-3.24:1, and that is the ledger's headline entry.
313
321
  */
314
322
  test(`${name}: muted text is readable on every surface it is used on`, () => {
315
323
  for (const [where, bg] of [
@@ -473,30 +481,83 @@ test("the design's own values are unchanged wherever they already passed", () =>
473
481
  });
474
482
 
475
483
  /**
476
- * Minimal darkening means MINIMAL. Each light value that moved is checked to
477
- * still be recognisably the colour it was - if a later pass "fixes" contrast
478
- * by reaching for a different green, this is what objects.
484
+ * THE SIX THAT WERE DARKENED ON 2026-08-13 ARE BACK ON THE HANDOFF, EXACTLY.
485
+ * Hex equality, not a tolerance: "design-exact" has no tolerance.
479
486
  */
480
- test("the light values that moved are still the same colours", () => {
481
- const moved: Array<[string, string, string]> = [
482
- ["muted", "#8b8f98", L.muted],
483
- ["ok", "#0f8a62", L.ok],
484
- ["warn", "#a97a14", L.warn],
485
- ["err", "#cf4b4b", L.err],
486
- ["info", "#3b6fd4", L.info],
487
- ["accent-strong vs accent", "#5b8def", L.accentStrong],
488
- ];
487
+ test("the light palette is the handoff's, value for value", () => {
488
+ assert.equal(L.muted, "#8b8f98");
489
+ assert.equal(L.ok, "#0f8a62");
490
+ assert.equal(L.warn, "#a97a14");
491
+ assert.equal(L.err, "#cf4b4b");
492
+ assert.equal(L.info, "#3b6fd4");
493
+ // `color-mix(in oklab, #5b8def 75%, #191b1f)`, resolved in a browser.
494
+ assert.equal(L.accentText, "#4a6eb6");
495
+ });
489
496
 
490
- for (const [name, before, after] of moved) {
491
- const shift = contrast(before, after);
492
- assert.ok(
493
- shift < 1.9,
494
- `${name} moved from ${before} to ${after}, a ${shift.toFixed(
495
- 2
496
- )}:1 jump - that is a different colour, not a darkened one`
497
+ /**
498
+ * THE LEDGER. The light theme's known sub-AA pairs, each pinned to the ratio it
499
+ * actually measures. This is the "recorded, not hidden" half of the owner's
500
+ * decision: nobody can quietly darken a value to make a test green, and nobody
501
+ * can lighten one further without this failing and making them write it down.
502
+ *
503
+ * The numbers come from `utils/theme-aa-ledger.ts`, which is also runnable:
504
+ * node --experimental-strip-types utils/theme-aa-ledger.ts
505
+ */
506
+ test("the light theme's sub-AA pairs measure exactly what the ledger says", () => {
507
+ const EXPECTED: Record<string, number> = {
508
+ "muted on a card (table header text, sub-lines)": 3.24,
509
+ "muted on the page background": 2.99,
510
+ "muted on a table header band": 3.1,
511
+ "muted on the sidebar": 3.13,
512
+ "muted on a disabled control's hover fill": 2.71,
513
+ "ok label on its own chip": 3.82,
514
+ "warn label on its own chip": 3.38,
515
+ "err label on its own chip": 3.86,
516
+ "info label on its own chip": 4.2,
517
+ "ok as plain text on a card": 4.35,
518
+ "warn as plain text on a card": 3.83,
519
+ "err as plain text on a card (the Logout row)": 4.41,
520
+ "info as plain text on a card": 4.76,
521
+ "accent-text as a link on a card": 5,
522
+ "accent-text on accent-soft over the sidebar (active nav item)": 4.29,
523
+ "accent-text on accent-soft over a card (active tab, avatar initials)": 4.42,
524
+ "white on the filled success colour": 4.35,
525
+ "white on the filled warning colour": 3.83,
526
+ "white on the filled error colour": 4.41,
527
+ "white on the filled info colour": 4.76,
528
+ "white on primary-button (accent-strong) - NOT reverted": 4.57,
529
+ "text on a card": 17.24,
530
+ "text2 on a card": 9.62,
531
+ };
532
+
533
+ const measured = measure();
534
+ assert.equal(
535
+ measured.length,
536
+ Object.keys(EXPECTED).length,
537
+ "a pair was added to or removed from the ledger without updating this test"
538
+ );
539
+
540
+ for (const row of measured) {
541
+ assert.equal(
542
+ row.ratio,
543
+ EXPECTED[row.what],
544
+ `${row.what}: ledger says ${EXPECTED[row.what]}:1, measured ${row.ratio}:1`
497
545
  );
498
- assert.ok(shift > 1, `${name} did not move at all`);
499
546
  }
547
+
548
+ const failing = measured.filter((r) => !r.passes).length;
549
+ assert.equal(
550
+ failing,
551
+ 17,
552
+ `${failing} light pairs are below AA; the recorded, accepted count is 17. ` +
553
+ "If this moved, the owner's design-fidelity decision needs re-stating."
554
+ );
555
+ });
556
+
557
+ /** The dark theme carries no such debt, and that is worth asserting out loud. */
558
+ test("the dark theme has no sub-AA pairs at all", () => {
559
+ const bad = DARK_PAIRS().filter((r) => !r.passes);
560
+ assert.deepEqual(bad, [], "dark mode was exact AND compliant - keep it that way");
500
561
  });
501
562
 
502
563
  /**