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

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,6 +1,18 @@
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 white label cannot also be that. `brand-surface` is the
7
+ deep brand navy in both themes - identical to light `primary`, so light mode
8
+ is unchanged - and the white label on it reads 16.51:1 light, 8.70:1 dark.
9
+
10
+ `floating` dropped so the drawer draws its own edge. The fill is 2.14:1
11
+ against the dark page, under the 3:1 a boundary wants; the border is 3.22:1
12
+ and carries it. In light mode the border is black at 0.26 over the navy, so
13
+ nothing there changes.
14
+ -->
15
+ <v-navigation-drawer v-model="drawer" permanent class="bg-brand-surface">
4
16
  <v-list>
5
17
  <v-list-item>
6
18
  <v-list-item-title class="text-h6 text-white">
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.70",
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,121 @@
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
+ const WHITE = "#FFFFFF";
28
+ const dark = DARK_THEME.colors;
29
+ const light = LIGHT_THEME.colors;
30
+
31
+ /** Sanity check on the measuring tape before it is used to judge anything. */
32
+ test("contrast is measured the way WCAG measures it", () => {
33
+ assert.equal(contrast(WHITE, "#000000").toFixed(2), "21.00");
34
+ assert.equal(contrast("#777777", WHITE).toFixed(2), "4.48");
35
+ });
36
+
37
+ test("dark primary is readable as text on the page, a card and a menu", () => {
38
+ assert.ok(
39
+ contrast(dark.primary, dark.background) >= 4.5,
40
+ `primary on the page: ${contrast(dark.primary, dark.background).toFixed(2)}`
41
+ );
42
+ assert.ok(
43
+ contrast(dark.primary, dark.surface) >= 4.5,
44
+ `primary on a card: ${contrast(dark.primary, dark.surface).toFixed(2)}`
45
+ );
46
+ assert.ok(
47
+ contrast(dark.primary, dark["surface-bright"]) >= 4.5,
48
+ `primary on surface-bright: ${contrast(
49
+ dark.primary,
50
+ dark["surface-bright"]
51
+ ).toFixed(2)}`
52
+ );
53
+ });
54
+
55
+ test("light primary is readable as text on white", () => {
56
+ assert.ok(contrast(light.primary, WHITE) >= 4.5);
57
+ assert.ok(contrast(light["text-primary"], WHITE) >= 4.5);
58
+ });
59
+
60
+ test("text-primary is readable on the surface each theme puts it on", () => {
61
+ assert.ok(contrast(dark["text-primary"], dark.background) >= 4.5);
62
+ assert.ok(contrast(dark["text-primary"], dark.surface) >= 4.5);
63
+ });
64
+
65
+ /**
66
+ * The drawer is the reason `primary` was moved in the first place. If someone
67
+ * points it back at `primary` this fails, because a foreground colour cannot
68
+ * carry a white label.
69
+ */
70
+ test("the nav drawer fill carries a white label in both themes", () => {
71
+ assert.ok(
72
+ contrast(WHITE, dark["brand-surface"]) >= 4.5,
73
+ `white on the dark drawer: ${contrast(
74
+ WHITE,
75
+ dark["brand-surface"]
76
+ ).toFixed(2)}`
77
+ );
78
+ assert.ok(contrast(WHITE, light["brand-surface"]) >= 4.5);
79
+ });
80
+
81
+ test("light mode's drawer fill is exactly what light primary used to be", () => {
82
+ assert.equal(light["brand-surface"], light.primary);
83
+ });
84
+
85
+ test("primary-button carries a white label in both themes", () => {
86
+ assert.ok(contrast(WHITE, dark["primary-button"]) >= 4.5);
87
+ assert.ok(contrast(WHITE, light["primary-button"]) >= 4.5);
88
+ });
89
+
90
+ /**
91
+ * Both themes correct the same two Vuetify defaults, and a value that drifts
92
+ * apart between them is a bug nobody sees until they flip the switch.
93
+ */
94
+ test("disabled text clears 4.5:1 on the surface each theme puts it on", () => {
95
+ const over = (fg: string, bg: string, alpha: number) =>
96
+ "#" +
97
+ [0, 2, 4]
98
+ .map((i) =>
99
+ Math.round(
100
+ parseInt(fg.slice(1 + i, 3 + i), 16) * alpha +
101
+ parseInt(bg.slice(1 + i, 3 + i), 16) * (1 - alpha)
102
+ )
103
+ .toString(16)
104
+ .padStart(2, "0")
105
+ )
106
+ .join("");
107
+
108
+ assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.55);
109
+ assert.equal(DARK_THEME.variables["disabled-opacity"], 0.55);
110
+ assert.ok(contrast(over("#000000", WHITE, 0.55), WHITE) >= 4.5);
111
+ assert.ok(
112
+ contrast(over(WHITE, dark.background, 0.55), dark.background) >= 4.5
113
+ );
114
+ });
115
+
116
+ test("the dark divider clears the 3:1 a boundary wants", () => {
117
+ const border = "#62666a"; // white at 0.35 over the dark page
118
+
119
+ assert.equal(DARK_THEME.variables["border-opacity"], 0.35);
120
+ assert.ok(contrast(border, dark.background) >= 3);
121
+ });
package/utils/theme.ts ADDED
@@ -0,0 +1,101 @@
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 the deep
36
+ * brand navy, it is what the navigation drawer is painted with, and `primary`
37
+ * is free to be a foreground colour. The foreground uses outnumber the fill
38
+ * uses by roughly an order of magnitude, which is why the split falls this way
39
+ * round.
40
+ */
41
+ export const LIGHT_THEME = {
42
+ dark: false,
43
+ colors: {
44
+ primary: "#042134",
45
+ /**
46
+ * The navigation drawer's own fill. Identical to light `primary`, so light
47
+ * mode renders exactly as it did before the drawer stopped using `primary`.
48
+ * White label on it: 16.51:1.
49
+ */
50
+ "brand-surface": "#042134",
51
+ "primary-button": "#1867C0",
52
+ "text-primary": "#052439",
53
+ },
54
+ variables: {
55
+ "disabled-opacity": 0.55,
56
+ "border-opacity": 0.26,
57
+ },
58
+ };
59
+
60
+ /**
61
+ * Only the keys that must differ; Vuetify merges the rest from its stock dark.
62
+ */
63
+ export const DARK_THEME = {
64
+ dark: true,
65
+ colors: {
66
+ /**
67
+ * Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
68
+ * card, a dialog and the page behind them were one flat sheet. These are
69
+ * 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
70
+ * are tinted towards the brand navy rather than neutral grey, and the
71
+ * divider opacity above is what actually draws the edges.
72
+ */
73
+ background: "#0E1319",
74
+ surface: "#1B242F",
75
+ /** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
76
+ "surface-bright": "#26313E",
77
+ "surface-light": "#26313E",
78
+ /**
79
+ * A FOREGROUND colour, chosen to be read: 6.40:1 on the page, 5.38:1 on a
80
+ * card, 4.53:1 on the bright surface. Not a new colour - it is the blue the
81
+ * camera wall already draws its accents in (`--vms-accent`), so the two
82
+ * dark surfaces in this product now agree on one blue.
83
+ *
84
+ * Where it is still used as a fill, Vuetify derives `on-primary` from it
85
+ * and picks black, which reads at 7.21:1. What it cannot carry is a
86
+ * hardcoded white label - see the note in the PR for the app-side sites
87
+ * that still do that.
88
+ */
89
+ primary: "#5B9BE0",
90
+ /** The navigation drawer's own fill. White label on it: 8.70:1. */
91
+ "brand-surface": "#17506F",
92
+ /** Unchanged on purpose: it already passes in both themes. */
93
+ "primary-button": "#1867C0",
94
+ /** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
95
+ "text-primary": "#E8ECF1",
96
+ },
97
+ variables: {
98
+ "disabled-opacity": 0.55,
99
+ "border-opacity": 0.35,
100
+ },
101
+ };