@josueavalosjim/taste-check 0.7.0 → 0.8.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
@@ -457,10 +457,11 @@ component are all invisible here. What this gives you is that the
457
457
  values in your token file relate to each other the way you said they should. It
458
458
  does not prove what a visitor sees.
459
459
 
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.
460
+ **Not every colour format parses.** What does: hex in 3, 4, 6 and 8 digits;
461
+ `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()`, `oklab()`, `lab()`
462
+ and `lch()`; and `white` / `black` / `transparent`. `color-mix()` and
463
+ `color()` do not. A value it cannot parse fails, so you hear about it
464
+ immediately.
464
465
 
465
466
  An `oklch()` outside the sRGB gamut is clipped rather than gamut-mapped, which
466
467
  is what a browser canvas does with it. That was checked rather than assumed:
@@ -482,10 +483,9 @@ Not built. Written down so the shape is clear.
482
483
 
483
484
  **YAML configs**, once there is a reason to take on a parser.
484
485
 
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.
486
+ **`color-mix()` and `color()`**, which are a different shape of problem: one
487
+ needs an interpolation model and the other a colour space argument, so neither
488
+ is another conversion function.
489
489
 
490
490
  ## Development
491
491
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
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';