@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.
@@ -3,21 +3,111 @@
3
3
  *
4
4
  * The rules live in palette.mjs, which is published, so this file and a
5
5
  * consumer's own suite measure the same table. What is here is the reading of
6
- * dist/css in IMPORT order, the assertion, and two guards on the suite itself:
7
- * a rule table that measured nothing would pass for any stylesheet.
6
+ * dist/css in IMPORT order, the assertion, and the guards on the suite itself:
7
+ * a rule table that measured nothing would pass for any stylesheet, and a
8
+ * structural rule no edit can trip is a claim rather than a check.
9
+ *
10
+ * It ships in the tarball and runs from an installed copy, so it reads only
11
+ * what the tarball carries: dist/, never the token source.
8
12
  */
9
13
 
10
14
  import { readFileSync } from 'node:fs';
11
15
  import { fileURLToPath } from 'node:url';
12
16
  import assert from 'node:assert/strict';
13
17
  import { describe, test } from 'node:test';
14
- import { describeFailure, paletteStates, runPalette, tightest } from './palette.mjs';
18
+ import {
19
+ ACTION_STEPS,
20
+ CARD_HAIRLINE,
21
+ DATA,
22
+ DATA_ADJACENT_DE,
23
+ DATA_ADJACENT_NORMAL_DE,
24
+ describeFailure,
25
+ fromOklab,
26
+ hex,
27
+ oklabToLinear,
28
+ OPAQUE_SURFACES,
29
+ OVERLAY_STEPS,
30
+ paletteStates,
31
+ parseHex,
32
+ RAIL_CURRENT_ROW,
33
+ runPalette,
34
+ RUNG_STEPS,
35
+ tightest,
36
+ toOklab,
37
+ VEIL_ALPHA,
38
+ withAlpha,
39
+ } from './palette.mjs';
40
+ import { color, themes as typed } from '../dist/index.js';
15
41
 
16
42
  const read = (name) => readFileSync(fileURLToPath(new URL(`../dist/css/${name}`, import.meta.url)), 'utf8');
17
43
  // tokens.css first, themes.css second: they share the :root selector, neither
18
44
  // adds specificity, and the later import is the one that paints.
19
45
  const sources = { tokens: read('tokens.css'), themes: read('themes.css') };
20
46
 
47
+ const STATES = ['base', 'dark', 'light (media query)', 'light (attribute)'];
48
+ const THEME_STATES = STATES.filter((state) => state !== 'base');
49
+
50
+ const DARK_ROOT = ':root {';
51
+ const LIGHT_MEDIA_BLOCK = ':root:not([data-theme="dark"]) {';
52
+ const LIGHT_ATTRIBUTE_BLOCK = ':root[data-theme="light"] {';
53
+
54
+ /**
55
+ * The theme file with ONE of its blocks edited, found by the block's own
56
+ * opening line so the edit cannot land in a neighbouring block. `edit`
57
+ * receives the block's body and returns the new one.
58
+ */
59
+ function editBlock(css, opening, edit) {
60
+ const start = css.indexOf(opening);
61
+ assert.ok(start >= 0, `themes.css has no block opening with ${opening}`);
62
+ const bodyStart = start + opening.length;
63
+ const bodyEnd = css.indexOf('}', bodyStart);
64
+ const body = css.slice(bodyStart, bodyEnd);
65
+ const edited = edit(body);
66
+ assert.notEqual(edited, body, 'the mutation changed nothing, so it proves nothing');
67
+ return css.slice(0, bodyStart) + edited + css.slice(bodyEnd);
68
+ }
69
+
70
+ /**
71
+ * The measured rules a palette fails once ONE declaration is edited in its own
72
+ * blocks (the dark root, or both light blocks alike, so the structural rule
73
+ * that holds them together stays out of it), by name and each once. Every
74
+ * case that uses it edits one declaration and expects exactly the rules it
75
+ * names back, so a mutation that tripped a neighbour as well would say so.
76
+ */
77
+ function failuresAfter(palette, name, value) {
78
+ return [...new Set(checksAfter(palette, name, value).map((check) => check.rule))];
79
+ }
80
+
81
+ /**
82
+ * The failing checks themselves, for a case that asserts what was MEASURED as
83
+ * well as which rule went red, each once: light is edited in both of its
84
+ * blocks, and the attribute block's copy of every check is left out.
85
+ */
86
+ function checksAfter(palette, name, value) {
87
+ const [openings, states] =
88
+ palette === 'dark'
89
+ ? [[DARK_ROOT], ['dark']]
90
+ : [[LIGHT_MEDIA_BLOCK, LIGHT_ATTRIBUTE_BLOCK], ['light (media query)', 'light (attribute)']];
91
+ let themes = sources.themes;
92
+ for (const opening of openings) {
93
+ themes = editBlock(themes, opening, (body) => body.replace(new RegExp(`${name}: [^;]+;`), `${name}: ${value};`));
94
+ }
95
+ const failing = runPalette({ ...sources, themes }).failures.filter((check) => states.includes(check.state));
96
+ const seen = new Set();
97
+ return failing.filter((check) => {
98
+ const key = `${check.rule}|${check.subject}`;
99
+ if (seen.has(key)) return false;
100
+ seen.add(key);
101
+ return true;
102
+ });
103
+ }
104
+
105
+ /** The structural rules a theme file fails, by name. */
106
+ const structuralFailures = (themes) =>
107
+ runPalette({ ...sources, themes })
108
+ .failures.filter((check) => check.state === 'the token files')
109
+ .map((check) => check.rule);
110
+
21
111
  describe('the palette', () => {
22
112
  const { checks, failures } = runPalette(sources);
23
113
 
@@ -32,24 +122,373 @@ describe('the palette', () => {
32
122
  // A rule table that read an empty token map would report no failures for
33
123
  // any stylesheet at all, which is the one way this suite can lie.
34
124
  const states = paletteStates(sources);
35
- assert.deepEqual(Object.keys(states), ['base', 'light', 'dark (media query)', 'dark (attribute)']);
125
+ assert.deepEqual(Object.keys(states), STATES);
36
126
  for (const [name, values] of Object.entries(states)) {
37
127
  assert.ok(values.size > 80, `${name} resolved only ${values.size} tokens`);
38
128
  assert.ok(values.has('--color-text-primary'), `${name} has no --color-text-primary`);
39
129
  }
40
130
  assert.notEqual(
41
- states.light.get('--color-surface-background'),
42
- states['dark (attribute)'].get('--color-surface-background'),
131
+ states['light (attribute)'].get('--color-surface-background'),
132
+ states.dark.get('--color-surface-background'),
43
133
  'the light and dark states resolved to the same page colour, so the theme blocks were not read',
44
134
  );
45
135
  assert.ok(checks.length > 400, `only ${checks.length} checks ran`);
46
136
  });
47
137
 
48
- test('the theme layer wins over the token layer', () => {
138
+ test('every measured rule runs in each theme state, and the marketing root runs its share', () => {
139
+ // Each state is its own route through the cascade, so a rule that ran in
140
+ // one light state and not the other would leave a whole route unmeasured.
141
+ // The marketing root is exempt from the product palette's separation and
142
+ // rail rules by design (see checkState), so what it owes is the part of
143
+ // the table it is held to.
144
+ const byRule = new Map();
145
+ for (const check of checks) {
146
+ if (check.state === 'the token files') continue;
147
+ byRule.set(check.rule, (byRule.get(check.rule) ?? new Set()).add(check.state));
148
+ }
149
+ assert.ok(byRule.size > 20, `only ${byRule.size} measured rules ran`);
150
+ for (const [rule, states] of byRule) {
151
+ for (const state of THEME_STATES) assert.ok(states.has(state), `"${rule}" never ran in the ${state} state`);
152
+ }
153
+ const onBase = [...byRule].filter(([, states]) => states.has('base')).map(([rule]) => rule);
154
+ assert.ok(onBase.length > 10, `only ${onBase.length} rules ran on the marketing root`);
155
+ assert.ok(onBase.includes('text step clears its floor'));
156
+ });
157
+
158
+ test('the theme layer is dark first, and wins over the token layer', () => {
49
159
  // themes.css is imported second on purpose. If that ever stopped being
50
- // true the light state would silently be the marketing palette.
160
+ // true the dark state would silently be the marketing palette; and if the
161
+ // bare root stopped being the dark palette, a document that sets nothing
162
+ // would stop being drawn in the palette the product is designed in.
51
163
  const states = paletteStates(sources);
52
164
  assert.equal(states.base.get('--color-surface-background'), '#000000');
53
- assert.equal(states.light.get('--color-surface-background'), '#ffffff');
165
+ assert.equal(states.dark.get('--color-surface-background'), typed.dark.color.surface.background);
166
+ for (const state of ['light (media query)', 'light (attribute)']) {
167
+ assert.equal(states[state].get('--color-surface-background'), typed.light.color.surface.background, state);
168
+ }
169
+ });
170
+ });
171
+
172
+ describe('the structure of the theme file', () => {
173
+ test('the file as built passes every structural rule', () => {
174
+ // The control for every case below: each mutation has to be the only
175
+ // thing that turned its rule red.
176
+ assert.deepEqual(structuralFailures(sources.themes), []);
177
+ });
178
+
179
+ test('a light slot that drifts in ONE light block is caught, whichever block it is', () => {
180
+ for (const opening of [LIGHT_MEDIA_BLOCK, LIGHT_ATTRIBUTE_BLOCK]) {
181
+ const themes = editBlock(sources.themes, opening, (body) =>
182
+ body.replace(/--color-text-primary: [^;]+;/, '--color-text-primary: #123456;'),
183
+ );
184
+ assert.deepEqual(structuralFailures(themes), ['the two light blocks agree'], opening);
185
+ }
186
+ });
187
+
188
+ test('a slot only one light block declares is drift, not agreement', () => {
189
+ // Dropped from the media block, the slot is painted DARK for a reader on
190
+ // a light system and light for one who chose light. A comparison that
191
+ // walked only the media block's own keys would call that agreement.
192
+ const themes = editBlock(sources.themes, LIGHT_MEDIA_BLOCK, (body) =>
193
+ body.replace(/\s*--color-text-primary: [^;]+;/, ''),
194
+ );
195
+ assert.deepEqual(structuralFailures(themes), ['the two light blocks agree']);
196
+ });
197
+
198
+ test('a theme file with no light media block is caught', () => {
199
+ const start = sources.themes.indexOf('@media (prefers-color-scheme: light)');
200
+ const end = sources.themes.indexOf('\n}\n', start) + 3;
201
+ assert.ok(start >= 0 && end > start, 'themes.css has no light media block to remove');
202
+ const themes = sources.themes.slice(0, start) + sources.themes.slice(end);
203
+ assert.deepEqual(structuralFailures(themes), ['the light media block is present', 'the two light blocks agree']);
204
+ });
205
+
206
+ test('a slot the dark root lacks is caught', () => {
207
+ // Missing from the bare root, the slot falls through to the MARKETING
208
+ // palette's value in tokens.css, which no measured rule can tell apart
209
+ // from a dark value somebody chose.
210
+ const themes = editBlock(sources.themes, DARK_ROOT, (body) => body.replace(/\s*--color-text-primary: [^;]+;/, ''));
211
+ assert.deepEqual(structuralFailures(themes), ['the light block declares exactly the key set the dark block declares']);
212
+ });
213
+ });
214
+
215
+ describe('the four rungs', () => {
216
+ const { checks } = runPalette(sources);
217
+ const dark = typed.dark.color;
218
+
219
+ const darkFailures = (name, value) => failuresAfter('dark', name, value);
220
+
221
+ test('four opaque rungs are measured, in every theme state', () => {
222
+ // A ladder that lost a rung, or a rung that resolved to nothing a rule
223
+ // could measure, would leave every rule above reading fewer grounds and
224
+ // passing for it.
225
+ assert.deepEqual(OPAQUE_SURFACES, [
226
+ '--color-surface-frame',
227
+ '--color-surface-background',
228
+ '--color-surface-subtle',
229
+ '--color-surface-elevated',
230
+ ]);
231
+ const states = paletteStates(sources);
232
+ for (const state of THEME_STATES) {
233
+ for (const rung of OPAQUE_SURFACES) {
234
+ assert.ok(parseHex(states[state].get(rung) ?? ''), `${state}: ${rung} is not an opaque colour`);
235
+ for (const rule of ['the neutral ramp stays neutral', OVERLAY_STEPS[0][0]]) {
236
+ assert.ok(
237
+ checks.some((check) => check.state === state && check.rule === rule && (check.subject === rung || check.subject.endsWith(` on ${rung}`))),
238
+ `${state}: "${rule}" never measured ${rung}`,
239
+ );
240
+ }
241
+ }
242
+ const ladder = checks.filter((check) => check.state === state && RUNG_STEPS.some(([rule]) => rule === check.rule));
243
+ assert.equal(ladder.length, RUNG_STEPS.length, `${state} ran ${ladder.length} steps of the ladder`);
244
+ }
245
+ });
246
+
247
+ test('a sheet at the frame\'s own value is caught', () => {
248
+ assert.deepEqual(darkFailures('--color-surface-background', dark.surface.frame), ['the sheet lifts off the frame']);
249
+ });
250
+
251
+ test('a card at the sheet\'s own value is caught', () => {
252
+ assert.deepEqual(darkFailures('--color-surface-subtle', dark.surface.background), ['a card separates from the sheet']);
253
+ });
254
+
255
+ test('a hairline at the card\'s own value is caught', () => {
256
+ const [line] = CARD_HAIRLINE;
257
+ assert.deepEqual(darkFailures(line, dark.surface.subtle), ['the hairline that finds a card is visible on both sides of it']);
258
+ });
259
+
260
+ test('raised at the card\'s own value is caught', () => {
261
+ assert.deepEqual(darkFailures('--color-surface-elevated', dark.surface.subtle), ['raised separates from the card']);
262
+ });
263
+
264
+ test('a hover overlay nobody can see is caught, and so is a press that looks like a hover', () => {
265
+ assert.deepEqual(darkFailures('--color-surface-hover', withAlpha(dark.surface.hover, 0.01)), [OVERLAY_STEPS[0][0]]);
266
+ assert.deepEqual(darkFailures('--color-surface-pressed', dark.surface.hover), [OVERLAY_STEPS[1][0]]);
267
+ });
268
+
269
+ test('a veil drawn in the sheet rather than the frame is caught', () => {
270
+ assert.deepEqual(darkFailures('--color-surface-veil', withAlpha(dark.surface.background, VEIL_ALPHA)), [
271
+ "the veil is this root's frame at an alpha, not a colour of its own",
272
+ ]);
273
+ });
274
+ });
275
+
276
+ describe('the accent and the primary action', () => {
277
+ const { checks } = runPalette(sources);
278
+ const LABEL_RULE = 'a label clears 4.5:1 on its own fill';
279
+ const [HOVER_RULE, PRESS_RULE] = ACTION_STEPS.map(([rule]) => rule);
280
+
281
+ /**
282
+ * What `filter: brightness(1.08)` does to a colour, which is the hover the
283
+ * approved design drew: each sRGB channel scaled and clipped.
284
+ */
285
+ const brightened = (value) => {
286
+ const { r, g, b } = parseHex(value);
287
+ return hex({ r: Math.min(255, r * 1.08), g: Math.min(255, g * 1.08), b: Math.min(255, b * 1.08) });
288
+ };
289
+
290
+ test('each of the three fills is measured under its label, and each step against the one before it, in every state', () => {
291
+ // A table that silently stopped measuring one of the three would pass the
292
+ // one step that was ever wrong.
293
+ for (const state of STATES) {
294
+ for (const fill of ['--color-brand-accent', '--color-brand-accent-hover', '--color-brand-accent-active']) {
295
+ assert.ok(
296
+ checks.some((check) => check.state === state && check.rule === LABEL_RULE && check.subject === `--color-text-on-accent on ${fill}`),
297
+ `${state}: the label was never measured on ${fill}`,
298
+ );
299
+ }
300
+ for (const rule of [HOVER_RULE, PRESS_RULE]) {
301
+ assert.equal(checks.filter((check) => check.state === state && check.rule === rule).length, 1, `${state}: ${rule}`);
302
+ }
303
+ }
304
+ });
305
+
306
+ test("the approved design's brightening hover is caught in both palettes", () => {
307
+ // In dark it takes the white label under the text floor (4.18:1), which
308
+ // the label rule sees; in light it still clears 5.01:1, and only the step
309
+ // rule sees that it moved toward the label rather than away from it.
310
+ assert.equal(brightened(typed.dark.color.brand.accent), '#865dff');
311
+ assert.deepEqual(failuresAfter('dark', '--color-brand-accent-hover', brightened(typed.dark.color.brand.accent)), [
312
+ LABEL_RULE,
313
+ HOVER_RULE,
314
+ ]);
315
+ assert.deepEqual(failuresAfter('light', '--color-brand-accent-hover', brightened(typed.light.color.brand.accent)), [
316
+ HOVER_RULE,
317
+ ]);
318
+ });
319
+
320
+ test('a step nobody can see is caught, even one in the right direction', () => {
321
+ // Darker by dE 1: the label still gains, so only the visibility floor
322
+ // stands between this and a button that does not answer the pointer.
323
+ const barely = (value) => {
324
+ const lab = toOklab(parseHex(value));
325
+ return hex(fromOklab({ ...lab, L: lab.L - 0.01 }));
326
+ };
327
+ const { accent, accentHover } = typed.dark.color.brand;
328
+ assert.deepEqual(failuresAfter('dark', '--color-brand-accent-hover', barely(accent)), [HOVER_RULE]);
329
+ assert.deepEqual(failuresAfter('dark', '--color-brand-accent-active', barely(accentHover)), [PRESS_RULE]);
330
+ // And a press that IS the hover moves nowhere at all.
331
+ assert.deepEqual(failuresAfter('dark', '--color-brand-accent-active', accentHover), [PRESS_RULE]);
332
+ });
333
+
334
+ test('a focus ring drawn in the dark accent itself is caught', () => {
335
+ // The accent cannot clear 3:1 on the lightest dark ground and still carry
336
+ // a white label, which is why the ring is its own step.
337
+ assert.deepEqual(failuresAfter('dark', '--color-focus', typed.dark.color.brand.accent), ['the focus ring clears 3:1']);
338
+ });
339
+
340
+ test('the selected tint is named as what its check measures, on each rung', () => {
341
+ // Every measured check names the thing it measures first and the ground it
342
+ // measured it on after, so a failure reads as what to change. This one
343
+ // named the rung alone, which is the one thing a fix must not move.
344
+ for (const state of THEME_STATES) {
345
+ assert.deepEqual(
346
+ checks.filter((check) => check.state === state && check.rule === 'the selected tint outreads the hover overlay').map((check) => check.subject),
347
+ OPAQUE_SURFACES.map((rung) => `--color-brand-accent-soft on ${rung}`),
348
+ state,
349
+ );
350
+ }
351
+ });
352
+
353
+ test("the rail's current row is caught when its hairline or its lift is lost", () => {
354
+ const [fill, line] = RAIL_CURRENT_ROW;
355
+ assert.deepEqual([fill, line], ['--color-surface-elevated', '--color-border-default']);
356
+ // The line drawn in the row's own colour is no line at all.
357
+ assert.deepEqual(failuresAfter('dark', line, typed.dark.color.surface.elevated), [
358
+ "the hairline round the rail's current row is visible on it",
359
+ ]);
360
+ // Raised two steps off the light frame: every other rule raised is held
361
+ // to still holds, and the row is no longer there.
362
+ const { r, g, b } = parseHex(typed.light.color.surface.frame);
363
+ assert.deepEqual(failuresAfter('light', fill, hex({ r: r + 2, g: g + 2, b: b + 2 })), [
364
+ "the rail's current row lifts off the rail",
365
+ ]);
366
+ });
367
+ });
368
+
369
+ describe('the state and chart hues', () => {
370
+ const { checks } = runPalette(sources);
371
+ const LABEL_RULE = 'a label clears 4.5:1 on its own fill';
372
+ const STATUS_RULE = 'status hues stay separable';
373
+ const DANGER_RULE = 'no data hue collides with danger';
374
+ const MARK_RULE = 'fill step clears 3:1 as a mark';
375
+ const ADJACENT_RULE = 'adjacent data hues stay separable';
376
+ const DESTRUCTIVE_RULE = ACTION_STEPS[2][0];
377
+ const summary = (check) => `${check.rule}: ${check.subject}: ${check.detail}`;
378
+
379
+ test('the chart ramp is four series, in every theme state and in the typed export', () => {
380
+ // A fifth series would have to sit between two of the four in a hue
381
+ // budget the reserved red and the accent have spent, so it is the
382
+ // residual; a state that still resolved a fifth would be a hue nothing
383
+ // measures against its neighbours.
384
+ assert.deepEqual(DATA, ['--color-data-1', '--color-data-2', '--color-data-3', '--color-data-4']);
385
+ for (const [state, values] of Object.entries(paletteStates(sources))) {
386
+ for (const name of DATA) assert.ok(parseHex(values.get(name) ?? ''), `${state}: ${name} is not an opaque colour`);
387
+ assert.equal(values.get('--color-data-5'), undefined, `${state} still declares --color-data-5`);
388
+ }
389
+ for (const palette of ['dark', 'light']) {
390
+ assert.deepEqual(Object.keys(typed[palette].color.data), ['1', '2', '3', '4', 'other'], palette);
391
+ }
392
+ for (const state of THEME_STATES) {
393
+ const pairs = checks.filter((check) => check.state === state && check.rule === ADJACENT_RULE).map((check) => check.subject);
394
+ assert.deepEqual(pairs, ['--color-data-1 vs --color-data-2', '--color-data-2 vs --color-data-3', '--color-data-3 vs --color-data-4'], state);
395
+ }
396
+ });
397
+
398
+ test('a phase is a category, so no palette spends a hue on one', () => {
399
+ // A phase, like a unit or a model, is drawn in the neutral colour with its
400
+ // word, and inside a figure as the series its legend names. A category
401
+ // family would spend hues the rule table holds apart for meanings, and a
402
+ // family the table did not name would ship with nothing measuring it at
403
+ // all, so the removed one stays removed in every state and in the typed
404
+ // export, where an application reads a colour by name.
405
+ for (const [state, values] of Object.entries(paletteStates(sources))) {
406
+ assert.deepEqual([...values.keys()].filter((name) => name.startsWith('--color-phase-')), [], state);
407
+ }
408
+ assert.equal(Object.hasOwn(color, 'phase'), false, 'color');
409
+ for (const palette of ['dark', 'light']) assert.equal(Object.hasOwn(typed[palette].color, 'phase'), false, palette);
410
+ });
411
+
412
+ test("the design's stopped red is caught by its label, by deuteranopia and by the chart hues", () => {
413
+ // The approved #f26d6d carries a destructive action's white label at
414
+ // 2.92:1, and a deuteranopic reader finds it dE 5.7 from done (5.59
415
+ // against the design's own green, before either moved). It also sits
416
+ // under the floors the reserved red keeps from the orange, the aqua
417
+ // (protanopia 2.0) and the yellow.
418
+ const failing = checksAfter('dark', '--color-feedback-danger', '#f26d6d').map(summary);
419
+ assert.deepEqual(failing, [
420
+ `${LABEL_RULE}: --color-text-on-accent on --color-feedback-danger: 2.92:1 (worst on --color-feedback-danger on --color-surface-frame)`,
421
+ `${STATUS_RULE}: --color-feedback-success vs --color-feedback-danger: dE n30.4/p15.2/d5.7 >= 10`,
422
+ `${DANGER_RULE}: --color-data-2: dE n7.8/p8.3/d7.3 >= 14 normal, 8 dichromat`,
423
+ `${DANGER_RULE}: --color-data-3: dE n28.7/p2.0/d14.0 >= 14 normal, 8 dichromat`,
424
+ `${DANGER_RULE}: --color-data-4: dE n13.6/p8.9/d7.0 >= 14 normal, 8 dichromat`,
425
+ ]);
426
+ });
427
+
428
+ test("the design's light yellow is caught as a mark nobody can see on the frame", () => {
429
+ // #eda100 on the light frame is 1.85:1, the palest mark the design draws,
430
+ // and the only rule it breaks: a yellow dark enough to be seen on a light
431
+ // page is an ochre.
432
+ assert.deepEqual(checksAfter('light', '--color-data-4', '#eda100').map(summary), [
433
+ `${MARK_RULE}: --color-data-4: 1.85:1 (worst on --color-surface-frame)`,
434
+ ]);
435
+ });
436
+
437
+ test('a neighbour that clears every dichromat floor is still caught under normal vision', () => {
438
+ // An olive fourth series beside the aqua third: dE 12.1 apart under both
439
+ // protanopia and deuteranopia, which the dichromat floor alone passes,
440
+ // and 11.6 under normal vision, two shades of one hue to every reader who
441
+ // sees colour. Nothing else it touches fails, so the normal-vision floor
442
+ // is the only thing standing between it and a chart.
443
+ const failing = checksAfter('dark', '--color-data-4', '#5a8100');
444
+ assert.deepEqual(failing.map(summary), [
445
+ `${ADJACENT_RULE}: --color-data-3 vs --color-data-4: dE n11.6/p12.1/d12.1 >= ${DATA_ADJACENT_NORMAL_DE} normal, ${DATA_ADJACENT_DE} every vision`,
446
+ ]);
447
+ assert.ok(failing[0].value >= DATA_ADJACENT_DE, 'the mutation has to clear the dichromat floor, or it proves nothing about the normal one');
448
+ });
449
+
450
+ test('a destructive action whose hover is its rest state is caught, in both palettes', () => {
451
+ // The danger fill carries a white label on a toast's destructive action,
452
+ // and its hover is the whole of the feedback there: a hover that IS the
453
+ // fill does not answer the pointer, and nothing else measures that.
454
+ for (const palette of ['dark', 'light']) {
455
+ const fill = typed[palette].color.feedback.danger;
456
+ assert.deepEqual(failuresAfter(palette, '--color-feedback-danger-hover', fill), [DESTRUCTIVE_RULE], palette);
457
+ }
458
+ for (const state of STATES) {
459
+ assert.equal(checks.filter((check) => check.state === state && check.rule === DESTRUCTIVE_RULE).length, 1, state);
460
+ }
461
+ });
462
+ });
463
+
464
+ describe('the colour maths', () => {
465
+ test('a colour a screen shows round-trips through OKLab, and one past the gamut says so', () => {
466
+ // fromOklab reads its channels from oklabToLinear and clips them, so a
467
+ // colour inside sRGB comes back as the same hex with every linear channel
468
+ // in [0, 1]. A colour past what sRGB can show has a channel outside, which
469
+ // is the one thing a search that walks chroma needs to know: clipped one
470
+ // channel at a time, it would come back a different hue without a word.
471
+ // The published matrices are good to seven places, so white and a channel
472
+ // at zero land within a millionth of the edge rather than on it.
473
+ for (const value of ['#000000', '#ffffff', '#7c56ff', '#101013', '#c98500', '#0f766e']) {
474
+ const lab = toOklab(parseHex(value));
475
+ assert.equal(hex(fromOklab(lab)), value);
476
+ assert.ok(Object.values(oklabToLinear(lab)).every((c) => c >= -1e-6 && c <= 1 + 1e-6), `${value} left the gamut`);
477
+ }
478
+ // Past the gamut on each side of each channel in turn, the grey that stays
479
+ // inside it on the other two: the channel says so and the others do not,
480
+ // so no one channel can be clipped where the others would give it away.
481
+ for (const channel of ['r', 'g', 'b']) {
482
+ for (const [value, outside] of [
483
+ [300, (c) => c > 1],
484
+ [-30, (c) => c < 0],
485
+ ]) {
486
+ const linear = oklabToLinear(toOklab({ r: 128, g: 128, b: 128, [channel]: value }));
487
+ assert.ok(outside(linear[channel]), `${channel} at ${value} came back as ${linear[channel]}`);
488
+ for (const other of ['r', 'g', 'b'].filter((name) => name !== channel)) {
489
+ assert.ok(linear[other] > 0 && linear[other] < 1, `${other} left the gamut with ${channel} at ${value}`);
490
+ }
491
+ }
492
+ }
54
493
  });
55
494
  });
Binary file
Binary file
Binary file