@crewlethq/tokens 0.2.0 → 0.4.0
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/README.md +153 -20
- package/dist/css/base.css +194 -0
- package/dist/css/breakpoint.css +18 -0
- package/dist/css/density.css +23 -0
- package/dist/css/legacy.css +4 -6
- package/dist/css/material-symbols.css +5 -3
- package/dist/css/themes.css +338 -38
- package/dist/css/tokens.css +152 -38
- package/dist/index.d.ts +152 -12
- package/dist/index.js +152 -13
- package/dist/themes.d.ts +274 -0
- package/dist/themes.js +274 -0
- package/package.json +17 -5
- package/test/color.mjs +262 -0
- package/test/palette.d.mts +77 -0
- package/test/palette.mjs +867 -0
- package/test/palette.test.mjs +55 -0
package/test/palette.mjs
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The palette's promises, recomputed from the stylesheets that ship.
|
|
3
|
+
*
|
|
4
|
+
* Every claim the token files make in their own comments is measured here, in
|
|
5
|
+
* every theme state, over the composited surfaces a token can actually land
|
|
6
|
+
* on. The point is not that the numbers are pretty: it is that an edit which
|
|
7
|
+
* lowers one of them fails a build instead of shipping.
|
|
8
|
+
*
|
|
9
|
+
* This module is PUBLISHED, as `@crewlethq/tokens/test/palette`. The rule
|
|
10
|
+
* table and the colour maths have one implementation and two callers: this
|
|
11
|
+
* package's own suite, over the CSS it just built, and a consumer's suite,
|
|
12
|
+
* over the version it installed. The owner's constraint is that the floors
|
|
13
|
+
* are kept, not only that the measurement moves, and a tokens bump that
|
|
14
|
+
* lowered a ratio would otherwise reach a consumer through an auto-merged
|
|
15
|
+
* dependency update with nothing measuring a ratio again.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
*
|
|
19
|
+
* import { readFileSync } from 'node:fs';
|
|
20
|
+
* import { runPalette } from '@crewlethq/tokens/test/palette';
|
|
21
|
+
*
|
|
22
|
+
* const dir = 'node_modules/@crewlethq/tokens/dist/css';
|
|
23
|
+
* const { failures } = runPalette({
|
|
24
|
+
* tokens: readFileSync(`${dir}/tokens.css`, 'utf8'),
|
|
25
|
+
* themes: readFileSync(`${dir}/themes.css`, 'utf8'),
|
|
26
|
+
* });
|
|
27
|
+
* expect(failures).toEqual([]);
|
|
28
|
+
*
|
|
29
|
+
* The two files are passed in IMPORT order, because they share the `:root`
|
|
30
|
+
* selector: themes.css is imported second and is what paints.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { cascade, chroma, contrast, deltaE, flatten, parseHex, separation, VISIONS, withAlpha } from './color.mjs';
|
|
34
|
+
|
|
35
|
+
export * from './color.mjs';
|
|
36
|
+
|
|
37
|
+
/** The alpha a soft tint is drawn at, and the one the build derives it with. */
|
|
38
|
+
export const SOFT_ALPHA = 0.12;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The token names each rule reads
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every opaque ground a piece of text can end up on.
|
|
46
|
+
*
|
|
47
|
+
* This list is the whole point of the exercise. A text ramp anchored to the
|
|
48
|
+
* panel it was designed against and then spent on a selected row inside a
|
|
49
|
+
* dialog body is how a palette ships six steps under 4.5:1 without anybody
|
|
50
|
+
* noticing.
|
|
51
|
+
*/
|
|
52
|
+
export const OPAQUE_SURFACES = [
|
|
53
|
+
'--color-surface-background',
|
|
54
|
+
'--color-surface-subtle',
|
|
55
|
+
'--color-surface-muted',
|
|
56
|
+
'--color-surface-elevated',
|
|
57
|
+
'--color-surface-topbar',
|
|
58
|
+
'--color-surface-topbar-lift',
|
|
59
|
+
'--color-surface-topbar-active',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The translucent overlays composited onto each ground. A hovered or pressed
|
|
64
|
+
* row IS one of these, and so is an inset well.
|
|
65
|
+
*
|
|
66
|
+
* There is deliberately no hover-over-selected composite, and that is a RULE
|
|
67
|
+
* rather than a gap: a row shows the selected tint or the hover overlay, never
|
|
68
|
+
* both, because both are background-color on the same element and the selected
|
|
69
|
+
* state wins.
|
|
70
|
+
*/
|
|
71
|
+
export const OVERLAYS = ['--color-surface-hover', '--color-surface-pressed', '--color-surface-inset'];
|
|
72
|
+
|
|
73
|
+
/** The neutral steps that carry a FACT, and the floor each clears. */
|
|
74
|
+
export const TEXT_STEPS = [
|
|
75
|
+
['--color-text-primary', 7],
|
|
76
|
+
['--color-text-secondary', 4.5],
|
|
77
|
+
['--color-text-tertiary', 4.5],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** A hue used as TEXT on a surface. */
|
|
81
|
+
export const INK_STEPS = [
|
|
82
|
+
'--color-brand-accent-ink',
|
|
83
|
+
'--color-feedback-success-ink',
|
|
84
|
+
'--color-feedback-warning-ink',
|
|
85
|
+
'--color-feedback-danger-ink',
|
|
86
|
+
'--color-feedback-info-ink',
|
|
87
|
+
'--color-phase-onboarding-ink',
|
|
88
|
+
'--color-phase-execute-ink',
|
|
89
|
+
'--color-phase-review-ink',
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** A hue used as a MARK, or as a fill behind a label. */
|
|
93
|
+
export const FILL_STEPS = [
|
|
94
|
+
'--color-brand-accent',
|
|
95
|
+
'--color-feedback-success',
|
|
96
|
+
'--color-feedback-warning',
|
|
97
|
+
'--color-feedback-danger',
|
|
98
|
+
'--color-feedback-info',
|
|
99
|
+
'--color-phase-onboarding',
|
|
100
|
+
'--color-phase-execute',
|
|
101
|
+
'--color-phase-review',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
export const STATUS = [
|
|
105
|
+
'--color-feedback-success',
|
|
106
|
+
'--color-feedback-warning',
|
|
107
|
+
'--color-feedback-danger',
|
|
108
|
+
'--color-feedback-info',
|
|
109
|
+
];
|
|
110
|
+
export const PHASE = ['--color-phase-onboarding', '--color-phase-execute', '--color-phase-review'];
|
|
111
|
+
export const DATA = ['--color-data-1', '--color-data-2', '--color-data-3', '--color-data-4', '--color-data-5'];
|
|
112
|
+
|
|
113
|
+
/** A fill with the label a component paints on it. The Button variants. */
|
|
114
|
+
export const LABEL_ON_FILL = [
|
|
115
|
+
['--color-text-on-brand', '--color-brand-primary'],
|
|
116
|
+
['--color-text-on-brand', '--color-brand-primary-hover'],
|
|
117
|
+
['--color-text-on-brand', '--color-brand-primary-active'],
|
|
118
|
+
['--color-text-on-accent', '--color-brand-accent'],
|
|
119
|
+
['--color-text-on-accent', '--color-brand-accent-hover'],
|
|
120
|
+
['--color-text-on-accent', '--color-brand-accent-active'],
|
|
121
|
+
['--color-text-on-accent', '--color-feedback-danger'],
|
|
122
|
+
['--color-text-on-accent', '--color-feedback-danger-hover'],
|
|
123
|
+
['--color-text-inverse', '--color-surface-inverse'],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/** The ten seeded avatar grounds, each carrying white initials. */
|
|
127
|
+
export const AVATAR_TINTS = Array.from({ length: 10 }, (_, i) => `--color-avatar-tint-${i}`);
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// The rail
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The application rail, which is the one surface in the product that paints A
|
|
135
|
+
* TINT ON A TINT.
|
|
136
|
+
*
|
|
137
|
+
* Every other rule here composites what a component draws onto an OPAQUE
|
|
138
|
+
* ground, because that is what a badge in a table cell and a callout in a
|
|
139
|
+
* dialog body do. The rail does not. The row the reader is on carries the
|
|
140
|
+
* accent's own soft tint, a hovered row carries the hover overlay, and the
|
|
141
|
+
* attention count's warning tint is drawn on top of whichever of those the row
|
|
142
|
+
* happens to have. `--color-feedback-warning-ink` on its own soft tint was
|
|
143
|
+
* already a rule, and it is measured over the seven opaque grounds; on the
|
|
144
|
+
* reader's own row the tint sits on the accent tint instead, and that third
|
|
145
|
+
* composite is both unmeasured and the tightest of the three.
|
|
146
|
+
*
|
|
147
|
+
* The rail's ground is the topbar surface rather than the page's, which is the
|
|
148
|
+
* other half of why this is a list of its own: every pair is named here, so an
|
|
149
|
+
* edit to the accent, to the warning hue or to either soft alpha shows its
|
|
150
|
+
* cost on the rail rather than only inside a dialog.
|
|
151
|
+
*/
|
|
152
|
+
export const RAIL_GROUND = '--color-surface-topbar';
|
|
153
|
+
|
|
154
|
+
/** The three grounds a row in the rail can have, over that ground. */
|
|
155
|
+
export const RAIL_ROWS = [
|
|
156
|
+
['a row', null],
|
|
157
|
+
['a hovered row', '--color-surface-hover'],
|
|
158
|
+
['the row the reader is on', '--color-brand-accent-soft'],
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What the rail paints: the ink, the tint it is drawn on (null for the row's
|
|
163
|
+
* own ground), which rows carry it, and the floor it clears.
|
|
164
|
+
*
|
|
165
|
+
* The resting glyph is deliberately not in this list. It is the decoration
|
|
166
|
+
* step, it is `aria-hidden` beside its own label, and it has a band rather
|
|
167
|
+
* than a floor; it gets a rule of its own below. On a hovered row and on the
|
|
168
|
+
* reader's own it takes the row's colour, so there it IS the label's entry.
|
|
169
|
+
*/
|
|
170
|
+
export const RAIL_PAINTS = [
|
|
171
|
+
["a row's label", '--color-text-secondary', null, ['a row'], 4.5],
|
|
172
|
+
['a hovered row, label and glyph', '--color-text-primary', null, ['a hovered row'], 4.5],
|
|
173
|
+
['the current row, label and glyph', '--color-brand-accent-ink', null, ['the row the reader is on'], 4.5],
|
|
174
|
+
['a group label, a quiet badge, a foot row', '--color-text-tertiary', null, ['a row', 'a hovered row'], 4.5],
|
|
175
|
+
[
|
|
176
|
+
'the attention count',
|
|
177
|
+
'--color-feedback-warning-ink',
|
|
178
|
+
'--color-feedback-warning-soft',
|
|
179
|
+
['a row', 'a hovered row', 'the row the reader is on'],
|
|
180
|
+
4.5,
|
|
181
|
+
],
|
|
182
|
+
['the focus ring', '--color-focus', null, ['a row', 'a hovered row', 'the row the reader is on'], 3],
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The label a component draws ON THE SCRIM, and every ground the scrim itself
|
|
187
|
+
* can land on.
|
|
188
|
+
*
|
|
189
|
+
* The scrim is the one ground in the palette that is not a surface step: it is
|
|
190
|
+
* a wash over whatever is already there, so the pair a reader sees depends on
|
|
191
|
+
* what that was. An upload overlay puts a word on it over an identity badge,
|
|
192
|
+
* which is a seeded tint, the accent fill, or the initials tile on whichever
|
|
193
|
+
* surface the form sits on, and none of those composites is reachable from the
|
|
194
|
+
* surface list. Every ground here is measured because the shallowest one is
|
|
195
|
+
* what sets the number: a 60 percent black over a white page is much lighter
|
|
196
|
+
* than the same wash over a dark tint.
|
|
197
|
+
*/
|
|
198
|
+
export const SCRIM_GROUNDS = ['--color-brand-accent', ...AVATAR_TINTS];
|
|
199
|
+
|
|
200
|
+
// The controls
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The segmented row, which is the one CONTROL that paints a tint on a tint.
|
|
205
|
+
*
|
|
206
|
+
* Its container is the inset well and the option that is on is lifted off it
|
|
207
|
+
* with the panel surface, so the label of the chosen answer sits on a
|
|
208
|
+
* composite of two overlays rather than on a ground any other rule reaches.
|
|
209
|
+
* It is the label a reader looks for first in the row, so it is held to the
|
|
210
|
+
* primary step's own floor rather than to the 4.5 a fact needs.
|
|
211
|
+
*
|
|
212
|
+
* The resting options are not listed: their ink is the tertiary step on the
|
|
213
|
+
* well alone, which is a plain overlay composite the text rule already
|
|
214
|
+
* measures over every ground.
|
|
215
|
+
*
|
|
216
|
+
* WHAT THIS RULE DOES NOT CLAIM, and the next reader should not assume: that
|
|
217
|
+
* the chip's own FACE is separable from the well it sits in. It is not, on
|
|
218
|
+
* every ground. The face is the panel surface and the well is the inset
|
|
219
|
+
* overlay, and on the page ground those land dE 0.43 apart in light and 1.91
|
|
220
|
+
* in dark, against a floor of 3 elsewhere in this file; on a panel they sit
|
|
221
|
+
* 3.54 and 4.68 apart. So a segmented row is drawn on the product's own page
|
|
222
|
+
* exactly as it is at :8020, and which option is on is read there from the
|
|
223
|
+
* INK step, primary against tertiary, and from the chip's shadow. Both inks
|
|
224
|
+
* are measured, which is why this holds the on label to the primary floor
|
|
225
|
+
* rather than to the 4.5 a fact needs. A restyle that flattened the two ink
|
|
226
|
+
* steps together would take the last thing saying which option is on.
|
|
227
|
+
*/
|
|
228
|
+
export const CONTROL_WELL = '--color-surface-inset';
|
|
229
|
+
export const CONTROL_CHIP = '--color-surface-subtle';
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The destructive button's face, and what its hover has to do on it.
|
|
233
|
+
*
|
|
234
|
+
* The button states itself in its INK and takes the hue only under the
|
|
235
|
+
* pointer, so the hover tint IS the whole of the feedback: a reader who
|
|
236
|
+
* cannot see it has no signal that the control under the pointer is the one
|
|
237
|
+
* that deletes. Both of its inks are measured elsewhere (the danger ink on
|
|
238
|
+
* the panel, and on its own soft tint), so what is left to measure is that
|
|
239
|
+
* the tint is separable from the face it replaces at all.
|
|
240
|
+
*/
|
|
241
|
+
export const DESTRUCTIVE_FACE = '--color-surface-subtle';
|
|
242
|
+
|
|
243
|
+
// The veil
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The ground a modal surface sits on, and the one overlay in the palette that
|
|
248
|
+
* covers the WHOLE PAGE rather than a component.
|
|
249
|
+
*
|
|
250
|
+
* Every other translucent step here is composited UNDER something a component
|
|
251
|
+
* draws: a hovered row, a soft tint behind a badge. The veil is composited over
|
|
252
|
+
* everything already on the page, so what it changes is not a component's ink
|
|
253
|
+
* but whether the page behind a dialog is still a page. Nothing measured that,
|
|
254
|
+
* and the alpha is the only thing deciding it.
|
|
255
|
+
*
|
|
256
|
+
* It is a BAND rather than a floor, for the same reason --color-text-muted is:
|
|
257
|
+
* both ends are real failures and only one of them looks like one. Too little
|
|
258
|
+
* and the page keeps reading as something to act on, competing with the
|
|
259
|
+
* surface that just took the keyboard; too much and the veil is a scrim, the
|
|
260
|
+
* page is gone, and an operator loses the place they were keeping. The
|
|
261
|
+
* measurement is the page's OWN primary text seen through the veil, against the
|
|
262
|
+
* veiled ground beside it, because that is the strongest thing the page has.
|
|
263
|
+
*
|
|
264
|
+
* The upper bound is the 3:1 mark floor: below it, nothing behind the surface
|
|
265
|
+
* reads as a thing a reader could use. The lower bound is 1.5:1, which is where
|
|
266
|
+
* a large dark heading stops being perceptible at all.
|
|
267
|
+
*/
|
|
268
|
+
export const VEIL = '--color-surface-veil';
|
|
269
|
+
export const VEIL_BAND = [1.5, 3];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The alpha the band above was measured at, and the only number the veil is
|
|
273
|
+
* free to choose.
|
|
274
|
+
*
|
|
275
|
+
* Its COLOUR is not free: a veil is the page seen through less of itself, so
|
|
276
|
+
* it is each root's own ground at this alpha and never a hue of its own. A
|
|
277
|
+
* veil that is a different colour from the page under it is a wash laid over
|
|
278
|
+
* the page, which moves every ground behind it somewhere the ink steps were
|
|
279
|
+
* never measured against, and it does it in the one state where a reader
|
|
280
|
+
* cannot tell a rendering fault from a design.
|
|
281
|
+
*/
|
|
282
|
+
export const VEIL_ALPHA = 0.72;
|
|
283
|
+
|
|
284
|
+
// The data surfaces
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The MARKS a table, a tree and a chart draw, on every ground a row can have.
|
|
289
|
+
*
|
|
290
|
+
* `FILL_STEPS` above measures a mark on the OPAQUE surfaces and stops there,
|
|
291
|
+
* which is right for a badge and wrong for a row: a row is the panel it sits
|
|
292
|
+
* on, OR that panel under the hover overlay, OR that panel under the accent
|
|
293
|
+
* tint when it is the row the reader is on. A mark painted on the last of
|
|
294
|
+
* those is the case nothing was measuring, and it is exactly the row that most
|
|
295
|
+
* needs one: the row that is both selected and broken. The danger FILL measured
|
|
296
|
+
* 2.86:1 there in dark on a card and 2.65:1 on the elevated step, under the 3:1
|
|
297
|
+
* a graphical object has to clear, so the leading rail on that row could not be
|
|
298
|
+
* seen at all. The other two tones cleared it (the tightest is the warning fill
|
|
299
|
+
* at 3.58:1), and all three move to the ink step together: which tone a row
|
|
300
|
+
* carries is not a reason for its rail to be drawn to a different floor, and a
|
|
301
|
+
* rule that held for two of three is a rule nobody can state. The ink steps are
|
|
302
|
+
* what those rails take now, and this is where they are held.
|
|
303
|
+
*
|
|
304
|
+
* Each entry is what the mark is, the token, which grounds it can land on, and
|
|
305
|
+
* the floor it clears. "every row ground" is all three; "a row at rest" is the
|
|
306
|
+
* opaque surfaces alone, for a mark that is only ever drawn on a row that has
|
|
307
|
+
* no tint of its own (a treegrid row carries its selection AS the rail, so the
|
|
308
|
+
* rail and the tint are never both there).
|
|
309
|
+
*
|
|
310
|
+
* WHAT THIS TABLE IS, HONESTLY. Every entry here is also reached by a rule
|
|
311
|
+
* above it: the ink steps by "ink step clears 4.5:1 as text", which measures
|
|
312
|
+
* the same composites to a HIGHER floor, the accent by the fill-step rule and
|
|
313
|
+
* the control boundary by its own. So none of these can fail on its own, and
|
|
314
|
+
* this table is not what would catch a regression in a token. What it is, is
|
|
315
|
+
* the MAP from a mark a data surface draws to the ground it actually lands
|
|
316
|
+
* on, written down where the next person choosing a token for a rail will
|
|
317
|
+
* look: the rails were fill steps because nobody had written that a row is
|
|
318
|
+
* sometimes the accent tint. The guard that catches the component putting a
|
|
319
|
+
* fill step back is `a rail drawn on a row takes an ink step` in
|
|
320
|
+
* @crewlethq/ui's DataTable suite, and it is the one with teeth.
|
|
321
|
+
*/
|
|
322
|
+
export const DATA_MARKS = [
|
|
323
|
+
["a row's leading state rail", '--color-feedback-danger-ink', 'every row ground', 3],
|
|
324
|
+
["a row's leading state rail", '--color-feedback-warning-ink', 'every row ground', 3],
|
|
325
|
+
["a row's leading state rail", '--color-feedback-info-ink', 'every row ground', 3],
|
|
326
|
+
["a selected row's rail, and a selected card's ring", '--color-brand-accent', 'a row at rest', 3],
|
|
327
|
+
["a chart's connector between two cards", '--color-border-control', 'a row at rest', 3],
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The one mark in these surfaces that is drawn at an OPACITY rather than at a
|
|
332
|
+
* step of its own: the sort arrow in a table's header, which is the only thing
|
|
333
|
+
* saying a column can be sorted at all and therefore has to stay resolvable.
|
|
334
|
+
*
|
|
335
|
+
* It is the header's own ink, faded, so it cannot be looked up in the palette:
|
|
336
|
+
* the alpha is part of the colour. Measured here because the alternative is a
|
|
337
|
+
* number in a stylesheet comment that nothing recomputes, which is what it was.
|
|
338
|
+
*/
|
|
339
|
+
export const SORT_MARK_ALPHA = 0.7;
|
|
340
|
+
|
|
341
|
+
/** Every shadow step. Each has to be declared in BOTH theme blocks. */
|
|
342
|
+
export const SHADOW_STEPS = [
|
|
343
|
+
'--shadow-xs',
|
|
344
|
+
'--shadow-sm',
|
|
345
|
+
'--shadow-md',
|
|
346
|
+
'--shadow-lg',
|
|
347
|
+
'--shadow-xl',
|
|
348
|
+
'--shadow-2xl',
|
|
349
|
+
'--shadow-card',
|
|
350
|
+
'--shadow-rim-lit',
|
|
351
|
+
'--shadow-hairline',
|
|
352
|
+
'--shadow-focus',
|
|
353
|
+
'--shadow-selection',
|
|
354
|
+
'--shadow-glow',
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
// The cross-family floors are lower than the within-family ones because the
|
|
358
|
+
// hue budget is finite (thirteen hues in a space deuteranopia collapses to
|
|
359
|
+
// blue, yellow and lightness) and because across families colour is never the
|
|
360
|
+
// only signal: a Tag always renders its label, a status Callout always renders
|
|
361
|
+
// its glyph, and the accent appears only as position. They are floors, not
|
|
362
|
+
// targets, and every measured value is reported so a later edit shows its cost.
|
|
363
|
+
const CROSS_NORMAL = 8;
|
|
364
|
+
const CROSS_DICHROMAT = 6;
|
|
365
|
+
const ACCENT_NORMAL = 10;
|
|
366
|
+
const ACCENT_DICHROMAT = 8;
|
|
367
|
+
// The reserved red keeps the highest normal floor of any cross-family pair,
|
|
368
|
+
// because it is the one hue that means the same thing everywhere in a product.
|
|
369
|
+
// Its dichromat floor is the ACCENT's, not the phase family's: both rules
|
|
370
|
+
// protect a RESERVED meaning from being claimed by something else, where the
|
|
371
|
+
// phase rule only asks two ordinary families to stay apart. It is also the
|
|
372
|
+
// most a green can carry. Red and green are one axis to a deuteranopic reader,
|
|
373
|
+
// so the whole separation is lightness and chroma, and pushing this pair past
|
|
374
|
+
// 9.5 takes the fourth series out of the green band altogether.
|
|
375
|
+
const DANGER_NORMAL = 14;
|
|
376
|
+
const DANGER_DICHROMAT = 8;
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Theme states
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
const DARK_MEDIA = '@media (prefers-color-scheme: dark)';
|
|
383
|
+
const DARK_MEDIA_SELECTOR = ':root:not([data-theme="light"])';
|
|
384
|
+
const DARK_ATTRIBUTE_SELECTOR = ':root[data-theme="dark"]';
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The four sets of values a browser can end up with.
|
|
388
|
+
*
|
|
389
|
+
* `base` is tokens.css on its own, which is what an application that imports
|
|
390
|
+
* only the token layer paints: the marketing palette. The other three are what
|
|
391
|
+
* the theme layer paints over it.
|
|
392
|
+
*/
|
|
393
|
+
export function paletteStates({ tokens, themes }) {
|
|
394
|
+
const tokensOnly = [{ name: 'tokens.css', css: tokens }];
|
|
395
|
+
const both = [...tokensOnly, { name: 'themes.css', css: themes }];
|
|
396
|
+
const bare = (block) => block.atRule === null && block.selector === ':root';
|
|
397
|
+
const light = cascade(both, bare);
|
|
398
|
+
const withDark = (selector, atRule) =>
|
|
399
|
+
new Map([...light, ...cascade(both, (block) => block.atRule === atRule && block.selector === selector)]);
|
|
400
|
+
return {
|
|
401
|
+
base: cascade(tokensOnly, bare),
|
|
402
|
+
light,
|
|
403
|
+
'dark (media query)': withDark(DARK_MEDIA_SELECTOR, DARK_MEDIA),
|
|
404
|
+
'dark (attribute)': withDark(DARK_ATTRIBUTE_SELECTOR, null),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// The rules
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
function surfacesOf(values) {
|
|
413
|
+
const ground = resolve(values, '--color-surface-background');
|
|
414
|
+
const opaque = OPAQUE_SURFACES.map((name) => [name, flatten(values.get(name), ground)]);
|
|
415
|
+
const all = [...opaque];
|
|
416
|
+
for (const overlay of OVERLAYS) {
|
|
417
|
+
for (const [name, rgb] of opaque) all.push([`${overlay} on ${name}`, flatten(values.get(overlay), rgb)]);
|
|
418
|
+
}
|
|
419
|
+
for (const [name, rgb] of opaque) {
|
|
420
|
+
all.push([`--color-brand-accent-soft on ${name}`, flatten(values.get('--color-brand-accent-soft'), rgb)]);
|
|
421
|
+
}
|
|
422
|
+
return { ground, opaque, all };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function resolve(values, name, ground) {
|
|
426
|
+
const raw = values.get(name);
|
|
427
|
+
if (raw === undefined) throw new Error(`token ${name} has no value`);
|
|
428
|
+
const rgb = parseHex(raw) ?? (ground ? flatten(raw, ground) : null);
|
|
429
|
+
if (!rgb) throw new Error(`token ${name} is "${raw}", which is not a colour this rule can measure`);
|
|
430
|
+
return rgb;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Run every rule over one state's values.
|
|
435
|
+
* `profile` is 'full' for a theme, or 'base' for the marketing root, which is
|
|
436
|
+
* exempt from the separation, ground and card rules: its pure black ground and
|
|
437
|
+
* its single brand hue are a deliberate marketing look rather than a product
|
|
438
|
+
* palette.
|
|
439
|
+
*/
|
|
440
|
+
function checkState(state, values, profile, push) {
|
|
441
|
+
const { ground, opaque, all } = surfacesOf(values);
|
|
442
|
+
const colour = (name) => resolve(values, name, ground);
|
|
443
|
+
const worst = (rgb, list) =>
|
|
444
|
+
list.reduce((low, [name, surface]) => {
|
|
445
|
+
const ratio = contrast(rgb, surface);
|
|
446
|
+
return ratio < low.ratio ? { ratio, name } : low;
|
|
447
|
+
}, { ratio: Infinity, name: '' });
|
|
448
|
+
const say = (rule, ok, subject, value, detail) => push({ state, rule, ok, subject, value, detail });
|
|
449
|
+
|
|
450
|
+
for (const [name, floor] of TEXT_STEPS) {
|
|
451
|
+
const low = worst(colour(name), all);
|
|
452
|
+
say('text step clears its floor', low.ratio >= floor, name, low.ratio, `${low.ratio.toFixed(2)}:1 >= ${floor} (worst on ${low.name})`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
{
|
|
456
|
+
// It exists for a hairline glyph and a disabled affordance. The assertion
|
|
457
|
+
// is that it stays BELOW the fact floor: a step that quietly crept up to
|
|
458
|
+
// 4.5 would invite itself into a table cell, which is the whole failure
|
|
459
|
+
// the separate name prevents.
|
|
460
|
+
const ratio = contrast(colour('--color-text-muted'), flatten(values.get('--color-surface-subtle'), ground));
|
|
461
|
+
say('text-muted is decoration', ratio > 2.8 && ratio < 4.5, '--color-text-muted', ratio, `2.8 < ${ratio.toFixed(2)}:1 < 4.5 on the panel`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const name of INK_STEPS) {
|
|
465
|
+
const low = worst(colour(name), all);
|
|
466
|
+
say('ink step clears 4.5:1 as text', low.ratio >= 4.5, name, low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// An ink on its OWN soft tint: a badge, a callout, a diff line. The tint is
|
|
470
|
+
// recomputed from the fill here rather than read from the token, so a
|
|
471
|
+
// hand-edited tint that no longer belongs to its fill fails.
|
|
472
|
+
for (const fill of [...STATUS, ...PHASE]) {
|
|
473
|
+
const { r, g, b } = colour(fill);
|
|
474
|
+
const tints = opaque.map(([name, rgb]) => [
|
|
475
|
+
`${fill} soft on ${name}`,
|
|
476
|
+
flatten(`rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${SOFT_ALPHA})`, rgb),
|
|
477
|
+
]);
|
|
478
|
+
const low = worst(colour(`${fill}-ink`), tints);
|
|
479
|
+
say('ink clears 4.5:1 on its own soft tint', low.ratio >= 4.5, `${fill}-ink`, low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
480
|
+
|
|
481
|
+
// AND ON THE INSET WELL DRAWN OVER THAT TINT. An InlineCode chip inside a
|
|
482
|
+
// Callout keeps the chip's own ground and takes only the message's ink,
|
|
483
|
+
// which is what stops a calm grey identifier sitting in the middle of an
|
|
484
|
+
// alarming sentence. That puts a status ink on a THIRD ground: the
|
|
485
|
+
// overlay composited onto the soft tint composited onto the page. Neither
|
|
486
|
+
// of the two rules above measures it, and the chip is the one place in a
|
|
487
|
+
// callout where a reader has to make out an exact string.
|
|
488
|
+
const chips = tints.map(([name, rgb]) => [
|
|
489
|
+
`the inset chip on ${name}`,
|
|
490
|
+
flatten(values.get('--color-surface-inset'), rgb),
|
|
491
|
+
]);
|
|
492
|
+
const chip = worst(colour(`${fill}-ink`), chips);
|
|
493
|
+
say('ink clears 4.5:1 on a code chip inside its own callout', chip.ratio >= 4.5, `${fill}-ink`, chip.ratio, `${chip.ratio.toFixed(2)}:1 (worst on ${chip.name})`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const name of [...FILL_STEPS, ...DATA, '--color-data-other', '--color-brand-mark']) {
|
|
497
|
+
const low = worst(colour(name), opaque);
|
|
498
|
+
say('fill step clears 3:1 as a mark', low.ratio >= 3, name, low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
for (const [label, fill] of LABEL_ON_FILL) {
|
|
502
|
+
// A fill can be translucent (the monochrome primary's hover and active
|
|
503
|
+
// steps are), so it is measured over every ground it can sit on.
|
|
504
|
+
const low = worst(colour(label), opaque.map(([name, rgb]) => [`${fill} on ${name}`, flatten(values.get(fill), rgb)]));
|
|
505
|
+
say('a label clears 4.5:1 on its own fill', low.ratio >= 4.5, `${label} on ${fill}`, low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (const name of AVATAR_TINTS) {
|
|
509
|
+
const ratio = contrast(parseHex('#ffffff'), colour(name));
|
|
510
|
+
say('an avatar tint clears 4.5:1 under white initials', ratio >= 4.5, name, ratio, `${ratio.toFixed(2)}:1`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
{
|
|
514
|
+
// A word on the scrim. The scrim is a wash rather than a surface, so the
|
|
515
|
+
// composite is taken over every ground it can be drawn on rather than read
|
|
516
|
+
// from a token: an upload overlay's label sits on the scrim over an
|
|
517
|
+
// identity badge, and the badge is a seeded tint, the accent fill or an
|
|
518
|
+
// initials tile on whichever surface the form is on.
|
|
519
|
+
const label = colour('--color-text-on-accent');
|
|
520
|
+
const scrim = values.get('--color-surface-scrim');
|
|
521
|
+
const over = [...opaque, ...SCRIM_GROUNDS.map((name) => [name, colour(name)])];
|
|
522
|
+
const low = worst(label, over.map(([name, rgb]) => [`the scrim over ${name}`, flatten(scrim, rgb)]));
|
|
523
|
+
say('a label on the scrim clears 4.5:1', low.ratio >= 4.5, '--color-text-on-accent', low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
{
|
|
527
|
+
// Every surface the ring can be drawn on, the SELECTED tint included.
|
|
528
|
+
//
|
|
529
|
+
// An outset ring around a selected row does sit on the row's own ground
|
|
530
|
+
// rather than on its tint, which is why this composite was left out once.
|
|
531
|
+
// But --size-focus-ring-inset-offset exists precisely because a focusable
|
|
532
|
+
// row inside a clipping scroller has to draw its ring INSIDE its own box,
|
|
533
|
+
// where an outset one is clipped on the first and last rows and painted
|
|
534
|
+
// over by the next; and a tree row or a grid row is the commonest row in a
|
|
535
|
+
// product to be both focused and selected. So the tint is a ground the
|
|
536
|
+
// ring lands on, and the rule measures it.
|
|
537
|
+
const low = worst(colour('--color-focus'), all);
|
|
538
|
+
say('the focus ring clears 3:1', low.ratio >= 3, '--color-focus', low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
{
|
|
542
|
+
// THE SEGMENTED ROW, chip on well on ground. See CONTROL_WELL.
|
|
543
|
+
for (const [name, rgb] of opaque) {
|
|
544
|
+
const well = flatten(values.get(CONTROL_WELL), rgb);
|
|
545
|
+
const chip = flatten(values.get(CONTROL_CHIP), well);
|
|
546
|
+
const ratio = contrast(colour('--color-text-primary'), chip);
|
|
547
|
+
say(
|
|
548
|
+
"the segmented chip's label clears the primary floor on its own well",
|
|
549
|
+
ratio >= 7,
|
|
550
|
+
`--color-text-primary on the chip over ${name}`,
|
|
551
|
+
ratio,
|
|
552
|
+
`${ratio.toFixed(2)}:1 >= 7`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// THE DESTRUCTIVE BUTTON'S HOVER. See DESTRUCTIVE_FACE. dE 3 is the same
|
|
557
|
+
// floor the selected tint is held to against a hovered row: below it a
|
|
558
|
+
// colour difference is not one a reader can be relied on to notice.
|
|
559
|
+
for (const [name, rgb] of opaque) {
|
|
560
|
+
const face = flatten(values.get(DESTRUCTIVE_FACE), rgb);
|
|
561
|
+
const tint = flatten(values.get('--color-feedback-danger-soft'), face);
|
|
562
|
+
const measured = deltaE(tint, face);
|
|
563
|
+
say(
|
|
564
|
+
"the destructive button's hover is visible on its own face",
|
|
565
|
+
measured >= 3,
|
|
566
|
+
`--color-feedback-danger-soft on ${name}`,
|
|
567
|
+
measured,
|
|
568
|
+
`dE ${measured.toFixed(2)} >= 3`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// THE VEIL, on every ground it can be drawn over. See VEIL.
|
|
573
|
+
const [floor, ceiling] = VEIL_BAND;
|
|
574
|
+
const veil = values.get(VEIL);
|
|
575
|
+
const own = withAlpha(values.get('--color-surface-background'), VEIL_ALPHA);
|
|
576
|
+
say(
|
|
577
|
+
'the veil is this root at an alpha, not a colour of its own',
|
|
578
|
+
veil === own,
|
|
579
|
+
VEIL,
|
|
580
|
+
VEIL_ALPHA,
|
|
581
|
+
`"${veil}" is the root's own ground at ${VEIL_ALPHA} ("${own}")`,
|
|
582
|
+
);
|
|
583
|
+
const ink = flatten(veil, colour('--color-text-primary'));
|
|
584
|
+
for (const [name, rgb] of opaque) {
|
|
585
|
+
const ratio = contrast(ink, flatten(veil, rgb));
|
|
586
|
+
say(
|
|
587
|
+
'the veil leaves the page as context and no more',
|
|
588
|
+
ratio > floor && ratio < ceiling,
|
|
589
|
+
`${VEIL} over ${name}`,
|
|
590
|
+
ratio,
|
|
591
|
+
`${floor} < ${ratio.toFixed(2)}:1 < ${ceiling} (the page's own primary text, veiled)`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// THE MARKS A TABLE, A TREE AND A CHART DRAW. See DATA_MARKS.
|
|
596
|
+
const atRest = opaque;
|
|
597
|
+
const everyRow = [
|
|
598
|
+
...opaque,
|
|
599
|
+
...opaque.flatMap(([name, rgb]) => [
|
|
600
|
+
[`a hovered ${name}`, flatten(values.get('--color-surface-hover'), rgb)],
|
|
601
|
+
[`a selected ${name}`, flatten(values.get('--color-brand-accent-soft'), rgb)],
|
|
602
|
+
]),
|
|
603
|
+
];
|
|
604
|
+
for (const [what, mark, where, floor] of DATA_MARKS) {
|
|
605
|
+
const low = worst(colour(mark), where === 'every row ground' ? everyRow : atRest);
|
|
606
|
+
say(
|
|
607
|
+
'a mark on a data surface clears its floor on every ground it lands on',
|
|
608
|
+
low.ratio >= floor,
|
|
609
|
+
`${mark} on ${where}`,
|
|
610
|
+
low.ratio,
|
|
611
|
+
`${low.ratio.toFixed(2)}:1 >= ${floor} (${what}, worst on ${low.name})`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
// The sort arrow, which is the header's own ink faded rather than a step.
|
|
615
|
+
// The header band is the panel surface, which is the only ground it has.
|
|
616
|
+
const band = flatten(values.get('--color-surface-subtle'), ground);
|
|
617
|
+
const sortInk = colour('--color-text-tertiary');
|
|
618
|
+
const faded = {
|
|
619
|
+
r: sortInk.r * SORT_MARK_ALPHA + band.r * (1 - SORT_MARK_ALPHA),
|
|
620
|
+
g: sortInk.g * SORT_MARK_ALPHA + band.g * (1 - SORT_MARK_ALPHA),
|
|
621
|
+
b: sortInk.b * SORT_MARK_ALPHA + band.b * (1 - SORT_MARK_ALPHA),
|
|
622
|
+
};
|
|
623
|
+
const ratio = contrast(faded, band);
|
|
624
|
+
say(
|
|
625
|
+
"a table's sort arrow stays resolvable at the alpha it is drawn with",
|
|
626
|
+
ratio >= 3,
|
|
627
|
+
`--color-text-tertiary at ${SORT_MARK_ALPHA}`,
|
|
628
|
+
ratio,
|
|
629
|
+
`${ratio.toFixed(2)}:1 >= 3 on the header band`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (profile !== 'full') return;
|
|
634
|
+
|
|
635
|
+
{
|
|
636
|
+
// THE RAIL, on its own ground and on its own row tints. See RAIL_GROUND.
|
|
637
|
+
const rail = flatten(values.get(RAIL_GROUND), ground);
|
|
638
|
+
const rows = new Map(RAIL_ROWS.map(([name, tint]) => [name, tint === null ? rail : flatten(values.get(tint), rail)]));
|
|
639
|
+
for (const [what, ink, tint, on, floor] of RAIL_PAINTS) {
|
|
640
|
+
for (const row of on) {
|
|
641
|
+
const under = rows.get(row);
|
|
642
|
+
const surface = tint === null ? under : flatten(values.get(tint), under);
|
|
643
|
+
const ratio = contrast(colour(ink), surface);
|
|
644
|
+
say(
|
|
645
|
+
"the rail's ink clears its floor on the row's own tint",
|
|
646
|
+
ratio >= floor,
|
|
647
|
+
`${ink} on ${row}`,
|
|
648
|
+
ratio,
|
|
649
|
+
`${ratio.toFixed(2)}:1 >= ${floor} (${what})`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// The resting glyph has a band rather than a floor, for the same reason
|
|
654
|
+
// --color-text-muted does anywhere: it is decoration, aria-hidden beside
|
|
655
|
+
// the word that carries the meaning, and a step that crept up to the fact
|
|
656
|
+
// floor would invite itself into a row that means something. What it must
|
|
657
|
+
// not do is vanish, so the band is asserted from both ends on the rail's
|
|
658
|
+
// own ground, which is the only one it is ever drawn on.
|
|
659
|
+
const glyph = contrast(colour('--color-text-muted'), rail);
|
|
660
|
+
say(
|
|
661
|
+
"the rail's resting glyph stays decoration",
|
|
662
|
+
glyph > 2.8 && glyph < 4.5,
|
|
663
|
+
'--color-text-muted',
|
|
664
|
+
glyph,
|
|
665
|
+
`2.8 < ${glyph.toFixed(2)}:1 < 4.5 on the rail`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const [set, floor, adjacent, rule] of [
|
|
670
|
+
[PHASE, 10, false, 'phase hues stay separable'],
|
|
671
|
+
[STATUS, 10, false, 'status hues stay separable'],
|
|
672
|
+
[DATA, 9, true, 'adjacent data hues stay separable'],
|
|
673
|
+
]) {
|
|
674
|
+
// ADJACENT, not every pair, for the data ramp: a legend reader
|
|
675
|
+
// distinguishes series 2 from series 3 because they sit next to each
|
|
676
|
+
// other, and demanding every pair of six be far apart is what forces a
|
|
677
|
+
// palette to spread until it is ugly.
|
|
678
|
+
const pairs = adjacent
|
|
679
|
+
? set.slice(0, -1).map((name, i) => [name, set[i + 1]])
|
|
680
|
+
: set.flatMap((name, i) => set.slice(i + 1).map((other) => [name, other]));
|
|
681
|
+
for (const [a, b] of pairs) {
|
|
682
|
+
const measured = separation(colour(a), colour(b));
|
|
683
|
+
say(rule, Math.min(...measured) >= floor, `${a} vs ${b}`, Math.min(...measured), `dE ${describe(measured)} >= ${floor}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// A phase tag must not be read as a status badge beside it. Measured under
|
|
688
|
+
// every vision: the first draft of this palette put an onboarding tag dE 3.1
|
|
689
|
+
// from an info badge for a deuteranopic reader and passed under normal
|
|
690
|
+
// vision alone.
|
|
691
|
+
for (const phase of PHASE) {
|
|
692
|
+
for (const status of STATUS) {
|
|
693
|
+
const measured = separation(colour(phase), colour(status));
|
|
694
|
+
const ok = measured[0] >= CROSS_NORMAL && Math.min(measured[1], measured[2]) >= CROSS_DICHROMAT;
|
|
695
|
+
say('a phase hue clears the status family', ok, `${phase} vs ${status}`, Math.min(...measured), `dE ${describe(measured)} >= ${CROSS_NORMAL} normal, ${CROSS_DICHROMAT} dichromat`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Red means "this broke" everywhere in a product. A chart series that
|
|
700
|
+
// happens to be red says so too, to a reader who is scanning for it, and
|
|
701
|
+
// THAT READER IS OFTEN THE DICHROMAT ONE: red and green collapse onto one
|
|
702
|
+
// axis for them, so a chart's green series is the likeliest thing in the
|
|
703
|
+
// whole palette to be read as failure. Measured under normal vision alone
|
|
704
|
+
// this rule passed at dE 24.0 while the light ramp's fourth series sat 6.0
|
|
705
|
+
// from the danger red under deuteranopia.
|
|
706
|
+
for (const name of DATA) {
|
|
707
|
+
const measured = separation(colour(name), colour('--color-feedback-danger'));
|
|
708
|
+
const ok = measured[0] >= DANGER_NORMAL && Math.min(measured[1], measured[2]) >= DANGER_DICHROMAT;
|
|
709
|
+
say('no data hue collides with danger', ok, name, Math.min(...measured), `dE ${describe(measured)} >= ${DANGER_NORMAL} normal, ${DANGER_DICHROMAT} dichromat`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// "The accent is the most saturated hue" cannot be kept on this palette:
|
|
713
|
+
// danger, both outer phase hues and two data hues out-saturate it. What the
|
|
714
|
+
// original rule was protecting is that nothing else reads as the selection,
|
|
715
|
+
// and that is a distance, not a saturation ranking.
|
|
716
|
+
for (const name of [...PHASE, ...STATUS, ...DATA]) {
|
|
717
|
+
const measured = separation(colour(name), colour('--color-brand-accent'));
|
|
718
|
+
const ok = measured[0] >= ACCENT_NORMAL && Math.min(measured[1], measured[2]) >= ACCENT_DICHROMAT;
|
|
719
|
+
say('no hue is read as the accent', ok, name, Math.min(...measured), `dE ${describe(measured)} >= ${ACCENT_NORMAL} normal, ${ACCENT_DICHROMAT} dichromat`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// A selected row has to outread a hovered one. One alpha for both themes
|
|
723
|
+
// fails this: at 0.10 over the dark panel the selected tint separated dE
|
|
724
|
+
// 3.94 from its ground while the plain hover overlay separated 4.68, so
|
|
725
|
+
// selected read WEAKER than hovered.
|
|
726
|
+
for (const [name, rgb] of opaque) {
|
|
727
|
+
const selected = flatten(values.get('--color-brand-accent-soft'), rgb);
|
|
728
|
+
const hovered = flatten(values.get('--color-surface-hover'), rgb);
|
|
729
|
+
const fromGround = deltaE(selected, rgb);
|
|
730
|
+
const fromHover = deltaE(selected, hovered);
|
|
731
|
+
const ok = fromGround >= deltaE(hovered, rgb) && fromHover >= 3;
|
|
732
|
+
say('the selected tint outreads the hover overlay', ok, name, Math.min(fromGround - deltaE(hovered, rgb), fromHover), `dE ${fromGround.toFixed(2)} from the ground (hover ${deltaE(hovered, rgb).toFixed(2)}), dE ${fromHover.toFixed(2)} apart`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
{
|
|
736
|
+
// Every surface a control can sit on, the overlay composites included: a
|
|
737
|
+
// checkbox inside a hovered row in a dialog body is the case the opaque
|
|
738
|
+
// surfaces alone do not catch.
|
|
739
|
+
const low = worst(colour('--color-border-control'), all);
|
|
740
|
+
say('a control boundary clears 3:1', low.ratio >= 3, '--color-border-control', low.ratio, `${low.ratio.toFixed(2)}:1 (worst on ${low.name})`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Glass carries chrome; anything carrying words sits on an opaque surface.
|
|
744
|
+
// The declared worst backdrop is the brand-primary fill, which a panned
|
|
745
|
+
// canvas can put under a floating panel.
|
|
746
|
+
const backdrops = [['the brand-primary fill', flatten(values.get('--color-brand-primary'), ground)], ...opaque];
|
|
747
|
+
for (const [name, backdrop] of backdrops) {
|
|
748
|
+
const glass = flatten(values.get('--color-surface-glass'), backdrop);
|
|
749
|
+
for (const step of ['--color-text-secondary', '--color-text-tertiary']) {
|
|
750
|
+
const ratio = contrast(colour(step), glass);
|
|
751
|
+
say('text on glass clears 4.5:1', ratio >= 4.5, `${step} over ${name}`, ratio, `${ratio.toFixed(2)}:1`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// The chroma cap is what stops the ground drifting into a tint of the
|
|
756
|
+
// accent, which leaves the accent nothing to separate itself from.
|
|
757
|
+
for (const [name, rgb] of opaque) {
|
|
758
|
+
const measured = chroma(rgb);
|
|
759
|
+
say('the neutral ramp stays neutral', measured <= 2.2, name, measured, `chroma ${measured.toFixed(2)} <= 2.2`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
{
|
|
763
|
+
// "The ground is lifted off pure white" re-expressed: uilet's light page
|
|
764
|
+
// IS white, with grey cards on it, so what has to hold is that the card
|
|
765
|
+
// differs from the page.
|
|
766
|
+
const measured = deltaE(flatten(values.get('--color-surface-subtle'), ground), ground);
|
|
767
|
+
say('the card differs from the page', measured >= 3, '--color-surface-subtle', measured, `dE ${measured.toFixed(1)} >= 3`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const describe = (measured) => measured.map((value, i) => `${VISIONS[i].slice(0, 1)}${value.toFixed(1)}`).join('/');
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Structure
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
function checkStructure(sources, states, push) {
|
|
778
|
+
const say = (rule, ok, subject, detail) => push({ state: 'the token files', rule, ok, subject, value: null, detail });
|
|
779
|
+
|
|
780
|
+
// A colour whose only definition is inside the theme layer is a colour that
|
|
781
|
+
// is missing for an application that imports the token layer alone.
|
|
782
|
+
const themed = [...states.light.keys()].filter((name) => name.startsWith('--color-') || name.startsWith('--shadow-'));
|
|
783
|
+
const missing = themed.filter((name) => !states.base.has(name));
|
|
784
|
+
say('every themed token has a value on the bare :root of tokens.css', missing.length === 0, 'tokens.css', missing.length === 0 ? 'none missing' : `missing: ${missing.join(', ')}`);
|
|
785
|
+
|
|
786
|
+
// The measured rules cannot catch a missing LIGHT slot: the token still has
|
|
787
|
+
// a value, inherited from tokens.css, and that value is the dark one. So
|
|
788
|
+
// parity is asserted directly.
|
|
789
|
+
const lightBlock = cascade([sources.themesSource], (block) => block.atRule === null && block.selector === ':root');
|
|
790
|
+
const darkBlock = cascade([sources.themesSource], (block) => block.atRule === null && block.selector === DARK_ATTRIBUTE_SELECTOR);
|
|
791
|
+
const onlyLight = [...lightBlock.keys()].filter((name) => !darkBlock.has(name));
|
|
792
|
+
const onlyDark = [...darkBlock.keys()].filter((name) => !lightBlock.has(name));
|
|
793
|
+
say(
|
|
794
|
+
'the light block declares exactly the key set the dark block declares',
|
|
795
|
+
onlyLight.length === 0 && onlyDark.length === 0,
|
|
796
|
+
'themes.css',
|
|
797
|
+
onlyLight.length === 0 && onlyDark.length === 0 ? 'the two key sets match' : `light only: ${onlyLight.join(', ') || 'none'}; dark only: ${onlyDark.join(', ') || 'none'}`,
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
// The dark palette is written twice on purpose, once so the system setting
|
|
801
|
+
// works and once so an explicit choice wins in both directions. A value that
|
|
802
|
+
// drifts between them means the toggle changes colours the system setting
|
|
803
|
+
// does not.
|
|
804
|
+
const media = cascade([sources.themesSource], (block) => block.atRule === DARK_MEDIA && block.selector === DARK_MEDIA_SELECTOR);
|
|
805
|
+
say('the dark media block is present', media.size > 0, 'themes.css', `${media.size} declarations`);
|
|
806
|
+
const drift = [...media].filter(([name, value]) => darkBlock.get(name) !== value).map(([name, value]) => `${name}: ${value} vs ${darkBlock.get(name)}`);
|
|
807
|
+
say('the two dark blocks agree', drift.length === 0, 'themes.css', drift.length === 0 ? 'no drift' : drift.join('; '));
|
|
808
|
+
|
|
809
|
+
// A dark-tuned shadow on a white page is a smudge, so every step is declared
|
|
810
|
+
// in both blocks rather than inherited from the token layer, AND the two
|
|
811
|
+
// declarations actually differ.
|
|
812
|
+
//
|
|
813
|
+
// Both halves, because declaring a step in both blocks at the same value is
|
|
814
|
+
// the defect wearing the fix's clothes: the key-set rule above passes, this
|
|
815
|
+
// rule's name is satisfied, and the light page still carries a shadow tuned
|
|
816
|
+
// for a dark one. The exception is a step whose value is composed of theme
|
|
817
|
+
// aware var()s: --shadow-focus reads --color-surface-background and
|
|
818
|
+
// --color-focus, --shadow-selection reads --color-brand-accent, and those
|
|
819
|
+
// repaint with the palette without the declaration changing. So the rule is
|
|
820
|
+
// applied to a LITERAL value, and a step that stops being var()-composed
|
|
821
|
+
// falls back under it.
|
|
822
|
+
const undeclared = SHADOW_STEPS.filter((name) => !lightBlock.has(name) || !darkBlock.has(name));
|
|
823
|
+
say('every shadow step is declared in both blocks', undeclared.length === 0, 'themes.css', undeclared.length === 0 ? `all ${SHADOW_STEPS.length} steps declared in both blocks` : `not declared in both: ${undeclared.join(', ')}`);
|
|
824
|
+
const literal = SHADOW_STEPS.filter((name) => !undeclared.includes(name) && !lightBlock.get(name).includes('var('));
|
|
825
|
+
const invariant = literal.filter((name) => lightBlock.get(name) === darkBlock.get(name));
|
|
826
|
+
say(
|
|
827
|
+
'no shadow step is theme-invariant',
|
|
828
|
+
invariant.length === 0,
|
|
829
|
+
'themes.css',
|
|
830
|
+
invariant.length === 0
|
|
831
|
+
? `all ${literal.length} literal steps differ between the palettes (${SHADOW_STEPS.length - literal.length} repaint through var())`
|
|
832
|
+
: `the same value in both palettes: ${invariant.map((name) => `${name} (${lightBlock.get(name)})`).join('; ')}`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
// The entry point
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Measure the whole rule table over one built pair of stylesheets.
|
|
842
|
+
* Returns every check with its measured value, and the failing subset.
|
|
843
|
+
*/
|
|
844
|
+
export function runPalette({ tokens, themes }) {
|
|
845
|
+
const states = paletteStates({ tokens, themes });
|
|
846
|
+
const checks = [];
|
|
847
|
+
const push = (check) => checks.push(check);
|
|
848
|
+
for (const [name, values] of Object.entries(states)) {
|
|
849
|
+
checkState(name, values, name === 'base' ? 'base' : 'full', push);
|
|
850
|
+
}
|
|
851
|
+
checkStructure({ themesSource: { name: 'themes.css', css: themes } }, states, push);
|
|
852
|
+
return { states, checks, failures: checks.filter((check) => !check.ok) };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** One line per failing check, for a test's assertion message. */
|
|
856
|
+
export const describeFailure = (check) => `${check.state}: ${check.rule}: ${check.subject}: ${check.detail}`;
|
|
857
|
+
|
|
858
|
+
/** The tightest measured value under each rule, for a report or a story. */
|
|
859
|
+
export function tightest(checks) {
|
|
860
|
+
const byRule = new Map();
|
|
861
|
+
for (const check of checks) {
|
|
862
|
+
if (check.value === null) continue;
|
|
863
|
+
const low = byRule.get(check.rule);
|
|
864
|
+
if (low === undefined || check.value < low.value) byRule.set(check.rule, check);
|
|
865
|
+
}
|
|
866
|
+
return [...byRule.values()];
|
|
867
|
+
}
|