@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.
- package/assets/css/primitives.css +221 -0
- package/assets/css/screens.css +76 -1
- package/assets/css/tokens.css +16 -0
- package/components/AccessCard/AvailableStats.vue +24 -16
- package/components/AppButton.vue +10 -1
- 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/StatusChip.vue +63 -0
- package/components/TableMain.vue +50 -1
- package/components/VisitorManagement.vue +20 -13
- 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"
|
|
@@ -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;
|
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",
|
|
@@ -613,15 +613,21 @@
|
|
|
613
613
|
class="d-flex align-center"
|
|
614
614
|
>
|
|
615
615
|
<strong>{{ label }}:</strong>
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
+
}
|