@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 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
- **Only some colour formats parse.** Hex in 3, 4, 6 and 8 digits; `rgb()`,
461
- `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()` and `oklab()`; and `white` /
462
- `black` / `transparent`. `lab()`, `lch()` and `color-mix()` are not parsed yet.
463
- A value it cannot parse fails, so you hear about it immediately.
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
- **`lab()` and `lch()`**, which need the D50 white point and a chromatic
486
- adaptation step that `oklch()` does not. Completeness rather than reach, so
487
- it sits behind the others. Worth doing the same way when it happens: derive it,
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.7.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 = ['lab', 'lch', 'color-mix', 'color'];
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 is 0-1, or a percentage of that.
164
- const L = parts[0].endsWith('%') ? percent(parts[0]) : NUMBER.test(parts[0]) ? parseFloat(parts[0]) : null;
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 (name === 'oklab') {
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) * 0.4 : NUMBER.test(t) ? parseFloat(t) : null,
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]) * 0.4
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
- return ok([...oklabToRgb(clamp(L, 0, 1), a, b), clamp(alphaValue, 0, 1)]);
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
  }