@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.
@@ -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
+ }