@7365admin1/layer-common 3.2.2-staging.80 → 3.2.2-staging.82

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * THE GROUPED SIDEBAR - section labels, without touching the menu.
3
+ *
4
+ * The design draws the sidebar in labelled sections (OVERVIEW, SERVICE DESK,
5
+ * PROPERTY, SECURITY OPERATIONS, COMMUNITY, WORKFORCE, ADMINISTRATION). Each
6
+ * application builds its own menu array in its `layouts/default.vue`, gated
7
+ * item by item on permissions, and that array is the product's navigation.
8
+ *
9
+ * So this file adds a LABEL and nothing else:
10
+ *
11
+ * - the order of the menu is the order the application gave us. Nothing is
12
+ * moved, sorted, merged, hidden or renamed. A label is inserted when the
13
+ * section CHANGES as we walk that existing order, which means the label is
14
+ * always true of the items under it.
15
+ * - a title this map has never heard of inherits the section above it, so a
16
+ * new menu item added by any of the eleven applications appears exactly
17
+ * where its author put it, under the heading it follows, rather than
18
+ * disappearing or forcing a stray heading.
19
+ * - the first item always gets its section printed, even if that section is
20
+ * "" (nothing) - a leading blank label is simply not rendered.
21
+ *
22
+ * A section may therefore appear twice in one sidebar. That is deliberate: the
23
+ * applications do not all list their modules in the same order (Landscape puts
24
+ * Feedbacks after Equipment; Property Management interleaves five sections),
25
+ * and printing the heading again is honest, where suppressing it would leave a
26
+ * run of items sitting under a heading that is not theirs.
27
+ */
28
+
29
+ export const NAV_SECTIONS: Record<string, string> = {
30
+ // OVERVIEW
31
+ Dashboard: "OVERVIEW",
32
+ Home: "OVERVIEW",
33
+
34
+ // SERVICE DESK
35
+ "Service Providers": "SERVICE DESK",
36
+ Feedbacks: "SERVICE DESK",
37
+ "Work Orders": "SERVICE DESK",
38
+ "Online Forms": "SERVICE DESK",
39
+
40
+ // SERVICE DELIVERY - the five service applications' own working modules
41
+ "Cleaning Schedule": "SERVICE DELIVERY",
42
+ "Landscape Schedule": "SERVICE DELIVERY",
43
+ "Pest Schedule": "SERVICE DELIVERY",
44
+ "Pool Schedule": "SERVICE DELIVERY",
45
+ "Technician Schedule": "SERVICE DELIVERY",
46
+ Area: "SERVICE DELIVERY",
47
+ Unit: "SERVICE DELIVERY",
48
+ "Schedule Task": "SERVICE DELIVERY",
49
+
50
+ // EQUIPMENT
51
+ "Equipment Management": "EQUIPMENT",
52
+ "Equipment Items": "EQUIPMENT",
53
+
54
+ // PROPERTY
55
+ "Vehicle Mgmt": "PROPERTY",
56
+ "Building Mgmt": "PROPERTY",
57
+ "Visitor Mgmt": "PROPERTY",
58
+ "Pass & Key Mgmt": "PROPERTY",
59
+ "Facility Mgmt": "PROPERTY",
60
+ "People Mgmt": "PROPERTY",
61
+ "Document Mgmt": "PROPERTY",
62
+ "Access Mgmt": "PROPERTY",
63
+ "Event Mgmt": "PROPERTY",
64
+ "Emergency Contacts": "PROPERTY",
65
+ "Online Billing": "PROPERTY",
66
+ "SOA Mgmt": "PROPERTY",
67
+
68
+ // SECURITY OPERATIONS
69
+ "HID Face Reader": "SECURITY OPERATIONS",
70
+ "Daily Occurrence Books": "SECURITY OPERATIONS",
71
+ Manpower: "SECURITY OPERATIONS",
72
+ "Virtual Patrol": "SECURITY OPERATIONS",
73
+ "NFC Patrol": "SECURITY OPERATIONS",
74
+ "Robot Mgmt": "SECURITY OPERATIONS",
75
+ "Incident Reports": "SECURITY OPERATIONS",
76
+ CCTV: "SECURITY OPERATIONS",
77
+
78
+ // COMMUNITY
79
+ "Bulletin Board": "COMMUNITY",
80
+ "Bulletin Videos": "COMMUNITY",
81
+
82
+ // WORKFORCE
83
+ Attendance: "WORKFORCE",
84
+ "My Attendance": "WORKFORCE",
85
+
86
+ // ADMINISTRATION
87
+ Invitations: "ADMINISTRATION",
88
+ "SP Approvals": "ADMINISTRATION",
89
+ Members: "ADMINISTRATION",
90
+ "Roles & Permissions": "ADMINISTRATION",
91
+ Settings: "ADMINISTRATION",
92
+ "Site Settings": "ADMINISTRATION",
93
+ Organizations: "ADMINISTRATION",
94
+ Users: "ADMINISTRATION",
95
+ "Promo Codes": "ADMINISTRATION",
96
+
97
+ // ACCOUNT - `web-app-account` is a personal-settings application, not a site
98
+ "Personal Info": "ACCOUNT",
99
+ "Payment Methods": "ACCOUNT",
100
+
101
+ // SERVICES - the account application's list of the products a user can enter
102
+ "Property Management": "SERVICES",
103
+ Security: "SERVICES",
104
+ Hygiene: "SERVICES",
105
+ "Mechanical & Electrical": "SERVICES",
106
+ "Pest Control Services": "SERVICES",
107
+ "Landscaping Services": "SERVICES",
108
+ "Pool Maintenance Services": "SERVICES",
109
+ };
110
+
111
+ export type TSectionedNavItem<T> = { item: T; section: string };
112
+
113
+ /**
114
+ * Walks the menu IN THE ORDER GIVEN and tags each item with the section label
115
+ * to print above it, or "" for no label. Same length, same order, same objects.
116
+ */
117
+ export function withSections<T extends { title?: string }>(
118
+ items: T[]
119
+ ): Array<TSectionedNavItem<T>> {
120
+ let current = "";
121
+
122
+ return items.map((item, index) => {
123
+ const section = NAV_SECTIONS[String(item?.title ?? "")] ?? current;
124
+ const changed = index === 0 || section !== current;
125
+ current = section;
126
+ return { item, section: changed ? section : "" };
127
+ });
128
+ }
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { statusTone, statusToneClass } from "./status.ts";
5
+
6
+ test("the handoff's mapping, word for word", () => {
7
+ for (const s of ["Completed", "Paid", "Available", "Open"]) {
8
+ assert.equal(statusTone(s), "ok", s);
9
+ }
10
+
11
+ assert.equal(statusTone("Pending"), "warn");
12
+
13
+ for (const s of ["In Use", "Closed", "Checkout"]) {
14
+ assert.equal(statusTone(s), "err", s);
15
+ }
16
+
17
+ assert.equal(statusTone("Role"), "info");
18
+ });
19
+
20
+ /** The same status arrives spelled three ways from three endpoints. */
21
+ test("casing, spacing and underscores do not change a status's colour", () => {
22
+ for (const s of ["in use", "IN USE", "In_Use", "in-use", " In Use "]) {
23
+ assert.equal(statusTone(s), "err", s);
24
+ }
25
+ });
26
+
27
+ /**
28
+ * The important one. A status the design never named must NOT be guessed into
29
+ * a colour - a wrong green on a failed job reads as success. Neutral is
30
+ * visible, honest, and gets it noticed.
31
+ */
32
+ test("an unmapped status comes out neutral rather than guessed", () => {
33
+ for (const s of ["Cancelled", "Overdue", "Draft", "", null, undefined]) {
34
+ assert.equal(statusTone(s), "neutral", String(s));
35
+ }
36
+ });
37
+
38
+ test("the class name matches what tokens.css actually defines", () => {
39
+ assert.equal(statusToneClass("Paid"), "tone-ok");
40
+ assert.equal(statusToneClass("Whatever"), "tone-neutral");
41
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * THE SEMANTIC STATUS MAPPING, IN ONE PLACE.
3
+ *
4
+ * The design assigns a colour to a status WORD rather than to a module, so the
5
+ * same word has to look the same wherever it appears - "Open" on a Daily
6
+ * Occurrence Book and "Open" on a work order are the same green. Keeping the
7
+ * list here rather than in each screen is what makes that true, and is what
8
+ * Phases 3 and 4 read when they restyle the chips.
9
+ *
10
+ * Straight from the handoff:
11
+ * Completed / Paid / Available / Open -> ok
12
+ * Pending -> warn
13
+ * In Use / Closed / Checkout -> err
14
+ * Role and info tags -> info
15
+ *
16
+ * Anything not listed is `neutral` on purpose. A status this product has and
17
+ * the design did not name must NOT be guessed into a colour - a wrong green on
18
+ * a failed job is worse than a grey one - so it comes out neutral and visible,
19
+ * which is how it gets noticed and added.
20
+ */
21
+ export type TStatusTone = "ok" | "warn" | "err" | "info" | "neutral";
22
+
23
+ const TONES: Record<string, TStatusTone> = {
24
+ completed: "ok",
25
+ paid: "ok",
26
+ available: "ok",
27
+ open: "ok",
28
+
29
+ pending: "warn",
30
+
31
+ "in use": "err",
32
+ closed: "err",
33
+ checkout: "err",
34
+
35
+ role: "info",
36
+ info: "info",
37
+ };
38
+
39
+ /**
40
+ * The tone for a status word. Case- and space-insensitive, because the same
41
+ * status arrives as "In Use", "in_use" and "IN USE" from different endpoints.
42
+ */
43
+ export function statusTone(status: string | null | undefined): TStatusTone {
44
+ if (!status) return "neutral";
45
+
46
+ const key = String(status).trim().toLowerCase().replace(/[_-]+/g, " ");
47
+
48
+ return TONES[key] ?? "neutral";
49
+ }
50
+
51
+ /** The class `assets/css/tokens.css` paints that tone with. */
52
+ export function statusToneClass(status: string | null | undefined): string {
53
+ return `tone-${statusTone(status)}`;
54
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
- import { DARK_THEME, LIGHT_THEME } from "./theme.ts";
4
+ import { DARK_THEME, LIGHT_THEME, PALETTE, type TPalette } from "./theme.ts";
5
5
 
6
6
  /**
7
7
  * The dark theme was shipped once with `primary` chosen for the one place it
@@ -199,18 +199,336 @@ test("a filled primary surface carries a readable label in both themes", () => {
199
199
  * Both themes correct the same two Vuetify defaults, and a value that drifts
200
200
  * apart between them is a bug nobody sees until they flip the switch.
201
201
  */
202
- test("disabled text clears 4.5:1 on the surface each theme puts it on", () => {
203
- assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.55);
204
- assert.equal(DARK_THEME.variables["disabled-opacity"], 0.55);
205
- assert.ok(contrast(over("#000000", WHITE, 0.55), WHITE) >= 4.5);
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);
206
216
  assert.ok(
207
- contrast(over(WHITE, dark.background, 0.55), dark.background) >= 4.5
217
+ nowLight >= wasLight,
218
+ `disabled light went backwards: ${wasLight.toFixed(2)} -> ${nowLight.toFixed(2)}`
208
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
+ }
209
250
  });
210
251
 
211
252
  test("the dark divider clears the 3:1 a boundary wants", () => {
212
- const border = "#62666a"; // white at 0.35 over the dark page
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);
213
257
 
214
258
  assert.equal(DARK_THEME.variables["border-opacity"], 0.35);
215
259
  assert.ok(contrast(border, dark.background) >= 3);
216
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
+ });