@josueavalosjim/taste-check 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/package.json +1 -1
- package/schema/config.schema.json +140 -0
- package/src/color.mjs +59 -9
- package/src/config.mjs +20 -0
package/README.md
CHANGED
|
@@ -342,6 +342,23 @@ Point your editor at `schema/config.schema.json` for completion and inline
|
|
|
342
342
|
docs. Paths inside a config resolve against the config file, so it can be run
|
|
343
343
|
from anywhere.
|
|
344
344
|
|
|
345
|
+
Unknown keys are rejected, because a misspelled key that silently does nothing
|
|
346
|
+
is a config that looks like it is working. `$comment` is the exception: it is
|
|
347
|
+
allowed wherever an object is, ignored, and takes a string or an array of them.
|
|
348
|
+
A config you are expected to live with for years should let you write down why
|
|
349
|
+
it is the way it is.
|
|
350
|
+
|
|
351
|
+
```json
|
|
352
|
+
{
|
|
353
|
+
"$comment": "Guards the ramp itself. The browser suite only covers tokens some selector reaches.",
|
|
354
|
+
"contrast": {
|
|
355
|
+
"pairs": [
|
|
356
|
+
{ "$comment": "Documented at 6.13:1 in tokens.css.", "fg": "--ink-muted", "bg": "--bg", "min": 4.5 }
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
345
362
|
| Key | Meaning |
|
|
346
363
|
| --- | --- |
|
|
347
364
|
| `contrast.tokens` | CSS files holding the custom properties. Later files override earlier ones. |
|
|
@@ -457,10 +474,11 @@ component are all invisible here. What this gives you is that the
|
|
|
457
474
|
values in your token file relate to each other the way you said they should. It
|
|
458
475
|
does not prove what a visitor sees.
|
|
459
476
|
|
|
460
|
-
**
|
|
461
|
-
`rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()
|
|
462
|
-
`black` / `transparent`. `
|
|
463
|
-
A value it cannot parse fails, so you hear about it
|
|
477
|
+
**Not every colour format parses.** What does: hex in 3, 4, 6 and 8 digits;
|
|
478
|
+
`rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()`, `oklab()`, `lab()`
|
|
479
|
+
and `lch()`; and `white` / `black` / `transparent`. `color-mix()` and
|
|
480
|
+
`color()` do not. A value it cannot parse fails, so you hear about it
|
|
481
|
+
immediately.
|
|
464
482
|
|
|
465
483
|
An `oklch()` outside the sRGB gamut is clipped rather than gamut-mapped, which
|
|
466
484
|
is what a browser canvas does with it. That was checked rather than assumed:
|
|
@@ -482,10 +500,9 @@ Not built. Written down so the shape is clear.
|
|
|
482
500
|
|
|
483
501
|
**YAML configs**, once there is a reason to take on a parser.
|
|
484
502
|
|
|
485
|
-
**`
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
then check every case against a browser rather than trusting the matrices.
|
|
503
|
+
**`color-mix()` and `color()`**, which are a different shape of problem: one
|
|
504
|
+
needs an interpolation model and the other a colour space argument, so neither
|
|
505
|
+
is another conversion function.
|
|
489
506
|
|
|
490
507
|
## Development
|
|
491
508
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@josueavalosjim/taste-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
@@ -89,11 +89,39 @@
|
|
|
89
89
|
"atRule": {
|
|
90
90
|
"type": "string",
|
|
91
91
|
"description": "Opt into declarations nested in a matching at-rule prelude, for example \"prefers-color-scheme: dark\". Conditional at-rules (@media, @supports, @container, @scope) are ignored without this. Grouping at-rules like @layer are always transparent, so you only name one to narrow a scope to it."
|
|
92
|
+
},
|
|
93
|
+
"$comment": {
|
|
94
|
+
"oneOf": [
|
|
95
|
+
{
|
|
96
|
+
"type": "string"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"type": "array",
|
|
100
|
+
"items": {
|
|
101
|
+
"type": "string"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
92
106
|
}
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
]
|
|
96
110
|
}
|
|
111
|
+
},
|
|
112
|
+
"$comment": {
|
|
113
|
+
"oneOf": [
|
|
114
|
+
{
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"type": "array",
|
|
119
|
+
"items": {
|
|
120
|
+
"type": "string"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
97
125
|
}
|
|
98
126
|
}
|
|
99
127
|
}
|
|
@@ -137,9 +165,37 @@
|
|
|
137
165
|
"items": {
|
|
138
166
|
"type": "string"
|
|
139
167
|
}
|
|
168
|
+
},
|
|
169
|
+
"$comment": {
|
|
170
|
+
"oneOf": [
|
|
171
|
+
{
|
|
172
|
+
"type": "string"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"type": "array",
|
|
176
|
+
"items": {
|
|
177
|
+
"type": "string"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
140
182
|
}
|
|
141
183
|
}
|
|
142
184
|
}
|
|
185
|
+
},
|
|
186
|
+
"$comment": {
|
|
187
|
+
"oneOf": [
|
|
188
|
+
{
|
|
189
|
+
"type": "string"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"type": "array",
|
|
193
|
+
"items": {
|
|
194
|
+
"type": "string"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
],
|
|
198
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
143
199
|
}
|
|
144
200
|
}
|
|
145
201
|
},
|
|
@@ -187,6 +243,20 @@
|
|
|
187
243
|
"items": {
|
|
188
244
|
"type": "string"
|
|
189
245
|
}
|
|
246
|
+
},
|
|
247
|
+
"$comment": {
|
|
248
|
+
"oneOf": [
|
|
249
|
+
{
|
|
250
|
+
"type": "string"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"type": "array",
|
|
254
|
+
"items": {
|
|
255
|
+
"type": "string"
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
190
260
|
}
|
|
191
261
|
}
|
|
192
262
|
},
|
|
@@ -227,6 +297,20 @@
|
|
|
227
297
|
],
|
|
228
298
|
"default": "never",
|
|
229
299
|
"description": "Whether a \"fail\" verdict affects the exit code. Defaults to never: a model's verdict is not reproducible, so it does not gate a build unless you decide it should. Independent of this, a judge that could not run always exits 1."
|
|
300
|
+
},
|
|
301
|
+
"$comment": {
|
|
302
|
+
"oneOf": [
|
|
303
|
+
{
|
|
304
|
+
"type": "string"
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"type": "array",
|
|
308
|
+
"items": {
|
|
309
|
+
"type": "string"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
],
|
|
313
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
230
314
|
}
|
|
231
315
|
}
|
|
232
316
|
},
|
|
@@ -282,6 +366,20 @@
|
|
|
282
366
|
"waitFor": {
|
|
283
367
|
"type": "string",
|
|
284
368
|
"description": "A selector to wait for before measuring."
|
|
369
|
+
},
|
|
370
|
+
"$comment": {
|
|
371
|
+
"oneOf": [
|
|
372
|
+
{
|
|
373
|
+
"type": "string"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"type": "array",
|
|
377
|
+
"items": {
|
|
378
|
+
"type": "string"
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
],
|
|
382
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
285
383
|
}
|
|
286
384
|
}
|
|
287
385
|
}
|
|
@@ -318,11 +416,53 @@
|
|
|
318
416
|
"againstParent": {
|
|
319
417
|
"type": "boolean",
|
|
320
418
|
"description": "Measure against what is behind the element rather than its own background. Use when the thing being measured is a fill: measuring a fill against itself scores 1.00 and means nothing."
|
|
419
|
+
},
|
|
420
|
+
"$comment": {
|
|
421
|
+
"oneOf": [
|
|
422
|
+
{
|
|
423
|
+
"type": "string"
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"type": "array",
|
|
427
|
+
"items": {
|
|
428
|
+
"type": "string"
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
],
|
|
432
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
321
433
|
}
|
|
322
434
|
}
|
|
323
435
|
}
|
|
436
|
+
},
|
|
437
|
+
"$comment": {
|
|
438
|
+
"oneOf": [
|
|
439
|
+
{
|
|
440
|
+
"type": "string"
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"type": "array",
|
|
444
|
+
"items": {
|
|
445
|
+
"type": "string"
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
],
|
|
449
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
324
450
|
}
|
|
325
451
|
}
|
|
452
|
+
},
|
|
453
|
+
"$comment": {
|
|
454
|
+
"oneOf": [
|
|
455
|
+
{
|
|
456
|
+
"type": "string"
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
"type": "array",
|
|
460
|
+
"items": {
|
|
461
|
+
"type": "string"
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
],
|
|
465
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
326
466
|
}
|
|
327
467
|
}
|
|
328
468
|
}
|
package/src/color.mjs
CHANGED
|
@@ -25,7 +25,7 @@ const NAMED = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
/** Formats deliberately not supported in v1, named so the error is useful. */
|
|
28
|
-
const KNOWN_UNSUPPORTED = ['
|
|
28
|
+
const KNOWN_UNSUPPORTED = ['color-mix', 'color'];
|
|
29
29
|
|
|
30
30
|
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)%?$/;
|
|
31
31
|
|
|
@@ -94,6 +94,45 @@ function oklabToRgb(L, a, b) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* CIELAB to sRGB.
|
|
99
|
+
*
|
|
100
|
+
* Longer than the OKLab path because CSS specifies lab() and lch() against the
|
|
101
|
+
* D50 white point while sRGB is D65, so a chromatic adaptation sits in the
|
|
102
|
+
* middle. The matrix below folds the adaptation and the XYZ-to-sRGB step
|
|
103
|
+
* together, which is how CSS Color 4 publishes it.
|
|
104
|
+
*
|
|
105
|
+
* Every constant here is checked against a browser rather than trusted. See
|
|
106
|
+
* the lab corpus in the tests: recalling a colour matrix correctly and
|
|
107
|
+
* recalling it confidently feel identical from the inside.
|
|
108
|
+
*/
|
|
109
|
+
const D50 = [0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585];
|
|
110
|
+
const XYZ_D50_TO_LINEAR_SRGB = [
|
|
111
|
+
[3.1341359569958707, -1.6173863321612538, -0.4906619460083532],
|
|
112
|
+
[-0.9787684456108496, 1.9161415707653082, 0.03344273116131949],
|
|
113
|
+
[0.07203000098937905, -0.22898208700867398, 1.4053851622747988],
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function labToRgb(L, a, b) {
|
|
117
|
+
const e = 216 / 24389;
|
|
118
|
+
const k = 24389 / 27;
|
|
119
|
+
const fy = (L + 16) / 116;
|
|
120
|
+
const fx = a / 500 + fy;
|
|
121
|
+
const fz = fy - b / 200;
|
|
122
|
+
const xr = fx ** 3 > e ? fx ** 3 : (116 * fx - 16) / k;
|
|
123
|
+
const yr = L > k * e ? fy ** 3 : L / k;
|
|
124
|
+
const zr = fz ** 3 > e ? fz ** 3 : (116 * fz - 16) / k;
|
|
125
|
+
const xyz = [xr * D50[0], yr * D50[1], zr * D50[2]];
|
|
126
|
+
const linear = XYZ_D50_TO_LINEAR_SRGB.map((row) => row.reduce((sum, m, i) => sum + m * xyz[i], 0));
|
|
127
|
+
return linear.map((c) => {
|
|
128
|
+
const v =
|
|
129
|
+
c <= 0.0031308
|
|
130
|
+
? 12.92 * c
|
|
131
|
+
: 1.055 * Math.abs(c) ** (1 / 2.4) * Math.sign(c) - 0.055 * Math.sign(c);
|
|
132
|
+
return clamp(v, 0, 1) * 255;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
/** HSL to sRGB. h in turns, s and l in 0-1. */
|
|
98
137
|
function hslToRgb(h, s, l) {
|
|
99
138
|
const f = (n) => {
|
|
@@ -153,29 +192,39 @@ export function parseColor(input) {
|
|
|
153
192
|
const fn = text.match(/^([a-zA-Z-]+)\s*\(([\s\S]*)\)$/);
|
|
154
193
|
if (fn) {
|
|
155
194
|
const name = fn[1].toLowerCase();
|
|
156
|
-
if (name === 'oklch' || name === 'oklab') {
|
|
195
|
+
if (name === 'oklch' || name === 'oklab' || name === 'lab' || name === 'lch') {
|
|
196
|
+
const cie = name === 'lab' || name === 'lch';
|
|
197
|
+
const polarSpace = name === 'oklch' || name === 'lch';
|
|
157
198
|
const [head, ...tail] = fn[2].split('/');
|
|
158
199
|
if (tail.length > 1) return err(`"${text}" has more than one slash`);
|
|
159
200
|
const parts = head.trim().split(/[,\s]+/).filter(Boolean);
|
|
160
201
|
const alphaToken = tail.length ? tail[0].trim() : undefined;
|
|
161
202
|
if (parts.length !== 3) return err(`"${text}" needs three components`);
|
|
162
203
|
|
|
163
|
-
// Lightness
|
|
164
|
-
|
|
204
|
+
// Lightness runs 0-1 in the OK spaces and 0-100 in the CIE ones. A
|
|
205
|
+
// percentage means the same thing in both, scaled to whichever.
|
|
206
|
+
const top = cie ? 100 : 1;
|
|
207
|
+
const L = parts[0].endsWith('%')
|
|
208
|
+
? percent(parts[0]) * top
|
|
209
|
+
: NUMBER.test(parts[0])
|
|
210
|
+
? parseFloat(parts[0])
|
|
211
|
+
: null;
|
|
165
212
|
if (L === null) return err(`"${text}" has a lightness that is not a number`);
|
|
213
|
+
// What 100% means for the other two components, per CSS Color 4.
|
|
214
|
+
const abFull = cie ? 125 : 0.4;
|
|
215
|
+
const chromaFull = cie ? 150 : 0.4;
|
|
166
216
|
|
|
167
217
|
let a;
|
|
168
218
|
let b;
|
|
169
|
-
if (
|
|
170
|
-
// a and b are roughly -0.4 to 0.4; a percentage is relative to 0.4.
|
|
219
|
+
if (!polarSpace) {
|
|
171
220
|
const ab = [parts[1], parts[2]].map((t) =>
|
|
172
|
-
t.endsWith('%') ? percent(t) *
|
|
221
|
+
t.endsWith('%') ? percent(t) * abFull : NUMBER.test(t) ? parseFloat(t) : null,
|
|
173
222
|
);
|
|
174
223
|
if (ab.some((v) => v === null)) return err(`"${text}" has a component that is not a number`);
|
|
175
224
|
[a, b] = ab;
|
|
176
225
|
} else {
|
|
177
226
|
const C = parts[1].endsWith('%')
|
|
178
|
-
? percent(parts[1]) *
|
|
227
|
+
? percent(parts[1]) * chromaFull
|
|
179
228
|
: NUMBER.test(parts[1])
|
|
180
229
|
? parseFloat(parts[1])
|
|
181
230
|
: null;
|
|
@@ -193,7 +242,8 @@ export function parseColor(input) {
|
|
|
193
242
|
if (parsed === null) return err(`"${text}" has an alpha that is not a number`);
|
|
194
243
|
alphaValue = parsed;
|
|
195
244
|
}
|
|
196
|
-
|
|
245
|
+
const rgb = cie ? labToRgb(clamp(L, 0, 100), a, b) : oklabToRgb(clamp(L, 0, 1), a, b);
|
|
246
|
+
return ok([...rgb, clamp(alphaValue, 0, 1)]);
|
|
197
247
|
}
|
|
198
248
|
|
|
199
249
|
const polar = name === 'hsl' || name === 'hsla' || name === 'hwb';
|
package/src/config.mjs
CHANGED
|
@@ -27,8 +27,28 @@ function stringArray(value, where, errors, { required = true } = {}) {
|
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Allowed anywhere an object is, and ignored.
|
|
32
|
+
*
|
|
33
|
+
* The validator rejects keys it does not know, which is right: a misspelled
|
|
34
|
+
* key that silently does nothing is a config that looks like it is working.
|
|
35
|
+
* The cost is that a config had nowhere to say why it exists. Per-pair labels
|
|
36
|
+
* carry the reasoning for a pair, and nothing carried the reasoning for the
|
|
37
|
+
* file. A format people are expected to live with for years should let them
|
|
38
|
+
* write that down.
|
|
39
|
+
*/
|
|
40
|
+
const COMMENT = '$comment';
|
|
41
|
+
|
|
30
42
|
function rejectUnknown(object, allowed, where, errors) {
|
|
31
43
|
for (const key of Object.keys(object)) {
|
|
44
|
+
if (key === COMMENT) {
|
|
45
|
+
const value = object[key];
|
|
46
|
+
const ok =
|
|
47
|
+
typeof value === 'string' ||
|
|
48
|
+
(Array.isArray(value) && value.every((v) => typeof v === 'string'));
|
|
49
|
+
if (!ok) errors.push(`${where}.${COMMENT} must be a string, or an array of them`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
32
52
|
if (!allowed.includes(key)) {
|
|
33
53
|
errors.push(`${where} has an unknown key "${key}". Allowed: ${allowed.join(', ')}.`);
|
|
34
54
|
}
|