@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.
- package/assets/css/primitives.css +168 -0
- package/assets/css/screens.css +76 -1
- package/components/AccessCard/AvailableStats.vue +24 -16
- package/components/AppDateField.vue +15 -1
- package/components/AppField.vue +25 -0
- package/components/BuildingUnitFormEdit.vue +5 -1
- package/components/Card/DeleteConfirmation.vue +5 -2
- package/components/HidAccessLogDashboard.vue +288 -447
- package/components/HidIntercomManagement.vue +387 -521
- package/components/HidQrCodeConfiguration.vue +293 -369
- package/components/HidReaderManagement.vue +197 -320
- package/components/HidServiceSettingsPanel.vue +38 -26
- package/components/HidUserEnrollment.vue +239 -344
- package/components/Nfc/NFCTagForm.vue +5 -1
- package/components/TableMain.vue +50 -1
- package/components/VisitorsReportPreview.vue +4 -1
- package/package.json +1 -1
- package/tools/render-harness/baselines.json +95 -0
- package/tools/render-harness/harness-init.ps1 +4 -0
- package/utils/status.test.ts +33 -0
- package/utils/status.ts +36 -0
- package/utils/theme-aa-ledger.ts +142 -0
- package/utils/theme.test.ts +95 -34
- package/utils/theme.ts +55 -28
|
@@ -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="
|
|
78
|
+
color="primary-button"
|
|
75
79
|
@click="submit"
|
|
76
80
|
:loading="loading"
|
|
77
81
|
:disabled="!valid || loading || !hasChanges"
|
package/components/TableMain.vue
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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
|
@@ -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
|
package/utils/status.test.ts
CHANGED
|
@@ -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
|
+
}
|
package/utils/theme.test.ts
CHANGED
|
@@ -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
|
-
/*
|
|
266
|
-
/* AA
|
|
267
|
-
/*
|
|
268
|
-
/*
|
|
269
|
-
/*
|
|
270
|
-
/*
|
|
271
|
-
/*
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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.
|
|
311
|
-
*
|
|
312
|
-
* the
|
|
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
|
-
*
|
|
477
|
-
*
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
/**
|