@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.
package/utils/theme.ts CHANGED
@@ -1,163 +1,294 @@
1
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.
2
+ * THE PLATFORM PALETTE, AND THE BRIDGE BETWEEN TWO COLOUR SYSTEMS.
3
+ *
4
+ * The redesign is specified in raw CSS custom properties (`--bg`, `--card`,
5
+ * `--muted`, `--ok`...). This product is built in Vuetify, which paints from
6
+ * its own `--v-theme-*` RGB triples. Stacking the two would leave every
7
+ * `v-card`, `v-btn`, `v-chip` and `v-data-table` in eleven applications on its
8
+ * factory colours underneath a correct-looking token file.
9
+ *
10
+ * So there is ONE source of truth - `PALETTE` below - and it flows one way:
11
+ *
12
+ * PALETTE -> Vuetify theme colours -> `--v-theme-*` -> `--bg` etc.
13
+ * (LIGHT_THEME/DARK_THEME) (Vuetify emits) (assets/css/tokens.css)
14
+ *
15
+ * `assets/css/tokens.css` does not restate a single colour; it aliases each
16
+ * design token to the Vuetify variable that already holds it. That matters for
17
+ * more than tidiness: Vuetify puts `.v-theme--light` / `.v-theme--dark` on
18
+ * every `v-theme-provider` subtree, so a token read inside the `plain-dark`
19
+ * layout or inside `FormDialog` resolves to that subtree's theme instead of the
20
+ * page's. A `body.dark` class - which is how the prototype does it - cannot do
21
+ * that, and would have gone wrong in exactly those two places.
22
+ *
23
+ * Every ratio quoted here is WCAG 2.1, alpha composited against the surface the
24
+ * value actually sits on, and `utils/theme.test.ts` re-measures all of them on
25
+ * every run. This file has already been got wrong once by moving a colour for
26
+ * one use and forgetting the other.
27
+ */
28
+
29
+ /**
30
+ * WHAT MOVED FROM THE DESIGN, AND WHY - the owner's decision, 2026-08-13:
31
+ * "minimal darkening, keep the look".
32
+ *
33
+ * Measured against the design as handed over, the LIGHT theme failed AA on 27
34
+ * text pairs and the DARK theme on 6. Six light values move, none by enough to
35
+ * read as a different colour, and the dark theme's own palette is untouched:
36
+ *
37
+ * --muted #8b8f98 -> #6b7079 (table headers, breadcrumbs, sub-lines)
38
+ * --ok #0f8a62 -> #0e7c58 (-10%)
39
+ * --warn #a97a14 -> #8e6611 (-16%)
40
+ * --err #cf4b4b -> #bc4444 (-9%)
41
+ * --info #3b6fd4 -> #3869c9 (-5%)
42
+ * --accent-text #4a6eb6 -> #476aaf (-4%)
43
+ *
44
+ * The soft chip BACKGROUNDS are left exactly as designed. The design states
45
+ * them literally rather than deriving them from the foreground, so darkening a
46
+ * label does not drag its chip with it - a Pending chip is the same colour it
47
+ * was, its text is simply readable on it.
48
+ *
49
+ * `--accent-text` is `color-mix(in oklab, var(--accent) 75%, var(--text))` in
50
+ * the design. It is resolved to a plain hex here because Vuetify needs a
51
+ * concrete colour and because a test cannot measure a `color-mix()`.
52
+ */
53
+
54
+ /**
55
+ * WHITE ON THE ACCENT: A DARKER ACCENT FOR FILLS, NOT A BIGGER FONT.
56
+ *
57
+ * White on `--accent` (#5b8def) is 3.23:1 in BOTH themes - the fill is
58
+ * theme-independent, so the failure is too. It is the primary button's label
59
+ * and the active segment of the Today/Week/Month control. There were two ways
60
+ * out and only one of them keeps the design:
61
+ *
62
+ * - The type route. 3.23:1 is legal for LARGE text, which starts at 18.66px
63
+ * bold. The design's buttons are 13.5px/700-800 in a 40px pill. Reaching
64
+ * 18.66px would make the label taller than the button was drawn for and
65
+ * change every button in the product. Rejected.
66
+ * - `--accent-strong`, an 18%-darker accent used ONLY where a white label
67
+ * sits on an accent fill. White reads 4.57:1 on it, and the fill still
68
+ * holds its own edge (4.57:1 against a light card, 3.64:1 against a dark
69
+ * one). Chosen.
70
+ *
71
+ * The point is what it does NOT touch. `--accent` itself is unchanged, so the
72
+ * links, the active sidebar item, the tab underline, the toggle in its on
73
+ * state, the chart lines, the focus rings, the logo tile and every
74
+ * `--accent-soft` background are the exact colour the design specifies. Only
75
+ * the two filled surfaces that carry white lettering are darker.
21
76
  */
22
77
 
23
78
  /**
24
79
  * `primary` IS BOTH A FILL AND A FOREGROUND, AND ONE VALUE CANNOT SERVE BOTH.
25
80
  *
26
81
  * 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.
82
+ * colour that fills a button is also the colour of every `class="text-primary"`
83
+ * sentence, every `color="primary"` icon, spinner and text button, and the
84
+ * focused outline on every input. On a dark page those pull in opposite
85
+ * directions: a fill that carries a white label wants to be dark, a foreground
86
+ * that is readable on a dark card wants to be light. There is no overlap.
87
+ *
88
+ * That is why the split established on 2026-08-12 survives this rewrite, just
89
+ * pointed at the new palette:
90
+ *
91
+ * primary = --accent-text (a FOREGROUND: links, icons, text)
92
+ * primary-button = --accent-strong (a FILL that carries a white label)
93
+ * brand-surface = --sidebar (the navigation drawer's own fill)
94
+ *
95
+ * Re-pointing rather than renaming is deliberate: `text-primary` and
96
+ * `primary-button` are written in roughly eighty places across this layer and
97
+ * the applications, and none of them has to change.
39
98
  */
40
99
 
41
100
  /**
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.
101
+ * VUETIFY DERIVES THE LABEL ON A FILLED COLOUR, AND ITS GUESS IS WHITE.
102
+ *
103
+ * Measured in a browser, not assumed: given `warning`, `info`, `error`,
104
+ * `success` or `primary` as a fill, Vuetify picks a WHITE foreground. On this
105
+ * palette's dark theme that is 2.04:1 to 2.99:1 - every filled chip, alert,
106
+ * badge and button in dark mode. So each `on-*` is set explicitly below rather
107
+ * than left to the derivation. Light's fills are dark enough that white is in
108
+ * fact right, and it is still written down there, so that no component in
109
+ * eleven applications is left depending on Vuetify guessing correctly.
66
110
  */
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",
111
+ export const PALETTE = {
112
+ light: {
113
+ /** Page background. */
114
+ bg: "#f6f6f2",
115
+ /** The navigation rail. */
116
+ sidebar: "#fbfbf8",
117
+ /** Cards, tables, modals, menus. */
118
+ card: "#ffffff",
119
+ /** Card and table edges. As designed - see the note in the test. */
120
+ border: "#e8e8e1",
121
+ /** Primary text. 17.24:1 on a card. */
122
+ text: "#191b1f",
123
+ /** Secondary text. 9.62:1 on a card. */
124
+ text2: "#41454d",
125
+ /** Table headers, breadcrumbs, KPI sub-lines. 4.98:1 on a card. */
126
+ muted: "#6b7079",
127
+ /** Completed / Paid / Available / Open. 4.58:1 on its chip. */
128
+ ok: "#0e7c58",
129
+ /** Pending. 4.56:1 on its chip. */
130
+ warn: "#8e6611",
131
+ /** In Use / Closed / Checkout. 4.55:1 on its chip. */
132
+ err: "#bc4444",
133
+ /** Role and info tags. 4.58:1 on its chip. */
134
+ info: "#3869c9",
135
+ /** The brand accent, exactly as designed. Never carries white lettering. */
136
+ accent: "#5b8def",
137
+ /** The only place the accent is darkened: fills with a white label. 4.57:1. */
138
+ accentStrong: "#4b74c4",
139
+ /** Links, active nav item, active tab. 4.57:1 on `--accent-soft`. */
140
+ accentText: "#476aaf",
141
+ /** Labels on the filled colours above. Light's fills carry white. */
142
+ onFill: "#ffffff",
143
+ },
144
+ dark: {
145
+ bg: "#131418",
146
+ sidebar: "#17181d",
147
+ card: "#1c1e24",
148
+ border: "#2a2d35",
149
+ text: "#eef0f3",
150
+ text2: "#c6cad2",
151
+ /** Passes as designed at 4.93:1 - the dark theme needed no darkening. */
152
+ muted: "#868c98",
153
+ ok: "#41c795",
154
+ warn: "#dcaf4e",
155
+ err: "#e57373",
156
+ info: "#7da6ec",
157
+ accent: "#5b8def",
158
+ /** Same value as light: the failure was theme-independent, so the fix is. */
159
+ accentStrong: "#4b74c4",
160
+ accentText: "#78a2f4",
78
161
  /**
79
- * Vuetify leaves `warning` (#FB8C00) and `info` (#2196F3) at its stock
80
- * values and DERIVES their foreground, and as with `on-primary` the
81
- * derivation picks WHITE. Measured, that is 2.37:1 on warning and 3.12:1
82
- * on info: every filled warning/info chip, alert, button and badge in the
83
- * product, including the invitation status chips. Setting the label to the
84
- * dark page colour takes them to 8.9:1 and 5.3:1. Same value in both
85
- * themes because the two fills are the same in both themes.
162
+ * Dark's fills are LIGHT colours, so their label is the page ink, not
163
+ * white. White on them measures 2.04:1 to 2.99:1; this reads 6.16:1 to
164
+ * 9.01:1. Not a new colour - it is `bg` above, so a filled chip reads as a
165
+ * hole punched in the page.
86
166
  */
87
- "on-warning": "#0E1319",
88
- "on-info": "#0E1319",
89
- "primary-button": "#1867C0",
90
- "text-primary": "#052439",
91
- },
92
- variables: {
93
- "disabled-opacity": 0.55,
94
- "border-opacity": 0.26,
167
+ onFill: "#131418",
95
168
  },
96
- };
169
+ } as const;
170
+
171
+ /**
172
+ * The values are widened back to `string`. `PALETTE` is `as const` so a test
173
+ * can assert an exact hex, but the builder below takes EITHER theme - and with
174
+ * the literal types intact, "#131418" is not assignable to "#f6f6f2" and the
175
+ * dark theme will not compile.
176
+ */
177
+ export type TPalette = { readonly [K in keyof (typeof PALETTE)["light"]]: string };
178
+
179
+ type Palette = TPalette;
97
180
 
98
181
  /**
99
- * Only the keys that must differ; Vuetify merges the rest from its stock dark.
182
+ * One theme builder for both themes, so a colour can never be wired into light
183
+ * and forgotten in dark - which is the specific way this file broke before.
100
184
  */
101
- export const DARK_THEME = {
102
- dark: true,
185
+ const theme = (dark: boolean, p: Palette) => ({
186
+ dark,
103
187
  colors: {
188
+ // --- the design's surfaces -------------------------------------------
189
+ background: p.bg,
190
+ surface: p.card,
191
+ "on-background": p.text,
192
+ "on-surface": p.text,
193
+ /** Menus and raised sheets sit on the card colour, not Vuetify's grey. */
194
+ "surface-bright": p.card,
195
+ "surface-light": p.card,
196
+ "surface-variant": p.sidebar,
197
+ "on-surface-variant": p.text2,
198
+
199
+ // --- the split accent -------------------------------------------------
200
+ /** A FOREGROUND. See the note above before making this a fill colour. */
201
+ primary: p.accentText,
202
+ "on-primary": p.onFill,
203
+ /** The FILL that carries a white label. White on it: 4.57:1, both themes. */
204
+ "primary-button": p.accentStrong,
205
+ "on-primary-button": "#ffffff",
206
+ /** The navigation drawer's fill = the design's sidebar colour. */
207
+ "brand-surface": p.sidebar,
208
+ "on-brand-surface": p.text,
209
+ /** ~80 call sites across the layer and the apps. Now the design's ink. */
210
+ "text-primary": p.text,
211
+
212
+ // --- semantic status, mapped as the design specifies ------------------
213
+ /** Completed / Paid / Available / Open. */
214
+ success: p.ok,
215
+ "on-success": p.onFill,
216
+ /** Pending. */
217
+ warning: p.warn,
218
+ "on-warning": p.onFill,
219
+ /** In Use / Closed / Checkout. */
220
+ error: p.err,
221
+ "on-error": p.onFill,
222
+ /** Role and info tags. */
223
+ info: p.info,
224
+ "on-info": p.onFill,
225
+
226
+ // --- design tokens with no Vuetify equivalent -------------------------
227
+ // These exist so `assets/css/tokens.css` can alias `--text2`, `--muted`,
228
+ // `--border`, `--accent` and friends to a `--v-theme-*` that Vuetify keeps
229
+ // in step with the active theme, rather than restating hexes in CSS.
230
+ accent: p.accent,
231
+ "accent-strong": p.accentStrong,
232
+ "accent-text": p.accentText,
233
+ text2: p.text2,
234
+ muted: p.muted,
235
+ border: p.border,
236
+ sidebar: p.sidebar,
237
+ },
238
+ variables: {
104
239
  /**
105
- * Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
106
- * card, a dialog and the page behind them were one flat sheet. These are
107
- * 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
108
- * are tinted towards the brand navy rather than neutral grey, and the
109
- * divider opacity above is what actually draws the edges.
110
- */
111
- background: "#0E1319",
112
- surface: "#1B242F",
113
- /** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
114
- "surface-bright": "#26313E",
115
- "surface-light": "#26313E",
116
- /**
117
- * A FOREGROUND colour, chosen to be read: 6.40:1 on the page, 5.38:1 on a
118
- * card, 4.53:1 on the bright surface. Not a new colour - it is the blue the
119
- * camera wall already draws its accents in (`--vms-accent`), so the two
120
- * dark surfaces in this product now agree on one blue.
240
+ * AN OPACITY IS ONLY AS DARK AS THE INK IT IS APPLIED TO.
121
241
  *
122
- * Where it is still used as a fill it needs a foreground, and the claim
123
- * that used to sit here - that Vuetify derives black - is wrong. Measured
124
- * in a browser, Vuetify picks WHITE, which reads at 2.68:1 on this fill:
125
- * every filled primary button, chip, alert and tab bar in dark mode. So
126
- * `on-primary` is set explicitly below rather than derived.
127
- */
128
- primary: "#5B9BE0",
129
- /**
130
- * The label on a filled `primary` surface. Measured 6.40:1 against
131
- * `primary`, against the 2.68:1 Vuetify's own derivation gives. Not a new
132
- * colour - it is the dark page `background` above, so a filled button
133
- * reads as a hole punched in the page rather than a fifth shade of navy.
242
+ * Both values below were tuned when `on-surface` was Vuetify's pure black.
243
+ * The design's ink is `#191b1f`, which is lighter, so the SAME opacity now
244
+ * composites to a paler grey - and it showed up the moment the two screens
245
+ * that shipped today were re-rendered: every `text-medium-emphasis` line
246
+ * fell from 5.74:1 to 4.53:1 on a card and 4.41:1 on the page, taking the
247
+ * dashboard's site name and the preference screen's summary below AA.
248
+ *
249
+ * That is one cause with about two hundred call sites across eleven
250
+ * applications, so it is fixed once, here, rather than in the two places
251
+ * the measurement happened to catch.
252
+ *
253
+ * `medium-emphasis-opacity` 0.64: 5.11:1 on a card, 4.94:1 on the page,
254
+ * 5.04:1 on the sidebar. The minimum that clears AA is 0.61 and this keeps
255
+ * a margin, while staying clearly lighter than `--text2`'s 9.62:1 - it is
256
+ * still medium emphasis, not body text.
257
+ *
258
+ * `disabled-opacity` 0.62: the value that composites `#191b1f` to the same
259
+ * grey `#000000` reached at 0.55, so a disabled field is exactly as dark as
260
+ * it was before this palette. It is NOT 4.5:1 as rendered - Vuetify
261
+ * multiplies its own field alpha on top and the result measures 1.9:1 -
262
+ * but that compounding predates this work and un-compounding it means
263
+ * changing how an input draws, which is Phase 3's job, not a token's.
264
+ *
265
+ * The same number in both themes on purpose: a value that drifts apart
266
+ * between light and dark is a bug nobody sees until they flip the switch.
134
267
  */
135
- "on-primary": "#0E1319",
136
268
  /**
137
- * The navigation drawer's own fill. Deliberately the same value as
138
- * `surface`: on a dark page the drawer, the app bar and the cards are the
139
- * same material, which is what makes the rail read as part of the
140
- * application instead of a blue block laid on top of it. The token stays
141
- * separate from `surface` so the drawer can be moved on its own later.
269
+ * `high-emphasis-opacity` 1, and this one REMOVES a dimming layer rather
270
+ * than retuning it. Vuetify's 0.87 exists to soften pure black into body
271
+ * ink; the design already ships body ink, so applying 0.87 on top of
272
+ * `#191b1f` dims text that was never black to begin with.
273
+ *
274
+ * It also compounds. A field label is high emphasis AND medium emphasis -
275
+ * 0.87 x 0.64 - which is how "Search work orders" was rendering at 3.95:1
276
+ * while every token it is built from passed on its own. Taking this to 1
277
+ * leaves one opacity on that label instead of two, and it measures 5.02:1.
142
278
  */
143
- "brand-surface": "#1B242F",
144
- /** The drawer's label colour. On the rail: 13.21:1. */
145
- "on-brand-surface": "#E8ECF1",
146
- /** See LIGHT_THEME: Vuetify derives white on these fills, at 2.37:1. */
147
- "on-warning": "#0E1319",
148
- "on-info": "#0E1319",
279
+ "high-emphasis-opacity": 1,
280
+ "medium-emphasis-opacity": 0.64,
281
+ "disabled-opacity": 0.62,
149
282
  /**
150
- * Dark's stock `error` is the pale #CF6679, and white on it is 3.60:1
151
- * the light theme's darker #B00020 carries white fine, this one does not.
283
+ * Vuetify draws borders as `on-surface` at this opacity. The design's
284
+ * `--border` is a much softer edge than Material's default; these are the
285
+ * opacities that land closest to it while staying visible. Components that
286
+ * need a real boundary raise it on themselves - see `NavigationDrawer.vue`.
152
287
  */
153
- "on-error": "#0E1319",
154
- /** Unchanged on purpose: it already passes in both themes. */
155
- "primary-button": "#1867C0",
156
- /** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
157
- "text-primary": "#E8ECF1",
158
- },
159
- variables: {
160
- "disabled-opacity": 0.55,
161
- "border-opacity": 0.35,
288
+ "border-opacity": dark ? 0.35 : 0.26,
162
289
  },
163
- };
290
+ });
291
+
292
+ export const LIGHT_THEME = theme(false, PALETTE.light);
293
+
294
+ export const DARK_THEME = theme(true, PALETTE.dark);