@crewlethq/tokens 0.4.4 → 0.5.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/test/palette.mjs CHANGED
@@ -30,7 +30,7 @@
30
30
  * selector: themes.css is imported second and is what paints.
31
31
  */
32
32
 
33
- import { cascade, chroma, contrast, deltaE, flatten, parseHex, separation, VISIONS, withAlpha } from './color.mjs';
33
+ import { cascade, chroma, contrast, deltaE, flatten, parseBlocks, parseHex, separation, VISIONS, withAlpha } from './color.mjs';
34
34
 
35
35
  export * from './color.mjs';
36
36
 
@@ -42,23 +42,89 @@ export const SOFT_ALPHA = 0.12;
42
42
  // ---------------------------------------------------------------------------
43
43
 
44
44
  /**
45
- * Every opaque ground a piece of text can end up on.
45
+ * Every opaque ground a piece of text can end up on: the four RUNGS.
46
+ *
47
+ * frame the application ground: the rail, and the body around the sheet
48
+ * background the SHEET, the page content sits on
49
+ * subtle the CARD: a card, a popover, a dialog body
50
+ * elevated RAISED: a chip, a well, a key cap, a track, a lifted row
46
51
  *
47
52
  * This list is the whole point of the exercise. A text ramp anchored to the
48
53
  * panel it was designed against and then spent on a selected row inside a
49
54
  * dialog body is how a palette ships six steps under 4.5:1 without anybody
50
55
  * noticing.
56
+ *
57
+ * It is exported so a component suite measures on these grounds rather than
58
+ * on a copy of them: six suites in @crewlethq/ui each kept a copy of their
59
+ * own, and five of the six had already drifted from this list.
51
60
  */
52
61
  export const OPAQUE_SURFACES = [
62
+ '--color-surface-frame',
53
63
  '--color-surface-background',
54
64
  '--color-surface-subtle',
55
- '--color-surface-muted',
56
65
  '--color-surface-elevated',
57
- '--color-surface-topbar',
58
- '--color-surface-topbar-lift',
59
- '--color-surface-topbar-active',
60
66
  ];
61
67
 
68
+ /**
69
+ * How far apart each rung sits from the one beneath it, in OKLab dE, and why.
70
+ *
71
+ * The four rungs are one ladder, and each step is held to what the step has
72
+ * to do on its own:
73
+ *
74
+ * - THE SHEET OVER THE FRAME is the whole layering device: a content sheet
75
+ * floating inside the application ground. At dE 3, the floor this file holds
76
+ * every "can a reader notice it" difference to, the sheet reads as a
77
+ * separate plane; the approved dark sheet, #0f0f12, measured 2.91 and was
78
+ * nudged to clear it.
79
+ * - A CARD OVER THE SHEET is deliberately near-flat, dE 1.5, because a card is
80
+ * found by its HAIRLINE rather than by its fill: a page of cards each lifted
81
+ * by dE 3 is a relief map. So the step comes with a second rule, and that is
82
+ * the one that carries the card: the plain border (CARD_HAIRLINE) has to be
83
+ * a line a reader can see, dE 3 against the card it bounds and against the
84
+ * sheet around it. That a card actually DRAWS that border is a component
85
+ * rule, held by the Card suite in @crewlethq/ui, because a palette cannot see
86
+ * a stylesheet.
87
+ * - RAISED OVER THE CARD is a chip, a well or a key cap with no border of its
88
+ * own, so it is the fill alone that says it is there: dE 3.
89
+ *
90
+ * There is deliberately no rule between the card and the frame, or raised and
91
+ * the sheet: nothing is drawn across two rungs at once, and a rule over a pair
92
+ * nobody puts side by side is a floor that binds for no reason.
93
+ */
94
+ export const RUNG_STEPS = [
95
+ ['the sheet lifts off the frame', '--color-surface-background', '--color-surface-frame', 3],
96
+ ['a card separates from the sheet', '--color-surface-subtle', '--color-surface-background', 1.5],
97
+ ['raised separates from the card', '--color-surface-elevated', '--color-surface-subtle', 3],
98
+ ];
99
+
100
+ /**
101
+ * The line that finds a card, and the two grounds it runs between: the card
102
+ * inside it and the sheet outside it. See RUNG_STEPS for why the card leans
103
+ * on it.
104
+ */
105
+ export const CARD_HAIRLINE = ['--color-border-default', '--color-surface-subtle', '--color-surface-background'];
106
+ export const HAIRLINE_DE = 3;
107
+
108
+ /**
109
+ * THE OVERLAY STEPS HAVE TO BE SEEN, on every rung.
110
+ *
111
+ * A translucent overlay is one alpha on four grounds, and the alpha is FITTED
112
+ * (the hover token says how): the approved design paints its hover as one
113
+ * opaque colour, and a stronger overlay costs the tertiary text step contrast
114
+ * on the rungs it lightens or darkens most. Left to that trade alone, the
115
+ * cheapest answer for the text is an overlay nobody sees, and the light
116
+ * palette's frame kept asking for it: the design's own light hover sits dE
117
+ * 0.60 off the frame. This is the floor that stops it. A hovered row separates
118
+ * from its rung by dE 3, and a pressed row from a hovered one by dE 3 more,
119
+ * because dE 3 is the floor this file holds every "can a reader notice it"
120
+ * difference to. The marketing root is exempt, as it is from the rungs.
121
+ */
122
+ export const OVERLAY_STEPS = [
123
+ ['a hovered row is visible on every rung', '--color-surface-hover', null],
124
+ ['a pressed row is visible over a hovered one', '--color-surface-pressed', '--color-surface-hover'],
125
+ ];
126
+ export const OVERLAY_DE = 3;
127
+
62
128
  /**
63
129
  * The translucent overlays composited onto each ground. A hovered or pressed
64
130
  * row IS one of these, and so is an inset well.
@@ -84,9 +150,6 @@ export const INK_STEPS = [
84
150
  '--color-feedback-warning-ink',
85
151
  '--color-feedback-danger-ink',
86
152
  '--color-feedback-info-ink',
87
- '--color-phase-onboarding-ink',
88
- '--color-phase-execute-ink',
89
- '--color-phase-review-ink',
90
153
  ];
91
154
 
92
155
  /** A hue used as a MARK, or as a fill behind a label. */
@@ -96,9 +159,6 @@ export const FILL_STEPS = [
96
159
  '--color-feedback-warning',
97
160
  '--color-feedback-danger',
98
161
  '--color-feedback-info',
99
- '--color-phase-onboarding',
100
- '--color-phase-execute',
101
- '--color-phase-review',
102
162
  ];
103
163
 
104
164
  export const STATUS = [
@@ -107,14 +167,42 @@ export const STATUS = [
107
167
  '--color-feedback-danger',
108
168
  '--color-feedback-info',
109
169
  ];
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
170
 
113
- /** A fill with the label a component paints on it. The Button variants. */
171
+ /**
172
+ * The chart series, in the order a figure assigns them: blue, orange, aqua and
173
+ * an ochre yellow, the four the approved design draws.
174
+ *
175
+ * FOUR, because four is what both floors below can hold at once. The design's
176
+ * four clear them with room (its tightest neighbours sit dE 19.8 apart under
177
+ * normal vision), and a fifth would have to sit between two of them in a hue
178
+ * budget the reserved red, the accent and a dichromat's collapse of red onto
179
+ * green have already spent.
180
+ */
181
+ export const DATA = ['--color-data-1', '--color-data-2', '--color-data-3', '--color-data-4'];
182
+
183
+ /**
184
+ * How far apart two NEIGHBOURING series sit: dE 9 under every vision, and dE
185
+ * 15 under normal vision on top of it.
186
+ *
187
+ * Neighbours, because a stacked bar, a legend and a line chart assign series
188
+ * in order and only neighbours touch. The dichromat floor is the budget a
189
+ * palette can afford once deuteranopia has folded red onto green; on its own
190
+ * it let a pair sit dE 9 apart for EVERY reader, which to full-colour vision
191
+ * is two shades of one hue rather than two series. The normal-vision floor is
192
+ * the companion gate of the validated categorical method the design's four
193
+ * hues come from: its worst adjacent pair clears dE 15 unsimulated, so a
194
+ * reader who sees every colour is never the one left squinting at a legend.
195
+ */
196
+ export const DATA_ADJACENT_DE = 9;
197
+ export const DATA_ADJACENT_NORMAL_DE = 15;
198
+
199
+ /**
200
+ * A fill with the label a component paints on it: the primary action at rest,
201
+ * under the pointer and pressed, the danger fill, and the inverse ground. The
202
+ * label on every fill but the last is on-accent, which is white in every
203
+ * palette, and each fill is measured over every ground it can sit on.
204
+ */
114
205
  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
206
  ['--color-text-on-accent', '--color-brand-accent'],
119
207
  ['--color-text-on-accent', '--color-brand-accent-hover'],
120
208
  ['--color-text-on-accent', '--color-brand-accent-active'],
@@ -123,6 +211,45 @@ export const LABEL_ON_FILL = [
123
211
  ['--color-text-inverse', '--color-surface-inverse'],
124
212
  ];
125
213
 
214
+ /**
215
+ * THE PRIMARY ACTION'S THREE FILLS, at rest, under the pointer and pressed,
216
+ * each held against the one before it: [rule, the step, the fill it follows].
217
+ *
218
+ * The primary action IS the accent, in both palettes, and on a filled button
219
+ * the fill is the whole of the feedback, so every step clears two things. It
220
+ * is SEEN: ACTION_DE, the floor this file holds every "can a reader notice
221
+ * it" difference to. And it moves AWAY from its label, which is ACTION_LABEL
222
+ * on every fill: a hover or a press only ever gains contrast. The approved
223
+ * design brightened its hover instead (brightness(1.08)), which is the step
224
+ * TOWARD a white label, and on the dark accent it took white from 4.53:1 to
225
+ * 4.18:1, under the text floor at the moment a reader is about to press. The
226
+ * label rule above catches that number in dark; this rule is what catches the
227
+ * same move in light, where the brightened step still measured 5.01:1 and
228
+ * only its direction was wrong.
229
+ *
230
+ * The DESTRUCTIVE action is the other filled action, and it is held the same
231
+ * way: a toast's destructive action is the danger fill under the same white
232
+ * label, and its hover is --color-feedback-danger-hover. The danger fill is
233
+ * fitted from the design's stopped red and the design draws no hover for it,
234
+ * so this rule is what decides where its hover goes, as it decides the
235
+ * accent's: a step a reader can see, away from the label.
236
+ */
237
+ export const ACTION_STEPS = [
238
+ ['a hovered primary action is a visible step away from its label', '--color-brand-accent-hover', '--color-brand-accent'],
239
+ [
240
+ 'a pressed primary action is a visible step past a hovered one',
241
+ '--color-brand-accent-active',
242
+ '--color-brand-accent-hover',
243
+ ],
244
+ [
245
+ 'a hovered destructive action is a visible step away from its label',
246
+ '--color-feedback-danger-hover',
247
+ '--color-feedback-danger',
248
+ ],
249
+ ];
250
+ export const ACTION_LABEL = '--color-text-on-accent';
251
+ export const ACTION_DE = 3;
252
+
126
253
  /** The ten seeded avatar grounds, each carrying white initials. */
127
254
  export const AVATAR_TINTS = Array.from({ length: 10 }, (_, i) => `--color-avatar-tint-${i}`);
128
255
 
@@ -131,51 +258,80 @@ export const AVATAR_TINTS = Array.from({ length: 10 }, (_, i) => `--color-avatar
131
258
  // ---------------------------------------------------------------------------
132
259
 
133
260
  /**
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.
261
+ * The application rail, and every pair it paints on its own grounds.
262
+ *
263
+ * The rail stands on the FRAME rather than on the sheet, which no other
264
+ * column of text does. Its rows have three grounds: the frame itself, the
265
+ * frame under the hover overlay, and RAISED for the row the reader is on
266
+ * (RAIL_CURRENT_ROW). The badge, a count of what is waiting on the reader, is
267
+ * the one hue in it, an opaque accent fill carrying the on-accent label; a
268
+ * project's key chip is the raised rung inside the plain hairline, carrying
269
+ * the secondary ink, on every row alike.
270
+ *
271
+ * WHAT THIS TABLE IS, HONESTLY. Every ink in it is also reached by a rule
272
+ * above, which measures the same composites over every rung: the text steps
273
+ * over the overlays on the frame, the focus ring over raised, the on-accent
274
+ * label on the accent fill. So none of these can fail on its own. What it is,
275
+ * is the MAP from what the rail paints to the ground it lands on, which the
276
+ * SidebarNav suite in @crewlethq/ui reads the stylesheet's own pairs against,
277
+ * so a rail that spent a token nobody measured there says so by name. The
278
+ * rules the table cannot reach are the current row's own, below.
151
279
  */
152
- export const RAIL_GROUND = '--color-surface-topbar';
280
+ export const RAIL_GROUND = '--color-surface-frame';
281
+
282
+ /**
283
+ * The row the reader is on, as [fill, hairline]: raised, with the plain
284
+ * border drawn round it INSIDE the row, and the primary ink.
285
+ *
286
+ * It is found the way a card is found. Its fill is allowed to be near-flat on
287
+ * the frame, RAIL_CURRENT_LIFT, because in light raised is only dE 1.84 off
288
+ * the frame (the design draws it there); what carries the row is the
289
+ * hairline, held to HAIRLINE_DE against the fill it is drawn on. The hairline
290
+ * is also what tells the current row from a HOVERED one, which draws none: in
291
+ * dark the two fills land dE 1.56 apart, because the fitted hover overlay
292
+ * lifts the frame almost to raised.
293
+ *
294
+ * It used to be the accent's own soft tint with the label in the accent ink.
295
+ * The accent is the primary action's fill now, so a violet row in the rail
296
+ * would read as a second primary on every screen, beside the one the screen
297
+ * actually asks for; where the reader is, is the row's position, its lift and
298
+ * its line.
299
+ */
300
+ export const RAIL_CURRENT_ROW = ['--color-surface-elevated', '--color-border-default'];
301
+ export const RAIL_CURRENT_LIFT = 1.5;
153
302
 
154
303
  /** The three grounds a row in the rail can have, over that ground. */
155
304
  export const RAIL_ROWS = [
156
305
  ['a row', null],
157
306
  ['a hovered row', '--color-surface-hover'],
158
- ['the row the reader is on', '--color-brand-accent-soft'],
307
+ ['the row the reader is on', RAIL_CURRENT_ROW[0]],
159
308
  ];
160
309
 
161
310
  /**
162
- * What the rail paints: the ink, the tint it is drawn on (null for the row's
311
+ * What the rail paints: the ink, the fill it is drawn on (null for the row's
163
312
  * own ground), which rows carry it, and the floor it clears.
164
313
  *
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.
314
+ * The glyph has no entry of its own: it is drawn in the row's colour on
315
+ * every row, at rest as much as pointed or current, so each label entry below
316
+ * is the glyph's too. A resting row is the secondary step, as the approved
317
+ * design draws it, and a hovered or current one lifts to the primary.
169
318
  */
170
319
  export const RAIL_PAINTS = [
171
- ["a row's label", '--color-text-secondary', null, ['a row'], 4.5],
320
+ ["a row's label and glyph", '--color-text-secondary', null, ['a row'], 4.5],
172
321
  ['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],
322
+ ['the current row, label, glyph and count', '--color-text-primary', null, ['the row the reader is on'], 4.5],
323
+ ['a group label and its action, a count, a foot row', '--color-text-tertiary', null, ['a row', 'a hovered row'], 4.5],
324
+ [
325
+ "a project's key chip",
326
+ '--color-text-secondary',
327
+ '--color-surface-elevated',
328
+ ['a row', 'a hovered row', 'the row the reader is on'],
329
+ 4.5,
330
+ ],
175
331
  [
176
- 'the attention count',
177
- '--color-feedback-warning-ink',
178
- '--color-feedback-warning-soft',
332
+ 'the badge, what is waiting on the reader',
333
+ '--color-text-on-accent',
334
+ '--color-brand-accent',
179
335
  ['a row', 'a hovered row', 'the row the reader is on'],
180
336
  4.5,
181
337
  ],
@@ -189,13 +345,17 @@ export const RAIL_PAINTS = [
189
345
  * The scrim is the one ground in the palette that is not a surface step: it is
190
346
  * a wash over whatever is already there, so the pair a reader sees depends on
191
347
  * 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.
348
+ * which is a seeded tint or the initials tile on whichever surface the form
349
+ * sits on, and the tints are not reachable from the surface list. Every ground
350
+ * here is measured because the shallowest one is what sets the number: a 60
351
+ * percent black over a white page is much lighter than the same wash over a
352
+ * dark tint.
353
+ *
354
+ * The accent's fill is not one of them. It was, while a badge could be filled
355
+ * with it; @crewlethq/ui's Avatar has no such tone any more (a SELECTED badge
356
+ * is a ring round a neutral one), so it would be a composite nothing draws.
197
357
  */
198
- export const SCRIM_GROUNDS = ['--color-brand-accent', ...AVATAR_TINTS];
358
+ export const SCRIM_GROUNDS = [...AVATAR_TINTS];
199
359
 
200
360
  // The controls
201
361
  // ---------------------------------------------------------------------------
@@ -204,7 +364,7 @@ export const SCRIM_GROUNDS = ['--color-brand-accent', ...AVATAR_TINTS];
204
364
  * The segmented row, which is the one CONTROL that paints a tint on a tint.
205
365
  *
206
366
  * 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
367
+ * with the card surface, so the label of the chosen answer sits on a
208
368
  * composite of two overlays rather than on a ground any other rule reaches.
209
369
  * It is the label a reader looks for first in the row, so it is held to the
210
370
  * primary step's own floor rather than to the 4.5 a fact needs.
@@ -215,12 +375,13 @@ export const SCRIM_GROUNDS = ['--color-brand-accent', ...AVATAR_TINTS];
215
375
  *
216
376
  * WHAT THIS RULE DOES NOT CLAIM, and the next reader should not assume: that
217
377
  * 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
378
+ * every ground. The face is the card surface and the well is the inset
379
+ * overlay, and on the sheet those land dE 1.17 apart in dark (3.61 in light)
380
+ * and inside a card 2.89 (1.83 in light), against a floor of 3 elsewhere in
381
+ * this file; only on the raised rung are they 5.84 and 5.40 apart. So a
382
+ * segmented row is drawn on the product's own page exactly as it is at
383
+ * :8020, and which option is on is read there from the INK step, primary
384
+ * against tertiary, and from the chip's shadow. Both inks
224
385
  * are measured, which is why this holds the on label to the primary floor
225
386
  * rather than to the 4.5 a fact needs. A restyle that flattened the two ink
226
387
  * steps together would take the last thing saying which option is on.
@@ -269,17 +430,30 @@ export const VEIL = '--color-surface-veil';
269
430
  export const VEIL_BAND = [1.5, 3];
270
431
 
271
432
  /**
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.
433
+ * The ground the veil is drawn in, and the alpha it is drawn at: the only
434
+ * number the veil is free to choose.
435
+ *
436
+ * Its COLOUR is not free: a veil is the application seen through less of
437
+ * itself, so it is each root's own FRAME at this alpha and never a hue of its
438
+ * own. The frame rather than the sheet because the veil covers the whole
439
+ * window, the rail as well as the sheet, and the frame is the ground both of
440
+ * them stand on: the application recedes into its own ground rather than
441
+ * under a film of its page. A veil that is a different colour from what is
442
+ * under it is a wash laid over it, which moves every ground behind it
443
+ * somewhere the ink steps were never measured against, and it does it in the
444
+ * one state where a reader cannot tell a rendering fault from a design.
445
+ *
446
+ * 0.65 is the approved dark scrim's 0.62 moved the least distance into the
447
+ * band for every root. At 0.62 the veiled dark page's own primary text still
448
+ * measured 3.02:1 to 3.15:1 over the four rungs, over the 3:1 ceiling, and the
449
+ * marketing root, near-white on black, needs 0.65 before its brightest
450
+ * composite comes under it. At 0.65 dark measures 2.73:1 to 2.84:1, light
451
+ * 2.18:1 to 2.30:1 and the marketing root 2.75:1 to 2.93:1. The approved light
452
+ * scrim, a dark wash at 0.28, left the light page at 8.35:1 to 9.64:1: a page
453
+ * nobody had faded at all.
281
454
  */
282
- export const VEIL_ALPHA = 0.72;
455
+ export const VEIL_GROUND = '--color-surface-frame';
456
+ export const VEIL_ALPHA = 0.65;
283
457
 
284
458
  // The data surfaces
285
459
  // ---------------------------------------------------------------------------
@@ -354,24 +528,34 @@ export const SHADOW_STEPS = [
354
528
  '--shadow-glow',
355
529
  ];
356
530
 
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
531
+ // Two cross-family rules, and both protect a RESERVED meaning from being
532
+ // claimed by another family: nothing may read as the accent, which is what to
533
+ // act on and what is chosen, and no series may read as the danger red. Their
534
+ // dichromat floors are lower than the within-family ones because the hue
535
+ // budget is finite (nine hues, the accent, four states and four series, in a
536
+ // space deuteranopia collapses to blue, yellow and lightness) and because
537
+ // across families colour is never the only signal: a Tag always renders its
538
+ // label, a status Callout always renders its glyph, a series is named by its
539
+ // legend, and the accent appears only as position. They are floors, not
362
540
  // targets, and every measured value is reported so a later edit shows its cost.
363
- const CROSS_NORMAL = 8;
364
- const CROSS_DICHROMAT = 6;
541
+ //
542
+ // No floor here is about a CATEGORY. A phase, a unit or a model is drawn in the
543
+ // neutral colour with its word, and inside a figure as the series its legend
544
+ // names, so no category family spends this budget. The phase family did: three
545
+ // hues held apart from each other, from the four states and from the accent,
546
+ // which is three hues fewer for everything that carries a meaning.
365
547
  const ACCENT_NORMAL = 10;
366
548
  const ACCENT_DICHROMAT = 8;
367
549
  // 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.
550
+ // because it is the one hue that means the same thing everywhere in a product,
551
+ // and the accent's dichromat floor. Red, orange and green fall onto one axis
552
+ // for a dichromat, so this is the floor the chart's orange and aqua spend the
553
+ // most on: the approved aqua sat dE 0.73 from the approved red under
554
+ // protanopia, and the approved orange 9.08 from it under normal vision in dark
555
+ // and 7.45 in light. The fit moves the red further than the orange or the
556
+ // aqua, darker, because it had to darken for its white label anyway, and in
557
+ // dark it turns toward crimson as well, which spares the orange more than it
558
+ // costs the red.
375
559
  const DANGER_NORMAL = 14;
376
560
  const DANGER_DICHROMAT = 8;
377
561
 
@@ -379,29 +563,65 @@ const DANGER_DICHROMAT = 8;
379
563
  // Theme states
380
564
  // ---------------------------------------------------------------------------
381
565
 
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"]';
566
+ // The theme layer is DARK FIRST: the dark palette is the bare :root, and light
567
+ // is painted over it by two blocks, one for a system that asks for light and
568
+ // one for an explicit choice. There is no dark attribute block, because the
569
+ // bare root already is one; the media block's :not() is what lets an explicit
570
+ // dark beat a light system.
571
+ const THEME_ROOT = ':root';
572
+ const LIGHT_MEDIA = '@media (prefers-color-scheme: light)';
573
+ const LIGHT_MEDIA_SELECTOR = ':root:not([data-theme="dark"])';
574
+ const LIGHT_ATTRIBUTE_SELECTOR = ':root[data-theme="light"]';
575
+
576
+ /**
577
+ * The two sheets, each parsed ONCE, as sources cascade() reads. A measurement
578
+ * reads four states and three theme blocks out of them, and parsing both
579
+ * sheets over again for every read was most of what a runPalette call cost,
580
+ * which scripts/fit-palette.mjs makes tens of thousands of.
581
+ */
582
+ const sheetsOf = ({ tokens, themes }) => ({
583
+ tokens: { name: 'tokens.css', blocks: parseBlocks(tokens) },
584
+ themes: { name: 'themes.css', blocks: parseBlocks(themes) },
585
+ });
586
+
587
+ /** The theme file's three blocks, each on its own, as the file declares it. */
588
+ function themeBlocks(themesSource) {
589
+ const block = (atRule, selector) => cascade([themesSource], (b) => b.atRule === atRule && b.selector === selector);
590
+ return {
591
+ dark: block(null, THEME_ROOT),
592
+ lightMedia: block(LIGHT_MEDIA, LIGHT_MEDIA_SELECTOR),
593
+ lightAttribute: block(null, LIGHT_ATTRIBUTE_SELECTOR),
594
+ };
595
+ }
385
596
 
386
597
  /**
387
598
  * The four sets of values a browser can end up with.
388
599
  *
389
600
  * `base` is tokens.css on its own, which is what an application that imports
390
601
  * only the token layer paints: the marketing palette. The other three are what
391
- * the theme layer paints over it.
602
+ * the theme layer paints over it: `dark`, the bare root, which is what a
603
+ * document that sets nothing gets and what an explicit dark gets on either
604
+ * system; and light twice over, once by the media query and once by the
605
+ * attribute, because they are two declarations a browser reaches by two
606
+ * different routes and either can drift from the other.
392
607
  */
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)]);
608
+ export function paletteStates(sources) {
609
+ return statesOf(sheetsOf(sources));
610
+ }
611
+
612
+ /** paletteStates over sheets already parsed, which runPalette shares with the structural rules. */
613
+ function statesOf(sheets) {
614
+ const tokensOnly = [sheets.tokens];
615
+ const both = [...tokensOnly, sheets.themes];
616
+ const bare = (block) => block.atRule === null && block.selector === THEME_ROOT;
617
+ const dark = cascade(both, bare);
618
+ const withLight = (selector, atRule) =>
619
+ new Map([...dark, ...cascade(both, (block) => block.atRule === atRule && block.selector === selector)]);
400
620
  return {
401
621
  base: cascade(tokensOnly, bare),
402
- light,
403
- 'dark (media query)': withDark(DARK_MEDIA_SELECTOR, DARK_MEDIA),
404
- 'dark (attribute)': withDark(DARK_ATTRIBUTE_SELECTOR, null),
622
+ dark,
623
+ 'light (media query)': withLight(LIGHT_MEDIA_SELECTOR, LIGHT_MEDIA),
624
+ 'light (attribute)': withLight(LIGHT_ATTRIBUTE_SELECTOR, null),
405
625
  };
406
626
  }
407
627
 
@@ -433,9 +653,9 @@ function resolve(values, name, ground) {
433
653
  /**
434
654
  * Run every rule over one state's values.
435
655
  * `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.
656
+ * exempt from the separation, rail, overlay and rung rules: its one pure black
657
+ * ground and its single brand hue are a deliberate marketing look rather than
658
+ * a product palette.
439
659
  */
440
660
  function checkState(state, values, profile, push) {
441
661
  const { ground, opaque, all } = surfacesOf(values);
@@ -456,9 +676,14 @@ function checkState(state, values, profile, push) {
456
676
  // It exists for a hairline glyph and a disabled affordance. The assertion
457
677
  // is that it stays BELOW the fact floor: a step that quietly crept up to
458
678
  // 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`);
679
+ // the separate name prevents. And that it stays ABOVE the band's floor on
680
+ // the three grounds a glyph beside its word is drawn on: the frame (the
681
+ // rail and the drawer), the sheet and a card. Raised is not one of them:
682
+ // it is a chip's or a well's fill, and what a chip carries is its label.
683
+ for (const [name, surface] of opaque.filter(([rung]) => rung !== '--color-surface-elevated')) {
684
+ const ratio = contrast(colour('--color-text-muted'), surface);
685
+ say('text-muted is decoration', ratio > 2.8 && ratio < 4.5, '--color-text-muted', ratio, `2.8 < ${ratio.toFixed(2)}:1 < 4.5 on ${name}`);
686
+ }
462
687
  }
463
688
 
464
689
  for (const name of INK_STEPS) {
@@ -469,7 +694,7 @@ function checkState(state, values, profile, push) {
469
694
  // An ink on its OWN soft tint: a badge, a callout, a diff line. The tint is
470
695
  // recomputed from the fill here rather than read from the token, so a
471
696
  // hand-edited tint that no longer belongs to its fill fails.
472
- for (const fill of [...STATUS, ...PHASE]) {
697
+ for (const fill of STATUS) {
473
698
  const { r, g, b } = colour(fill);
474
699
  const tints = opaque.map(([name, rgb]) => [
475
700
  `${fill} soft on ${name}`,
@@ -499,12 +724,28 @@ function checkState(state, values, profile, push) {
499
724
  }
500
725
 
501
726
  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.
727
+ // Measured over every ground the fill can sit on, so a translucent fill
728
+ // is measured as the composite a reader sees. None is today; the
729
+ // monochrome primary this list used to carry had two.
504
730
  const low = worst(colour(label), opaque.map(([name, rgb]) => [`${fill} on ${name}`, flatten(values.get(fill), rgb)]));
505
731
  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
732
  }
507
733
 
734
+ // THE PRIMARY ACTION'S STEPS. See ACTION_STEPS.
735
+ for (const [rule, step, from] of ACTION_STEPS) {
736
+ const label = colour(ACTION_LABEL);
737
+ const [was, now] = [colour(from), colour(step)];
738
+ const measured = deltaE(now, was);
739
+ const gain = contrast(label, now) - contrast(label, was);
740
+ say(
741
+ rule,
742
+ measured >= ACTION_DE && gain > 0,
743
+ `${step} after ${from}`,
744
+ measured,
745
+ `dE ${measured.toFixed(2)} >= ${ACTION_DE}, and ${ACTION_LABEL} ${contrast(label, was).toFixed(2)}:1 -> ${contrast(label, now).toFixed(2)}:1 rises`,
746
+ );
747
+ }
748
+
508
749
  for (const name of AVATAR_TINTS) {
509
750
  const ratio = contrast(parseHex('#ffffff'), colour(name));
510
751
  say('an avatar tint clears 4.5:1 under white initials', ratio >= 4.5, name, ratio, `${ratio.toFixed(2)}:1`);
@@ -514,8 +755,8 @@ function checkState(state, values, profile, push) {
514
755
  // A word on the scrim. The scrim is a wash rather than a surface, so the
515
756
  // composite is taken over every ground it can be drawn on rather than read
516
757
  // 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.
758
+ // identity badge, and the badge is a seeded tint or an initials tile on
759
+ // whichever surface the form is on.
519
760
  const label = colour('--color-text-on-accent');
520
761
  const scrim = values.get('--color-surface-scrim');
521
762
  const over = [...opaque, ...SCRIM_GROUNDS.map((name) => [name, colour(name)])];
@@ -572,13 +813,13 @@ function checkState(state, values, profile, push) {
572
813
  // THE VEIL, on every ground it can be drawn over. See VEIL.
573
814
  const [floor, ceiling] = VEIL_BAND;
574
815
  const veil = values.get(VEIL);
575
- const own = withAlpha(values.get('--color-surface-background'), VEIL_ALPHA);
816
+ const own = withAlpha(values.get(VEIL_GROUND), VEIL_ALPHA);
576
817
  say(
577
- 'the veil is this root at an alpha, not a colour of its own',
818
+ "the veil is this root's frame at an alpha, not a colour of its own",
578
819
  veil === own,
579
820
  VEIL,
580
821
  VEIL_ALPHA,
581
- `"${veil}" is the root's own ground at ${VEIL_ALPHA} ("${own}")`,
822
+ `"${veil}" is the root's own frame at ${VEIL_ALPHA} ("${own}")`,
582
823
  );
583
824
  const ink = flatten(veil, colour('--color-text-primary'));
584
825
  for (const [name, rgb] of opaque) {
@@ -650,58 +891,53 @@ function checkState(state, values, profile, push) {
650
891
  );
651
892
  }
652
893
  }
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);
894
+ // THE ROW THE READER IS ON, found as a card is. See RAIL_CURRENT_ROW.
895
+ const [fill, line] = RAIL_CURRENT_ROW;
896
+ const current = rows.get('the row the reader is on');
897
+ const lift = deltaE(current, rail);
898
+ say(
899
+ "the rail's current row lifts off the rail",
900
+ lift >= RAIL_CURRENT_LIFT,
901
+ `${fill} on ${RAIL_GROUND}`,
902
+ lift,
903
+ `dE ${lift.toFixed(2)} >= ${RAIL_CURRENT_LIFT}`,
904
+ );
905
+ const drawn = deltaE(flatten(values.get(line), current), current);
660
906
  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`,
907
+ "the hairline round the rail's current row is visible on it",
908
+ drawn >= HAIRLINE_DE,
909
+ `${line} on ${fill}`,
910
+ drawn,
911
+ `dE ${drawn.toFixed(2)} >= ${HAIRLINE_DE}`,
666
912
  );
667
913
  }
668
914
 
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'],
915
+ for (const [set, floor, normal, adjacent, rule] of [
916
+ [STATUS, 10, 10, false, 'status hues stay separable'],
917
+ [DATA, DATA_ADJACENT_DE, DATA_ADJACENT_NORMAL_DE, true, 'adjacent data hues stay separable'],
673
918
  ]) {
674
919
  // ADJACENT, not every pair, for the data ramp: a legend reader
675
920
  // 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.
921
+ // other, and demanding every pair be far apart is what forces a palette
922
+ // to spread until it is ugly. Its second floor is the normal-vision one;
923
+ // see DATA_ADJACENT_NORMAL_DE.
678
924
  const pairs = adjacent
679
925
  ? set.slice(0, -1).map((name, i) => [name, set[i + 1]])
680
926
  : set.flatMap((name, i) => set.slice(i + 1).map((other) => [name, other]));
927
+ const floors = normal > floor ? `${normal} normal, ${floor} every vision` : `${floor}`;
681
928
  for (const [a, b] of pairs) {
682
929
  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`);
930
+ const ok = Math.min(...measured) >= floor && measured[0] >= normal;
931
+ say(rule, ok, `${a} vs ${b}`, Math.min(...measured), `dE ${describe(measured)} >= ${floors}`);
696
932
  }
697
933
  }
698
934
 
699
935
  // Red means "this broke" everywhere in a product. A chart series that
700
936
  // happens to be red says so too, to a reader who is scanning for it, and
701
937
  // 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
938
+ // axis for them, so a chart's aqua or orange series is the likeliest thing
939
+ // in the whole palette to be read as failure. Measured under normal vision
940
+ // alone this rule once passed at dE 24.0 while a green fourth series sat 6.0
705
941
  // from the danger red under deuteranopia.
706
942
  for (const name of DATA) {
707
943
  const measured = separation(colour(name), colour('--color-feedback-danger'));
@@ -709,11 +945,13 @@ function checkState(state, values, profile, push) {
709
945
  say('no data hue collides with danger', ok, name, Math.min(...measured), `dE ${describe(measured)} >= ${DANGER_NORMAL} normal, ${DANGER_DICHROMAT} dichromat`);
710
946
  }
711
947
 
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]) {
948
+ // "The accent is the most saturated hue" was a rule once, and was dropped
949
+ // when danger, both outer phase hues and two data hues out-saturated the
950
+ // indigo it was. It happens to hold of the violet (chroma 23.7 dark, 24.0
951
+ // light, above every state and series), but a ranking is still not what
952
+ // protects the selection: nothing else may READ as it, and that is a
953
+ // distance, measured here under every vision.
954
+ for (const name of [...STATUS, ...DATA]) {
717
955
  const measured = separation(colour(name), colour('--color-brand-accent'));
718
956
  const ok = measured[0] >= ACCENT_NORMAL && Math.min(measured[1], measured[2]) >= ACCENT_DICHROMAT;
719
957
  say('no hue is read as the accent', ok, name, Math.min(...measured), `dE ${describe(measured)} >= ${ACCENT_NORMAL} normal, ${ACCENT_DICHROMAT} dichromat`);
@@ -729,7 +967,7 @@ function checkState(state, values, profile, push) {
729
967
  const fromGround = deltaE(selected, rgb);
730
968
  const fromHover = deltaE(selected, hovered);
731
969
  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`);
970
+ say('the selected tint outreads the hover overlay', ok, `--color-brand-accent-soft on ${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
971
  }
734
972
 
735
973
  {
@@ -740,13 +978,24 @@ function checkState(state, values, profile, push) {
740
978
  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
979
  }
742
980
 
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];
981
+ // Glass is the ground of what floats OVER CONTENT rather than over
982
+ // interface: a canvas's bars and notes, and a chart's reading, which stands
983
+ // beside the mark it reads and so over its neighbours. Each carries words,
984
+ // so every text step is held to 4.5:1 on the composite. The declared worst
985
+ // backdrop is the INVERSE ground, the far end of the palette from the glass
986
+ // itself, which is as far as anything under a floating panel can pull the
987
+ // composite; the accent fill, the primary action on a canvas, and every data
988
+ // hue a chart paints (the residual included) are measured by name beside it,
989
+ // so the reading over a column is a measured case rather than an inference.
990
+ const backdrops = [
991
+ ['the inverse ground', colour('--color-surface-inverse')],
992
+ ['the accent fill', colour('--color-brand-accent')],
993
+ ...[...DATA, '--color-data-other'].map((name) => [name, colour(name)]),
994
+ ...opaque,
995
+ ];
747
996
  for (const [name, backdrop] of backdrops) {
748
997
  const glass = flatten(values.get('--color-surface-glass'), backdrop);
749
- for (const step of ['--color-text-secondary', '--color-text-tertiary']) {
998
+ for (const step of ['--color-text-primary', '--color-text-secondary', '--color-text-tertiary']) {
750
999
  const ratio = contrast(colour(step), glass);
751
1000
  say('text on glass clears 4.5:1', ratio >= 4.5, `${step} over ${name}`, ratio, `${ratio.toFixed(2)}:1`);
752
1001
  }
@@ -759,12 +1008,39 @@ function checkState(state, values, profile, push) {
759
1008
  say('the neutral ramp stays neutral', measured <= 2.2, name, measured, `chroma ${measured.toFixed(2)} <= 2.2`);
760
1009
  }
761
1010
 
1011
+ // THE OVERLAY STEPS. See OVERLAY_STEPS.
1012
+ for (const [rule, overlay, beneath] of OVERLAY_STEPS) {
1013
+ for (const [name, rgb] of opaque) {
1014
+ const from = beneath === null ? rgb : flatten(values.get(beneath), rgb);
1015
+ const measured = deltaE(flatten(values.get(overlay), rgb), from);
1016
+ say(
1017
+ rule,
1018
+ measured >= OVERLAY_DE,
1019
+ `${overlay} on ${name}`,
1020
+ measured,
1021
+ `dE ${measured.toFixed(2)} >= ${OVERLAY_DE} from ${beneath === null ? 'the rung' : `${beneath} on it`}`,
1022
+ );
1023
+ }
1024
+ }
1025
+
1026
+ // THE LADDER. See RUNG_STEPS.
1027
+ for (const [rule, upper, lower, floor] of RUNG_STEPS) {
1028
+ const measured = deltaE(colour(upper), colour(lower));
1029
+ say(rule, measured >= floor, `${upper} over ${lower}`, measured, `dE ${measured.toFixed(2)} >= ${floor}`);
1030
+ }
762
1031
  {
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`);
1032
+ // The other half of the card's step. See CARD_HAIRLINE.
1033
+ const [line, card, sheet] = CARD_HAIRLINE;
1034
+ for (const side of [card, sheet]) {
1035
+ const measured = deltaE(colour(line), colour(side));
1036
+ say(
1037
+ 'the hairline that finds a card is visible on both sides of it',
1038
+ measured >= HAIRLINE_DE,
1039
+ `${line} on ${side}`,
1040
+ measured,
1041
+ `dE ${measured.toFixed(2)} >= ${HAIRLINE_DE}`,
1042
+ );
1043
+ }
768
1044
  }
769
1045
  }
770
1046
 
@@ -777,17 +1053,22 @@ const describe = (measured) => measured.map((value, i) => `${VISIONS[i].slice(0,
777
1053
  function checkStructure(sources, states, push) {
778
1054
  const say = (rule, ok, subject, detail) => push({ state: 'the token files', rule, ok, subject, value: null, detail });
779
1055
 
1056
+ const { dark: darkBlock, lightMedia, lightAttribute: lightBlock } = themeBlocks(sources.themesSource);
1057
+
780
1058
  // 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-'));
1059
+ // is missing for an application that imports the token layer alone. Read
1060
+ // off the three blocks themselves, so a slot only one of them declares is
1061
+ // still asked about.
1062
+ const themed = [...new Set([...darkBlock.keys(), ...lightMedia.keys(), ...lightBlock.keys()])].filter(
1063
+ (name) => name.startsWith('--color-') || name.startsWith('--shadow-'),
1064
+ );
783
1065
  const missing = themed.filter((name) => !states.base.has(name));
784
1066
  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
1067
 
786
1068
  // 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);
1069
+ // a value, inherited from the dark root under it, and that value is the dark
1070
+ // one. Nor a missing DARK slot, which inherits the marketing palette's from
1071
+ // tokens.css. So parity is asserted directly.
791
1072
  const onlyLight = [...lightBlock.keys()].filter((name) => !darkBlock.has(name));
792
1073
  const onlyDark = [...darkBlock.keys()].filter((name) => !lightBlock.has(name));
793
1074
  say(
@@ -797,14 +1078,17 @@ function checkStructure(sources, states, push) {
797
1078
  onlyLight.length === 0 && onlyDark.length === 0 ? 'the two key sets match' : `light only: ${onlyLight.join(', ') || 'none'}; dark only: ${onlyDark.join(', ') || 'none'}`,
798
1079
  );
799
1080
 
800
- // The dark palette is written twice on purpose, once so the system setting
1081
+ // The light palette is written twice on purpose, once so the system setting
801
1082
  // 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('; '));
1083
+ // drifts between them means the toggle paints colours the system setting
1084
+ // does not, and a slot one of them lacks is painted DARK in that one alone,
1085
+ // so the comparison runs from both sides: a slot missing from either block
1086
+ // is drift, not agreement.
1087
+ say('the light media block is present', lightMedia.size > 0, 'themes.css', `${lightMedia.size} declarations`);
1088
+ const drift = [...new Set([...lightMedia.keys(), ...lightBlock.keys()])]
1089
+ .filter((name) => lightMedia.get(name) !== lightBlock.get(name))
1090
+ .map((name) => `${name}: ${lightMedia.get(name) ?? 'undeclared'} by media query vs ${lightBlock.get(name) ?? 'undeclared'} by attribute`);
1091
+ say('the two light blocks agree', drift.length === 0, 'themes.css', drift.length === 0 ? 'no drift' : drift.join('; '));
808
1092
 
809
1093
  // A dark-tuned shadow on a white page is a smudge, so every step is declared
810
1094
  // in both blocks rather than inherited from the token layer, AND the two
@@ -841,14 +1125,15 @@ function checkStructure(sources, states, push) {
841
1125
  * Measure the whole rule table over one built pair of stylesheets.
842
1126
  * Returns every check with its measured value, and the failing subset.
843
1127
  */
844
- export function runPalette({ tokens, themes }) {
845
- const states = paletteStates({ tokens, themes });
1128
+ export function runPalette(sources) {
1129
+ const sheets = sheetsOf(sources);
1130
+ const states = statesOf(sheets);
846
1131
  const checks = [];
847
1132
  const push = (check) => checks.push(check);
848
1133
  for (const [name, values] of Object.entries(states)) {
849
1134
  checkState(name, values, name === 'base' ? 'base' : 'full', push);
850
1135
  }
851
- checkStructure({ themesSource: { name: 'themes.css', css: themes } }, states, push);
1136
+ checkStructure({ themesSource: sheets.themes }, states, push);
852
1137
  return { states, checks, failures: checks.filter((check) => !check.ok) };
853
1138
  }
854
1139