@7365admin1/layer-common 3.2.1 → 3.2.2-staging.101
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/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/.changeset/camera-wall-plain-english.md +60 -0
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/.changeset/dark-primary-contrast.md +39 -0
- package/.changeset/dashboard-dark-theme-contrast.md +15 -0
- package/.changeset/notification-preferences.md +17 -0
- package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
- package/.changeset/service-provider-invitation-actions.md +28 -0
- package/assets/css/primitives.css +680 -0
- package/assets/css/screens.css +541 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +220 -0
- package/components/AccessCardQrTagging.vue +1 -1
- package/components/AccessManagement.vue +53 -72
- package/components/AppButton.vue +52 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +35 -0
- package/components/AppField.vue +51 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +203 -0
- package/components/AreaChecklistHistoryLogs.vue +51 -44
- package/components/AreaChecklistHistoryMain.vue +6 -2
- package/components/AreaMain.vue +9 -17
- package/components/AttendanceMain.vue +4 -3
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BuildingManagement/buildings.vue +27 -38
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BulletinBoardManagement.vue +23 -10
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/CardToolbar.vue +37 -0
- package/components/CleaningScheduleMain.vue +29 -46
- package/components/ClientMain.vue +90 -78
- package/components/DashboardEmptyState.vue +11 -1
- package/components/DashboardMain.vue +349 -275
- package/components/DashboardPanel.vue +1 -1
- package/components/DocumentManagement.vue +129 -93
- package/components/EntryPassInformation.vue +4 -4
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +4 -6
- package/components/EquipmentManagementMain.vue +9 -7
- package/components/FeedbackMain.vue +42 -56
- package/components/FormDialog.vue +60 -21
- package/components/HidAccessLogDashboard.vue +119 -44
- package/components/HidCameraCaptureDialog.vue +199 -0
- package/components/HidIntercomManagement.vue +825 -202
- package/components/HidQrCodeConfiguration.vue +927 -93
- package/components/HidServiceSettingsPanel.vue +20 -1
- package/components/HidUserEnrollment.vue +454 -106
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +289 -37
- package/components/Layout/NavigationDrawer.vue +340 -30
- package/components/ManageChecklistMain.vue +17 -17
- package/components/MemberMain.vue +79 -60
- package/components/MyAttendanceMain.vue +3 -5
- package/components/NavigationItem.vue +9 -0
- package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
- package/components/NotificationSettings.vue +361 -0
- package/components/OnlineFormConfigurationForm.vue +620 -224
- package/components/OnlineFormFill.vue +36 -8
- package/components/OnlineFormsConfiguration.vue +7 -3
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +104 -45
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/RolePermissionMain.vue +184 -91
- package/components/ScanVisitorQRCode.vue +35 -7
- package/components/ScheduleAreaMain.vue +15 -49
- package/components/ScheduleTaskMain.vue +23 -26
- package/components/ScheduleTastTicketMain.vue +18 -43
- package/components/ServiceProviderInvitationPrompt.vue +150 -0
- package/components/ServiceProviderMain.vue +346 -157
- package/components/SiteSettings.vue +25 -10
- package/components/SiteSettingsWorkOrder.vue +61 -30
- package/components/SpecificAttr.vue +10 -2
- package/components/StatusChip.vue +93 -0
- package/components/StockCard.vue +17 -12
- package/components/SwitchContext.vue +33 -1
- package/components/TableHygiene.vue +29 -46
- package/components/TableList.vue +1 -1
- package/components/TableListSecondary.vue +1 -1
- package/components/TableMain.vue +161 -77
- package/components/TableV3.vue +61 -66
- package/components/UnitMain.vue +9 -17
- package/components/VehicleManagement.vue +20 -9
- package/components/VisitorForm.vue +294 -78
- package/components/VisitorManagement.vue +221 -71
- package/components/VisitorSocketPopUp.vue +56 -2
- package/components/WorkOrder/Main.vue +42 -51
- package/composables/useComment.ts +3 -3
- package/composables/useHidAmico.ts +151 -0
- package/composables/useHidNavigation.ts +0 -7
- package/composables/useLocalAuth.ts +9 -36
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useOrg.ts +1 -1
- package/composables/useRole.ts +22 -0
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +336 -0
- package/composables/useSiteSettings.ts +50 -0
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/composables/useVisitorSocket.ts +26 -0
- package/composables/useWebUsb.ts +127 -37
- package/nuxt.config.ts +31 -0
- package/package.json +4 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/pages/notification-settings.vue +19 -0
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +68 -9
- package/test/visitor-socket.test.mjs +36 -0
- package/types/org.d.ts +2 -0
- package/types/site.d.ts +71 -0
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/status.test.ts +77 -0
- package/utils/status.ts +90 -0
- package/utils/theme.test.ts +534 -0
- package/utils/theme.ts +294 -0
- package/components/HidIdentityMapping.vue +0 -975
- package/middleware/member.ts +0 -4
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { DARK_THEME, LIGHT_THEME, PALETTE, type TPalette } from "./theme.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The dark theme was shipped once with `primary` chosen for the one place it
|
|
8
|
+
* was a fill, which left the twenty-six places it is a sentence at 1.80:1.
|
|
9
|
+
* These are the measurements that would have caught it.
|
|
10
|
+
*
|
|
11
|
+
* WCAG 2.1 relative luminance and contrast ratio, nothing else.
|
|
12
|
+
*/
|
|
13
|
+
const luminance = (hex: string): number => {
|
|
14
|
+
const channels = [0, 2, 4]
|
|
15
|
+
.map((i) => parseInt(hex.slice(1 + i, 3 + i), 16) / 255)
|
|
16
|
+
.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
|
17
|
+
|
|
18
|
+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const contrast = (a: string, b: string): number => {
|
|
22
|
+
const [x, y] = [luminance(a), luminance(b)];
|
|
23
|
+
|
|
24
|
+
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** `fg` at `alpha` composited over `bg` - an overlay is not its raw value. */
|
|
28
|
+
const over = (fg: string, bg: string, alpha: number): string =>
|
|
29
|
+
"#" +
|
|
30
|
+
[0, 2, 4]
|
|
31
|
+
.map((i) =>
|
|
32
|
+
Math.round(
|
|
33
|
+
parseInt(fg.slice(1 + i, 3 + i), 16) * alpha +
|
|
34
|
+
parseInt(bg.slice(1 + i, 3 + i), 16) * (1 - alpha)
|
|
35
|
+
)
|
|
36
|
+
.toString(16)
|
|
37
|
+
.padStart(2, "0")
|
|
38
|
+
)
|
|
39
|
+
.join("");
|
|
40
|
+
|
|
41
|
+
const WHITE = "#FFFFFF";
|
|
42
|
+
const dark = DARK_THEME.colors;
|
|
43
|
+
const light = LIGHT_THEME.colors;
|
|
44
|
+
|
|
45
|
+
/** Sanity check on the measuring tape before it is used to judge anything. */
|
|
46
|
+
test("contrast is measured the way WCAG measures it", () => {
|
|
47
|
+
assert.equal(contrast(WHITE, "#000000").toFixed(2), "21.00");
|
|
48
|
+
assert.equal(contrast("#777777", WHITE).toFixed(2), "4.48");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("dark primary is readable as text on the page, a card and a menu", () => {
|
|
52
|
+
assert.ok(
|
|
53
|
+
contrast(dark.primary, dark.background) >= 4.5,
|
|
54
|
+
`primary on the page: ${contrast(dark.primary, dark.background).toFixed(2)}`
|
|
55
|
+
);
|
|
56
|
+
assert.ok(
|
|
57
|
+
contrast(dark.primary, dark.surface) >= 4.5,
|
|
58
|
+
`primary on a card: ${contrast(dark.primary, dark.surface).toFixed(2)}`
|
|
59
|
+
);
|
|
60
|
+
assert.ok(
|
|
61
|
+
contrast(dark.primary, dark["surface-bright"]) >= 4.5,
|
|
62
|
+
`primary on surface-bright: ${contrast(
|
|
63
|
+
dark.primary,
|
|
64
|
+
dark["surface-bright"]
|
|
65
|
+
).toFixed(2)}`
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("light primary is readable as text on white", () => {
|
|
70
|
+
assert.ok(contrast(light.primary, WHITE) >= 4.5);
|
|
71
|
+
assert.ok(contrast(light["text-primary"], WHITE) >= 4.5);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("text-primary is readable on the surface each theme puts it on", () => {
|
|
75
|
+
assert.ok(contrast(dark["text-primary"], dark.background) >= 4.5);
|
|
76
|
+
assert.ok(contrast(dark["text-primary"], dark.surface) >= 4.5);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The drawer is the reason `primary` was moved in the first place. If someone
|
|
81
|
+
* points it back at `primary` this fails, because a foreground colour cannot
|
|
82
|
+
* carry the drawer's label.
|
|
83
|
+
*/
|
|
84
|
+
test("the nav drawer fill carries its own label in both themes", () => {
|
|
85
|
+
assert.ok(
|
|
86
|
+
contrast(dark["on-brand-surface"], dark["brand-surface"]) >= 4.5,
|
|
87
|
+
`dark drawer label: ${contrast(
|
|
88
|
+
dark["on-brand-surface"],
|
|
89
|
+
dark["brand-surface"]
|
|
90
|
+
).toFixed(2)}`
|
|
91
|
+
);
|
|
92
|
+
assert.ok(
|
|
93
|
+
contrast(light["on-brand-surface"], light["brand-surface"]) >= 4.5,
|
|
94
|
+
`light drawer label: ${contrast(
|
|
95
|
+
light["on-brand-surface"],
|
|
96
|
+
light["brand-surface"]
|
|
97
|
+
).toFixed(2)}`
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The defect this replaced: `brand-surface` was the same deep navy in both
|
|
103
|
+
* themes, so the drawer was the one panel the theme switch did not reach. A
|
|
104
|
+
* light-theme surface has to be light, a dark-theme surface has to be dark,
|
|
105
|
+
* and if the two are ever within 3:1 of each other the switch is invisible on
|
|
106
|
+
* the drawer again.
|
|
107
|
+
*/
|
|
108
|
+
test("the drawer belongs to its theme, and the switch visibly changes it", () => {
|
|
109
|
+
assert.ok(
|
|
110
|
+
contrast(light["brand-surface"], WHITE) < 1.5,
|
|
111
|
+
"the light drawer must be a light surface, not a dark slab on a white page"
|
|
112
|
+
);
|
|
113
|
+
assert.ok(
|
|
114
|
+
contrast(dark["brand-surface"], dark.background) < 1.5,
|
|
115
|
+
"the dark drawer must sit with the dark page, not float above it"
|
|
116
|
+
);
|
|
117
|
+
assert.ok(
|
|
118
|
+
contrast(light["brand-surface"], dark["brand-surface"]) >= 3,
|
|
119
|
+
`light and dark drawers only ${contrast(
|
|
120
|
+
light["brand-surface"],
|
|
121
|
+
dark["brand-surface"]
|
|
122
|
+
).toFixed(2)} apart - the switch would look like it does nothing`
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The drawer's fill is deliberately close to the page, so the edge is the
|
|
128
|
+
* boundary and it is the thing that has to reach 3:1. 0.45 is the value
|
|
129
|
+
* `NavigationDrawer.vue` sets on itself; the theme-wide `border-opacity` is
|
|
130
|
+
* left alone.
|
|
131
|
+
*/
|
|
132
|
+
test("the drawer's own edge clears the 3:1 a boundary wants", () => {
|
|
133
|
+
const edge = (fg: string, bg: string) => over(fg, bg, 0.45);
|
|
134
|
+
|
|
135
|
+
assert.ok(
|
|
136
|
+
contrast(edge("#000000", light["brand-surface"]), WHITE) >= 3,
|
|
137
|
+
`light drawer edge vs page: ${contrast(
|
|
138
|
+
edge("#000000", light["brand-surface"]),
|
|
139
|
+
WHITE
|
|
140
|
+
).toFixed(2)}`
|
|
141
|
+
);
|
|
142
|
+
assert.ok(
|
|
143
|
+
contrast(edge(WHITE, dark["brand-surface"]), dark.background) >= 3,
|
|
144
|
+
`dark drawer edge vs page: ${contrast(
|
|
145
|
+
edge(WHITE, dark["brand-surface"]),
|
|
146
|
+
dark.background
|
|
147
|
+
).toFixed(2)}`
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** The active item is named by colour, so `primary` has to be readable on it. */
|
|
152
|
+
test("the active nav item is readable on the drawer in both themes", () => {
|
|
153
|
+
assert.ok(
|
|
154
|
+
contrast(light.primary, light["brand-surface"]) >= 4.5,
|
|
155
|
+
`light: ${contrast(light.primary, light["brand-surface"]).toFixed(2)}`
|
|
156
|
+
);
|
|
157
|
+
assert.ok(
|
|
158
|
+
contrast(dark.primary, dark["brand-surface"]) >= 4.5,
|
|
159
|
+
`dark: ${contrast(dark.primary, dark["brand-surface"]).toFixed(2)}`
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `v-list-subheader` is the one thing in the drawer that does NOT inherit the
|
|
165
|
+
* drawer's label colour - Vuetify paints it `on-surface` at medium emphasis.
|
|
166
|
+
* That is why apps used to hardcode `text-white` on it. It has to work
|
|
167
|
+
* unaided now, or those hardcodes come back.
|
|
168
|
+
*/
|
|
169
|
+
test("an unstyled subheader is readable on the drawer in both themes", () => {
|
|
170
|
+
assert.ok(
|
|
171
|
+
contrast(over("#000000", light["brand-surface"], 0.6), light["brand-surface"]) >= 4.5
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
contrast(over(WHITE, dark["brand-surface"], 0.6), dark["brand-surface"]) >= 4.5
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("primary-button carries a white label in both themes", () => {
|
|
179
|
+
assert.ok(contrast(WHITE, dark["primary-button"]) >= 4.5);
|
|
180
|
+
assert.ok(contrast(WHITE, light["primary-button"]) >= 4.5);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `primary` is still a fill in a v-btn, a v-chip, a v-alert and a tab bar. The
|
|
185
|
+
* comment above `primary` used to claim Vuetify derives black for `on-primary`;
|
|
186
|
+
* it derives WHITE, and white on this blue is 2.68:1. Nothing measured it, so
|
|
187
|
+
* it shipped. This is that measurement.
|
|
188
|
+
*/
|
|
189
|
+
test("a filled primary surface carries a readable label in both themes", () => {
|
|
190
|
+
assert.equal(DARK_THEME.colors["on-primary"], dark.background);
|
|
191
|
+
assert.ok(contrast(dark["on-primary"], dark.primary) >= 4.5);
|
|
192
|
+
assert.ok(contrast(WHITE, dark.primary) < 4.5, "white here is the defect");
|
|
193
|
+
|
|
194
|
+
// Light derives its own and it passes; measure it rather than assume.
|
|
195
|
+
assert.ok(contrast(WHITE, light.primary) >= 4.5);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Both themes correct the same two Vuetify defaults, and a value that drifts
|
|
200
|
+
* apart between them is a bug nobody sees until they flip the switch.
|
|
201
|
+
*/
|
|
202
|
+
/**
|
|
203
|
+
* Both opacities are applied to `on-surface`, which is now the design's ink
|
|
204
|
+
* rather than pure black - so an opacity that was right for black is not right
|
|
205
|
+
* for this. These hold the corrected values in place, measured against the ink
|
|
206
|
+
* they are actually applied to.
|
|
207
|
+
*/
|
|
208
|
+
test("disabled text is no paler than it was before the palette changed", () => {
|
|
209
|
+
assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.62);
|
|
210
|
+
assert.equal(DARK_THEME.variables["disabled-opacity"], 0.62);
|
|
211
|
+
|
|
212
|
+
// The bar is the previous rendering, not 4.5: Vuetify multiplies its own
|
|
213
|
+
// field alpha on top of this, which is Phase 3's problem to unpick.
|
|
214
|
+
const wasLight = contrast(over("#000000", WHITE, 0.55), WHITE);
|
|
215
|
+
const nowLight = contrast(over(PALETTE.light.text, WHITE, 0.62), WHITE);
|
|
216
|
+
assert.ok(
|
|
217
|
+
nowLight >= wasLight,
|
|
218
|
+
`disabled light went backwards: ${wasLight.toFixed(2)} -> ${nowLight.toFixed(2)}`
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const nowDark = contrast(
|
|
222
|
+
over(PALETTE.dark.text, PALETTE.dark.card, 0.62),
|
|
223
|
+
PALETTE.dark.card
|
|
224
|
+
);
|
|
225
|
+
assert.ok(nowDark >= 4.5, `disabled dark: ${nowDark.toFixed(2)}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* THE REGRESSION THIS CAUGHT. `text-medium-emphasis` is `on-surface` at this
|
|
230
|
+
* opacity, and it is roughly two hundred lines of secondary text across eleven
|
|
231
|
+
* applications. At Vuetify's 0.6 the new ink lands at 4.41:1 on the page.
|
|
232
|
+
*/
|
|
233
|
+
test("medium-emphasis text clears AA on every surface, in both themes", () => {
|
|
234
|
+
for (const [name, p] of [
|
|
235
|
+
["light", PALETTE.light],
|
|
236
|
+
["dark", PALETTE.dark],
|
|
237
|
+
] as const) {
|
|
238
|
+
const alpha = (name === "light" ? LIGHT_THEME : DARK_THEME).variables[
|
|
239
|
+
"medium-emphasis-opacity"
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
for (const bg of [p.card, p.bg, p.sidebar]) {
|
|
243
|
+
const ratio = contrast(over(p.text, bg, alpha), bg);
|
|
244
|
+
assert.ok(
|
|
245
|
+
ratio >= 4.5,
|
|
246
|
+
`${name} medium emphasis on ${bg}: ${ratio.toFixed(2)} at alpha ${alpha}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the dark divider clears the 3:1 a boundary wants", () => {
|
|
253
|
+
// Derived, not hardcoded: this used to be a literal "#62666a" with a comment
|
|
254
|
+
// saying it was white at 0.35 over the dark page, and it stopped being that
|
|
255
|
+
// the moment the page colour moved.
|
|
256
|
+
const border = over(WHITE, dark.background, 0.35);
|
|
257
|
+
|
|
258
|
+
assert.equal(DARK_THEME.variables["border-opacity"], 0.35);
|
|
259
|
+
assert.ok(contrast(border, dark.background) >= 3);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/* ==================================================================== */
|
|
263
|
+
/* THE REDESIGN PALETTE. */
|
|
264
|
+
/* */
|
|
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. */
|
|
272
|
+
/* ==================================================================== */
|
|
273
|
+
|
|
274
|
+
const L = PALETTE.light;
|
|
275
|
+
const D = PALETTE.dark;
|
|
276
|
+
|
|
277
|
+
/** The soft chip backgrounds, as the design states them - literally. */
|
|
278
|
+
const SOFT = {
|
|
279
|
+
light: {
|
|
280
|
+
ok: ["#0f8a62", 0.1],
|
|
281
|
+
warn: ["#be8c19", 0.13],
|
|
282
|
+
err: ["#cf4b4b", 0.1],
|
|
283
|
+
info: ["#3b6fd4", 0.1],
|
|
284
|
+
},
|
|
285
|
+
dark: {
|
|
286
|
+
ok: ["#41c795", 0.12],
|
|
287
|
+
warn: ["#dcaf4e", 0.13],
|
|
288
|
+
err: ["#e57373", 0.12],
|
|
289
|
+
info: ["#7da6ec", 0.12],
|
|
290
|
+
},
|
|
291
|
+
} as const;
|
|
292
|
+
|
|
293
|
+
/** `--thead` is a 2% ink wash over the card, not a colour of its own. */
|
|
294
|
+
const thead = (p: TPalette) =>
|
|
295
|
+
over(p === L ? "#14161a" : WHITE, p.card, 0.02);
|
|
296
|
+
|
|
297
|
+
const soft = (p: TPalette, key: "ok" | "warn" | "err" | "info", on: string) => {
|
|
298
|
+
const [hex, alpha] = SOFT[p === L ? "light" : "dark"][key];
|
|
299
|
+
return over(hex, on, alpha);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const accentSoft = (p: TPalette, on: string) => over(p.accent, on, 0.12);
|
|
303
|
+
|
|
304
|
+
for (const [name, p] of [
|
|
305
|
+
["light", L],
|
|
306
|
+
["dark", D],
|
|
307
|
+
] as const) {
|
|
308
|
+
/**
|
|
309
|
+
* `--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.
|
|
313
|
+
*/
|
|
314
|
+
test(`${name}: muted text is readable on every surface it is used on`, () => {
|
|
315
|
+
for (const [where, bg] of [
|
|
316
|
+
["a card", p.card],
|
|
317
|
+
["the page", p.bg],
|
|
318
|
+
["a table header", thead(p)],
|
|
319
|
+
["the sidebar", p.sidebar],
|
|
320
|
+
] as const) {
|
|
321
|
+
assert.ok(
|
|
322
|
+
contrast(p.muted, bg) >= 4.5,
|
|
323
|
+
`${name} muted on ${where}: ${contrast(p.muted, bg).toFixed(2)}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test(`${name}: primary and secondary text clear AA on every surface`, () => {
|
|
329
|
+
for (const fg of [p.text, p.text2]) {
|
|
330
|
+
for (const bg of [p.card, p.bg, p.sidebar]) {
|
|
331
|
+
assert.ok(
|
|
332
|
+
contrast(fg, bg) >= 4.5,
|
|
333
|
+
`${name} ${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A status chip is its own soft background, so the label is NOT read on the
|
|
341
|
+
* card - measuring it against white flatters it by about half a point.
|
|
342
|
+
*/
|
|
343
|
+
test(`${name}: every status chip's label is readable on its own chip`, () => {
|
|
344
|
+
for (const [key, fg] of [
|
|
345
|
+
["ok", p.ok],
|
|
346
|
+
["warn", p.warn],
|
|
347
|
+
["err", p.err],
|
|
348
|
+
["info", p.info],
|
|
349
|
+
] as const) {
|
|
350
|
+
const bg = soft(p, key, p.card);
|
|
351
|
+
assert.ok(
|
|
352
|
+
contrast(fg, bg) >= 4.5,
|
|
353
|
+
`${name} ${key} chip: ${contrast(fg, bg).toFixed(2)}`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/** The same four also appear as bare text: status dots, chart labels, the
|
|
359
|
+
* red Logout row in the profile dropdown. */
|
|
360
|
+
test(`${name}: the status colours are readable as plain text on a card`, () => {
|
|
361
|
+
for (const fg of [p.ok, p.warn, p.err, p.info]) {
|
|
362
|
+
assert.ok(
|
|
363
|
+
contrast(fg, p.card) >= 4.5,
|
|
364
|
+
`${name} ${fg} on a card: ${contrast(fg, p.card).toFixed(2)}`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The active sidebar item is `--accent-text` on `--accent-soft`, and
|
|
371
|
+
* `--accent-soft` is transparent - so it takes the colour of whatever is
|
|
372
|
+
* behind it. Behind it is the SIDEBAR, which is not the card. Solving this
|
|
373
|
+
* against the card gives 4.50:1 and ships a 4.35:1 sidebar.
|
|
374
|
+
*/
|
|
375
|
+
test(`${name}: accent text is readable on accent-soft over BOTH surfaces`, () => {
|
|
376
|
+
for (const [where, under] of [
|
|
377
|
+
["the sidebar", p.sidebar],
|
|
378
|
+
["a card", p.card],
|
|
379
|
+
] as const) {
|
|
380
|
+
const bg = accentSoft(p, under);
|
|
381
|
+
assert.ok(
|
|
382
|
+
contrast(p.accentText, bg) >= 4.5,
|
|
383
|
+
`${name} accent-text on accent-soft over ${where}: ${contrast(
|
|
384
|
+
p.accentText,
|
|
385
|
+
bg
|
|
386
|
+
).toFixed(2)}`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
assert.ok(contrast(p.accentText, p.card) >= 4.5, "as a plain link");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* THE REASON `--accent-strong` EXISTS. If someone deletes it and points the
|
|
395
|
+
* primary button back at `--accent`, the second assertion is what fails, and
|
|
396
|
+
* the message says why rather than just reporting a number.
|
|
397
|
+
*/
|
|
398
|
+
test(`${name}: a white label sits on accent-strong, never on accent`, () => {
|
|
399
|
+
assert.ok(
|
|
400
|
+
contrast(WHITE, p.accentStrong) >= 4.5,
|
|
401
|
+
`white on accent-strong: ${contrast(WHITE, p.accentStrong).toFixed(2)}`
|
|
402
|
+
);
|
|
403
|
+
assert.ok(
|
|
404
|
+
contrast(WHITE, p.accent) < 4.5,
|
|
405
|
+
"white on --accent passes now? then --accent-strong is redundant - " +
|
|
406
|
+
"re-measure before deleting it"
|
|
407
|
+
);
|
|
408
|
+
// The fill still has to be findable against the card it sits on.
|
|
409
|
+
assert.ok(
|
|
410
|
+
contrast(p.accentStrong, p.card) >= 3,
|
|
411
|
+
`accent-strong against a card: ${contrast(p.accentStrong, p.card).toFixed(2)}`
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** Non-text uses of the accent only owe 3:1, and they keep the design value. */
|
|
416
|
+
test(`${name}: the accent clears 3:1 where it is a line rather than a word`, () => {
|
|
417
|
+
assert.ok(contrast(p.accent, p.card) >= 3, "chart line / tab underline");
|
|
418
|
+
assert.ok(contrast(WHITE, p.accent) >= 3, "the toggle knob on its on state");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Vuetify DERIVES the label on a filled colour and its derivation picks
|
|
423
|
+
* white, which on the dark palette is 2.04:1 to 2.99:1. Every one is set
|
|
424
|
+
* explicitly in `theme.ts`; this is what proves they were all set.
|
|
425
|
+
*/
|
|
426
|
+
test(`${name}: every filled semantic colour carries a readable label`, () => {
|
|
427
|
+
const t = p === L ? LIGHT_THEME : DARK_THEME;
|
|
428
|
+
|
|
429
|
+
for (const key of ["primary", "success", "warning", "error", "info"]) {
|
|
430
|
+
const on = (t.colors as Record<string, string>)[`on-${key}`];
|
|
431
|
+
const fill = (t.colors as Record<string, string>)[key];
|
|
432
|
+
|
|
433
|
+
assert.ok(on, `on-${key} is not set - Vuetify would guess, and it guesses white`);
|
|
434
|
+
assert.ok(
|
|
435
|
+
contrast(on, fill) >= 4.5,
|
|
436
|
+
`${name} on-${key}: ${contrast(on, fill).toFixed(2)}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
assert.ok(
|
|
441
|
+
contrast(t.colors["on-primary-button"], t.colors["primary-button"]) >= 4.5
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* "KEEP THE LOOK" IS HALF THE INSTRUCTION, AND IT IS THE HALF A CONTRAST TEST
|
|
448
|
+
* CANNOT CATCH ON ITS OWN - darkening everything until it passes would satisfy
|
|
449
|
+
* every test above and would not be the design. These are the values that were
|
|
450
|
+
* NOT the problem, pinned to what the handoff says.
|
|
451
|
+
*/
|
|
452
|
+
test("the design's own values are unchanged wherever they already passed", () => {
|
|
453
|
+
assert.equal(L.accent, "#5b8def", "the brand accent is the design's, exactly");
|
|
454
|
+
assert.equal(D.accent, "#5b8def");
|
|
455
|
+
|
|
456
|
+
assert.equal(L.bg, "#f6f6f2");
|
|
457
|
+
assert.equal(L.sidebar, "#fbfbf8");
|
|
458
|
+
assert.equal(L.card, "#ffffff");
|
|
459
|
+
assert.equal(L.border, "#e8e8e1");
|
|
460
|
+
assert.equal(L.text, "#191b1f");
|
|
461
|
+
assert.equal(L.text2, "#41454d");
|
|
462
|
+
|
|
463
|
+
// The dark theme passed AA as designed and therefore moves nowhere at all.
|
|
464
|
+
assert.deepEqual(
|
|
465
|
+
[D.bg, D.sidebar, D.card, D.border, D.text, D.text2, D.muted],
|
|
466
|
+
["#131418", "#17181d", "#1c1e24", "#2a2d35", "#eef0f3", "#c6cad2", "#868c98"],
|
|
467
|
+
"the dark palette needed no darkening - it should still be the handoff's"
|
|
468
|
+
);
|
|
469
|
+
assert.deepEqual(
|
|
470
|
+
[D.ok, D.warn, D.err, D.info, D.accentText],
|
|
471
|
+
["#41c795", "#dcaf4e", "#e57373", "#7da6ec", "#78a2f4"]
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
/**
|
|
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.
|
|
479
|
+
*/
|
|
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
|
+
];
|
|
489
|
+
|
|
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
|
+
assert.ok(shift > 1, `${name} did not move at all`);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* THE BRIDGE. Every design token in `assets/css/tokens.css` is an alias onto a
|
|
504
|
+
* `--v-theme-*`, so a token whose Vuetify colour does not exist resolves to
|
|
505
|
+
* nothing and paints transparent - silently, in eleven applications. This is
|
|
506
|
+
* the list that CSS file reads.
|
|
507
|
+
*/
|
|
508
|
+
test("every design token has a Vuetify colour behind it, in both themes", () => {
|
|
509
|
+
const required = [
|
|
510
|
+
"background", "surface", "sidebar", "border",
|
|
511
|
+
"text-primary", "text2", "muted",
|
|
512
|
+
"success", "warning", "error", "info",
|
|
513
|
+
"accent", "accent-strong", "accent-text",
|
|
514
|
+
"primary", "primary-button", "brand-surface",
|
|
515
|
+
];
|
|
516
|
+
|
|
517
|
+
for (const [name, t] of [["light", LIGHT_THEME], ["dark", DARK_THEME]] as const) {
|
|
518
|
+
for (const key of required) {
|
|
519
|
+
const value = (t.colors as Record<string, string>)[key];
|
|
520
|
+
assert.match(
|
|
521
|
+
value ?? "",
|
|
522
|
+
/^#[0-9a-f]{6}$/i,
|
|
523
|
+
`${name} is missing a usable colour for "${key}" - tokens.css aliases it`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
assert.deepEqual(
|
|
529
|
+
Object.keys(LIGHT_THEME.colors),
|
|
530
|
+
Object.keys(DARK_THEME.colors),
|
|
531
|
+
"a colour wired into one theme and forgotten in the other is how this " +
|
|
532
|
+
"file broke before"
|
|
533
|
+
);
|
|
534
|
+
});
|