@7365admin1/layer-common 3.2.1-staging.69 → 3.2.1-staging.71

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,39 @@
1
+ ---
2
+ "@7365admin1/layer-common": patch
3
+ ---
4
+
5
+ Split the dark theme's `primary` into a foreground colour and a fill, so text
6
+ using it is readable again.
7
+
8
+ Dark `primary` was set to the brand navy `#17506F` so the navigation drawer
9
+ would read as a surface. That is the right value for a fill — a white label on
10
+ it measures 8.70:1 — but Vuetify emits `.text-primary` from the same token, so
11
+ every `class="text-primary"` sentence and every `color="primary"` icon, spinner
12
+ and text button rendered navy on a dark background:
13
+
14
+ | | before | now |
15
+ |---|---|---|
16
+ | `primary` as text on the dark page `#0E1319` | 2.14:1 | **6.40:1** |
17
+ | `primary` as text on a dark card `#1B242F` | 1.80:1 | **5.38:1** |
18
+ | `primary` as text on `surface-bright` `#26313E`| 1.52:1 | **4.53:1** |
19
+
20
+ Dark `primary` is now `#5B9BE0`. That is not a new colour: it is the blue the
21
+ camera wall already draws its accents in (`--vms-accent`), so the two dark
22
+ surfaces in this product agree on one blue.
23
+
24
+ The navigation drawer no longer depends on `primary`. Both themes gain a
25
+ `brand-surface` colour — `#042134` light (identical to light `primary`, so
26
+ light mode renders exactly as before) and `#17506F` dark — and
27
+ `Layout/NavigationDrawer.vue` is painted with it. Its white label still reads
28
+ 16.51:1 light and 8.70:1 dark. `floating` was dropped from the drawer so it
29
+ draws its own edge: the fill is 2.14:1 against the dark page, under the 3:1 a
30
+ boundary wants, and the border at `border-opacity` 0.35 is 3.22:1.
31
+
32
+ Where `primary` is still used as a fill, Vuetify derives `on-primary` from it
33
+ and picks black, which reads at 7.21:1. What a light `primary` cannot carry is
34
+ a hardcoded white label, and a handful of app-side sites still set one — see
35
+ the pull request for the list.
36
+
37
+ The light theme is unchanged. The colours moved to `utils/theme.ts` and
38
+ `utils/theme.test.ts` now measures them, so a token cannot be moved for one use
39
+ and quietly broken for the other again.
@@ -1,9 +1,41 @@
1
1
  <!-- LayoutNavigationDrawer.vue -->
2
2
  <template>
3
- <v-navigation-drawer v-model="drawer" permanent floating class="bg-primary">
3
+ <!--
4
+ `bg-brand-surface`, not `bg-primary`: `primary` is a foreground colour on a
5
+ dark page (it has to carry `class="text-primary"` sentences at 4.5:1), and a
6
+ fill that carries a label cannot also be that. `brand-surface` is a real
7
+ surface in each theme - see `utils/theme.ts` - so the drawer belongs to the
8
+ application it is in rather than being the one panel the theme switch never
9
+ reached. Its label reads 15.43:1 light, 13.21:1 dark.
10
+
11
+ NO HARDCODED LABEL COLOUR ANYWHERE IN HERE. Vuetify paints the drawer with
12
+ `on-brand-surface` and the list inside it inherits that through
13
+ `bg-transparent`, which the drawer supplies to every `v-list` it contains.
14
+ A `text-white` in here (or in an app's `#action` / `#services` slot) is
15
+ white on a light rail at 1.13:1.
16
+
17
+ `floating` dropped so the drawer draws its own edge, and the three
18
+ opacities below are raised on this element only:
19
+
20
+ - `border-opacity` 0.45. A surface is a low-contrast panel by design
21
+ (1.13:1 light, 1.19:1 dark against the page), so the edge is what carries
22
+ the boundary: 3.27:1 light, 4.33:1 dark. The theme-wide value stays where
23
+ it is; raising that would redraw every table and card edge in eleven
24
+ applications.
25
+ - `hover-opacity` 0.08. Material's 0.04 is 1.11:1 on this rail, which is
26
+ not feedback. 0.08 is 1.17:1 light, 1.24:1 dark.
27
+ - `activated-opacity` 0.18. The active item is `primary` text on a
28
+ primary-tinted fill (see `NavigationItem.vue`); 0.12 left the fill at
29
+ 1.15:1, 0.18 is 1.44:1 light, 1.33:1 dark.
30
+ -->
31
+ <v-navigation-drawer
32
+ v-model="drawer"
33
+ permanent
34
+ class="bg-brand-surface brand-drawer"
35
+ >
4
36
  <v-list>
5
37
  <v-list-item>
6
- <v-list-item-title class="text-h6 text-white">
38
+ <v-list-item-title class="text-h6">
7
39
  {{ props.title || APP_NAME }}
8
40
  </v-list-item-title>
9
41
  </v-list-item>
@@ -91,3 +123,12 @@ const handleClick = (item: any) => {
91
123
  emit("navigate", item);
92
124
  };
93
125
  </script>
126
+
127
+ <style scoped>
128
+ /* Scoped to the drawer: none of these reach the rest of the application. */
129
+ .brand-drawer {
130
+ --v-border-opacity: 0.45;
131
+ --v-hover-opacity: 0.08;
132
+ --v-activated-opacity: 0.18;
133
+ }
134
+ </style>
@@ -1,9 +1,16 @@
1
+ <!--
2
+ `color="primary"` is applied by Vuetify only while the item is active, so the
3
+ page you are on is named by colour and not just by a 1.15:1 tint you cannot
4
+ see. `primary` reads 14.56:1 on the light rail and 5.38:1 on the dark one,
5
+ and it is the one brand colour that is a foreground in both themes.
6
+ -->
1
7
  <template>
2
8
  <v-list-group v-if="children && children.length">
3
9
  <template #activator="{ props: groupProps }">
4
10
  <v-list-item
5
11
  v-bind="groupProps"
6
12
  :prepend-icon="icon"
13
+ color="primary"
7
14
  class="text-subtitle-2"
8
15
  @click.stop="onParentClick"
9
16
  >
@@ -25,6 +32,7 @@
25
32
  v-else-if="routeTo"
26
33
  :prepend-icon="icon"
27
34
  :to="routeTo"
35
+ color="primary"
28
36
  class="text-subtitle-2"
29
37
  >
30
38
  {{ title }}
@@ -34,6 +42,7 @@
34
42
  v-else-if="props.link"
35
43
  :prepend-icon="icon"
36
44
  :href="props.link"
45
+ color="primary"
37
46
  class="text-subtitle-2"
38
47
  >
39
48
  {{ title }}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.1-staging.69",
5
+ "version": "3.2.1-staging.71",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -42,66 +42,7 @@ import {
42
42
  } from "vuetify-pro-tiptap";
43
43
  import "vuetify-pro-tiptap/style.css";
44
44
 
45
- /**
46
- * Contrast decisions live here rather than inline, because both themes need the
47
- * same two opacity corrections and a value that differs by accident between
48
- * light and dark is a bug nobody sees until somebody flips the switch.
49
- *
50
- * `disabled-opacity`: Material's 0.38 measures 2.68:1 on white, below the 4.5:1
51
- * a sentence needs, and disabled fields in this product carry sentences people
52
- * are expected to read. 0.55 is 4.74:1 on white and 6.21:1 on the dark page -
53
- * the same value the camera wall settled on, for the same reason.
54
- *
55
- * `border-opacity`: 0.12 measures 1.32:1, which is a divider you cannot see.
56
- * Dark goes to 0.35 (3.22:1) and carries the boundary properly. Light goes to
57
- * 0.26 (1.88:1) - visible, but short of the 3:1 a component boundary wants.
58
- * Reaching it on white needs 0.42, which turns every table and card edge into a
59
- * mid-grey grid across eleven applications; that is a change somebody should
60
- * look at before it ships, not one to slip in behind a contrast fix.
61
- */
62
- const LIGHT_THEME = {
63
- dark: false,
64
- colors: {
65
- primary: "#042134",
66
- "primary-button": "#1867C0",
67
- "text-primary": "#052439",
68
- },
69
- variables: {
70
- "disabled-opacity": 0.55,
71
- "border-opacity": 0.26,
72
- },
73
- };
74
-
75
- /**
76
- * Only the keys that must differ; Vuetify merges the rest from its stock dark.
77
- */
78
- const DARK_THEME = {
79
- dark: true,
80
- colors: {
81
- /**
82
- * Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
83
- * card, a dialog and the page behind them were one flat sheet. These are
84
- * 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
85
- * are tinted towards the brand navy rather than neutral grey, and the
86
- * divider opacity above is what actually draws the edges.
87
- */
88
- background: "#0E1319",
89
- surface: "#1B242F",
90
- /** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
91
- "surface-bright": "#26313E",
92
- "surface-light": "#26313E",
93
- /** The brand navy lifted until the nav drawer is a surface, not a hole. */
94
- primary: "#17506F",
95
- /** Unchanged on purpose: it already passes in both themes. */
96
- "primary-button": "#1867C0",
97
- /** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
98
- "text-primary": "#E8ECF1",
99
- },
100
- variables: {
101
- "disabled-opacity": 0.55,
102
- "border-opacity": 0.35,
103
- },
104
- };
45
+ import { LIGHT_THEME, DARK_THEME } from "../utils/theme";
105
46
 
106
47
  export default defineNuxtPlugin((app) => {
107
48
  const vuetify = createVuetify({
@@ -152,18 +93,8 @@ export default defineNuxtPlugin((app) => {
152
93
  * means the toggle, `v-theme-provider theme="light"`, the `plain-dark`
153
94
  * layout and all of those comparisons keep working, and start being right.
154
95
  *
155
- * Measured, not eyeballed - WCAG 2.1, alpha composited against the surface
156
- * each value actually sits on:
157
- *
158
- * text-primary on the page dark 1.18:1 -> 15.72:1
159
- * nav drawer fill vs the page dark 1.13:1 -> 2.14:1 (+ a real border)
160
- * white label in the nav drawer dark 3.12:1 -> 8.70:1
161
- * primary-button vs the page dark 3.34:1 -> 3.32:1 (unchanged)
162
- * white label on that button both 5.61:1 -> 5.61:1 (unchanged)
163
- * disabled text light 2.68:1 -> 4.74:1
164
- * disabled text dark 3.57:1 -> 6.21:1
165
- * divider / outline dark 1.38:1 -> 3.22:1
166
- * divider / outline light 1.32:1 -> 1.88:1
96
+ * The colours themselves, and every ratio they were chosen for, are in
97
+ * `utils/theme.ts`, which `utils/theme.test.ts` measures on every run.
167
98
  */
168
99
  theme: {
169
100
  defaultTheme: "light",
@@ -0,0 +1,201 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { DARK_THEME, LIGHT_THEME } 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
+ * Both themes correct the same two Vuetify defaults, and a value that drifts
185
+ * apart between them is a bug nobody sees until they flip the switch.
186
+ */
187
+ test("disabled text clears 4.5:1 on the surface each theme puts it on", () => {
188
+ assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.55);
189
+ assert.equal(DARK_THEME.variables["disabled-opacity"], 0.55);
190
+ assert.ok(contrast(over("#000000", WHITE, 0.55), WHITE) >= 4.5);
191
+ assert.ok(
192
+ contrast(over(WHITE, dark.background, 0.55), dark.background) >= 4.5
193
+ );
194
+ });
195
+
196
+ test("the dark divider clears the 3:1 a boundary wants", () => {
197
+ const border = "#62666a"; // white at 0.35 over the dark page
198
+
199
+ assert.equal(DARK_THEME.variables["border-opacity"], 0.35);
200
+ assert.ok(contrast(border, dark.background) >= 3);
201
+ });
package/utils/theme.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * THE TWO THEMES THIS PRODUCT SHIPS.
3
+ *
4
+ * They live here rather than inline in `plugins/vuetify.ts` so that
5
+ * `utils/theme.test.ts` can measure them. Every ratio quoted below is WCAG 2.1,
6
+ * alpha composited against the surface the value actually sits on, and the test
7
+ * re-measures the ones that matter on every run - this file has already been
8
+ * got wrong once by moving a colour for one use and forgetting the other.
9
+ *
10
+ * `disabled-opacity`: Material's 0.38 measures 2.68:1 on white, below the 4.5:1
11
+ * a sentence needs, and disabled fields in this product carry sentences people
12
+ * are expected to read. 0.55 is 4.74:1 on white and 6.21:1 on the dark page -
13
+ * the same value the camera wall settled on, for the same reason.
14
+ *
15
+ * `border-opacity`: 0.12 measures 1.32:1, which is a divider you cannot see.
16
+ * Dark goes to 0.35 (3.22:1) and carries the boundary properly. Light goes to
17
+ * 0.26 (1.88:1) - visible, but short of the 3:1 a component boundary wants.
18
+ * Reaching it on white needs 0.42, which turns every table and card edge into a
19
+ * mid-grey grid across eleven applications; that is a change somebody should
20
+ * look at before it ships, not one to slip in behind a contrast fix.
21
+ */
22
+
23
+ /**
24
+ * `primary` IS BOTH A FILL AND A FOREGROUND, AND ONE VALUE CANNOT SERVE BOTH.
25
+ *
26
+ * Vuetify emits `.bg-primary` and `.text-primary` from the same token, so the
27
+ * colour that fills the navigation drawer is also the colour of every
28
+ * `class="text-primary"` sentence, every `color="primary"` icon, spinner and
29
+ * text button, and the focused outline on every input. On a dark page those
30
+ * pull in opposite directions: a fill that carries a white label wants to be
31
+ * dark, and a foreground that is readable on a dark card wants to be light.
32
+ * There is no overlap - a value light enough for 4.5:1 text on `surface`
33
+ * (#1B242F) can carry white at 3.48:1 at the very best.
34
+ *
35
+ * So the fill is not `primary` any more. `brand-surface` below is what the
36
+ * navigation drawer is painted with, and `primary` is free to be a foreground
37
+ * colour. The foreground uses outnumber the fill uses by roughly an order of
38
+ * magnitude, which is why the split falls this way round.
39
+ */
40
+
41
+ /**
42
+ * `brand-surface` IS A SURFACE, AND A SURFACE BELONGS TO ITS THEME.
43
+ *
44
+ * It was first set to the deep brand navy in BOTH themes, which made the
45
+ * navigation drawer the one part of the product the theme switch did not
46
+ * reach: a near-black slab bolted onto a white application in light mode, and
47
+ * a lighter blue block sitting 1.80:1 off the cards beside it in dark mode. It
48
+ * belonged to neither.
49
+ *
50
+ * It is now a real surface in each theme - a light navy-tinted rail on the
51
+ * light page, the same `surface` the app bar and the cards use on the dark
52
+ * page - so the drawer reads as part of the application it is in and the
53
+ * switch visibly changes it. The brand is carried by the title and by the
54
+ * active item, which is `primary` in both themes, rather than by painting the
55
+ * whole rail.
56
+ *
57
+ * The drawer is a low-contrast panel against the page by design (1.13:1 light,
58
+ * 1.19:1 dark, exactly like every other surface in Material), so its edge is
59
+ * what carries the boundary. `NavigationDrawer.vue` raises `border-opacity` on
60
+ * itself alone to reach 3:1 there without redrawing every table and card edge
61
+ * in eleven applications.
62
+ *
63
+ * `on-brand-surface` is set explicitly rather than left to Vuetify's black or
64
+ * white, so the label is the same near-black / near-white the rest of the
65
+ * theme reads in.
66
+ */
67
+ export const LIGHT_THEME = {
68
+ dark: false,
69
+ colors: {
70
+ primary: "#042134",
71
+ /**
72
+ * The navigation drawer's own fill: a navy-tinted light rail, 1.13:1
73
+ * against the white page and carried by its own 3.27:1 edge.
74
+ */
75
+ "brand-surface": "#EDF1F7",
76
+ /** The drawer's label colour. On the rail: 15.43:1. */
77
+ "on-brand-surface": "#0B1B27",
78
+ "primary-button": "#1867C0",
79
+ "text-primary": "#052439",
80
+ },
81
+ variables: {
82
+ "disabled-opacity": 0.55,
83
+ "border-opacity": 0.26,
84
+ },
85
+ };
86
+
87
+ /**
88
+ * Only the keys that must differ; Vuetify merges the rest from its stock dark.
89
+ */
90
+ export const DARK_THEME = {
91
+ dark: true,
92
+ colors: {
93
+ /**
94
+ * Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
95
+ * card, a dialog and the page behind them were one flat sheet. These are
96
+ * 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
97
+ * are tinted towards the brand navy rather than neutral grey, and the
98
+ * divider opacity above is what actually draws the edges.
99
+ */
100
+ background: "#0E1319",
101
+ surface: "#1B242F",
102
+ /** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
103
+ "surface-bright": "#26313E",
104
+ "surface-light": "#26313E",
105
+ /**
106
+ * A FOREGROUND colour, chosen to be read: 6.40:1 on the page, 5.38:1 on a
107
+ * card, 4.53:1 on the bright surface. Not a new colour - it is the blue the
108
+ * camera wall already draws its accents in (`--vms-accent`), so the two
109
+ * dark surfaces in this product now agree on one blue.
110
+ *
111
+ * Where it is still used as a fill, Vuetify derives `on-primary` from it
112
+ * and picks black, which reads at 7.21:1. What it cannot carry is a
113
+ * hardcoded white label - see the note in the PR for the app-side sites
114
+ * that still do that.
115
+ */
116
+ primary: "#5B9BE0",
117
+ /**
118
+ * The navigation drawer's own fill. Deliberately the same value as
119
+ * `surface`: on a dark page the drawer, the app bar and the cards are the
120
+ * same material, which is what makes the rail read as part of the
121
+ * application instead of a blue block laid on top of it. The token stays
122
+ * separate from `surface` so the drawer can be moved on its own later.
123
+ */
124
+ "brand-surface": "#1B242F",
125
+ /** The drawer's label colour. On the rail: 13.21:1. */
126
+ "on-brand-surface": "#E8ECF1",
127
+ /** Unchanged on purpose: it already passes in both themes. */
128
+ "primary-button": "#1867C0",
129
+ /** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
130
+ "text-primary": "#E8ECF1",
131
+ },
132
+ variables: {
133
+ "disabled-opacity": 0.55,
134
+ "border-opacity": 0.35,
135
+ },
136
+ };