@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.
- package/.changeset/dark-primary-contrast.md +39 -0
- package/components/Layout/NavigationDrawer.vue +13 -1
- package/package.json +1 -1
- package/plugins/vuetify.ts +3 -72
- package/utils/theme.test.ts +121 -0
- package/utils/theme.ts +101 -0
|
@@ -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
|
-
|
|
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
package/plugins/vuetify.ts
CHANGED
|
@@ -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
|
-
*
|
|
156
|
-
*
|
|
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
|
+
};
|