@crewlethq/tokens 0.4.4 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -31
- package/dist/css/base.css +9 -3
- package/dist/css/breakpoint.css +2 -1
- package/dist/css/fonts.css +11 -11
- package/dist/css/legacy.css +11 -9
- package/dist/css/themes.css +267 -300
- package/dist/css/tokens.css +55 -65
- package/dist/index.d.ts +47 -59
- package/dist/index.js +50 -62
- package/dist/themes.d.ts +98 -126
- package/dist/themes.js +98 -126
- package/fonts/OFL.txt +15 -14
- package/fonts/README.md +22 -13
- package/fonts/SHA256SUMS +4 -4
- package/fonts/geist-latin-ext.woff2 +0 -0
- package/fonts/geist-latin.woff2 +0 -0
- package/fonts/geist-mono-latin-ext.woff2 +0 -0
- package/fonts/geist-mono-latin.woff2 +0 -0
- package/package.json +1 -1
- package/test/color.mjs +26 -12
- package/test/palette.d.mts +88 -3
- package/test/palette.mjs +464 -179
- package/test/palette.test.mjs +448 -9
- package/fonts/inter-latin-ext.woff2 +0 -0
- package/fonts/inter-latin.woff2 +0 -0
- package/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/fonts/jetbrains-mono-latin.woff2 +0 -0
package/test/palette.test.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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),
|
|
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
|
|
42
|
-
states
|
|
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('
|
|
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
|
|
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.
|
|
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
|
package/fonts/inter-latin.woff2
DELETED
|
Binary file
|
|
Binary file
|
|
Binary file
|