@josueavalosjim/taste-check 0.3.0 → 0.4.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
@@ -265,10 +265,15 @@ values in your token file relate to each other the way you said they should. It
265
265
  does not prove what a visitor sees.
266
266
 
267
267
  **Only some colour formats parse.** Hex in 3, 4, 6 and 8 digits; `rgb()`,
268
- `rgba()`, `hsl()`, `hsla()` and `hwb()`, each in both the comma and the space
269
- syntax; and `white` / `black` / `transparent`. The perceptual spaces
270
- (`oklch()`, `lab()`, `lch()`) and `color-mix()` are not parsed yet. A value it
271
- cannot parse fails, so you hear about it immediately.
268
+ `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()` and `oklab()`; and `white` /
269
+ `black` / `transparent`. `lab()`, `lch()` and `color-mix()` are not parsed yet.
270
+ A value it cannot parse fails, so you hear about it immediately.
271
+
272
+ An `oklch()` outside the sRGB gamut is clipped rather than gamut-mapped, which
273
+ is what a browser canvas does with it. That was checked rather than assumed:
274
+ the test corpus has fifty deliberately out-of-gamut colours painted in a real
275
+ browser and read back as pixels, and the parser agrees with all of them to
276
+ within one channel unit.
272
277
 
273
278
  **There is no specificity resolution.** Scopes apply in the order you list
274
279
  them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
@@ -287,9 +292,9 @@ in a real browser, as an optional peer dependency so the core stays free of one.
287
292
 
288
293
  **YAML configs**, once there is a reason to take on a parser.
289
294
 
290
- **The perceptual colour spaces**, `oklch()` first, which needs real colour
291
- space conversion and browser-checked tests rather than a formula taken on
292
- trust.
295
+ **`lab()` and `lch()`**, which need the D50 white point and a chromatic
296
+ adaptation step that `oklch()` does not. Worth doing the same way: derive it,
297
+ then check every case against a browser rather than trusting the matrices.
293
298
 
294
299
  ## Development
295
300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic design-system checks: WCAG contrast over your own tokens, and a one-off value linter over your own approved list. No palette, no class list, no opinions shipped.",
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', 'oklab', 'oklch', 'color-mix', 'color'];
28
+ const KNOWN_UNSUPPORTED = ['lab', 'lch', 'color-mix', 'color'];
29
29
 
30
30
  const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)%?$/;
31
31
 
@@ -65,6 +65,35 @@ function percent(token) {
65
65
  return parseFloat(token) / 100;
66
66
  }
67
67
 
68
+ /**
69
+ * OKLab to sRGB, via linear sRGB.
70
+ *
71
+ * The matrices are Björn Ottosson's, which is what CSS Color 4 specifies.
72
+ * They are checked against a browser rather than trusted: see the oklch corpus
73
+ * in the tests, generated by painting each colour on a canvas and reading the
74
+ * pixel back.
75
+ */
76
+ function oklabToRgb(L, a, b) {
77
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
78
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
79
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
80
+ const l = l_ * l_ * l_;
81
+ const m = m_ * m_ * m_;
82
+ const q = s_ * s_ * s_;
83
+ const linear = [
84
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * q,
85
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * q,
86
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * q,
87
+ ];
88
+ // Linear to gamma-encoded sRGB, then clipped into gamut. Clipping is the
89
+ // simple choice and it is not what a browser does for wildly out-of-gamut
90
+ // colours, which is why parseColor reports whether it had to clip.
91
+ return linear.map((c) => {
92
+ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.abs(c) ** (1 / 2.4) * Math.sign(c) - 0.055 * Math.sign(c);
93
+ return clamp(v, 0, 1) * 255;
94
+ });
95
+ }
96
+
68
97
  /** HSL to sRGB. h in turns, s and l in 0-1. */
69
98
  function hslToRgb(h, s, l) {
70
99
  const f = (n) => {
@@ -124,6 +153,49 @@ export function parseColor(input) {
124
153
  const fn = text.match(/^([a-zA-Z-]+)\s*\(([\s\S]*)\)$/);
125
154
  if (fn) {
126
155
  const name = fn[1].toLowerCase();
156
+ if (name === 'oklch' || name === 'oklab') {
157
+ const [head, ...tail] = fn[2].split('/');
158
+ if (tail.length > 1) return err(`"${text}" has more than one slash`);
159
+ const parts = head.trim().split(/[,\s]+/).filter(Boolean);
160
+ const alphaToken = tail.length ? tail[0].trim() : undefined;
161
+ if (parts.length !== 3) return err(`"${text}" needs three components`);
162
+
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;
165
+ if (L === null) return err(`"${text}" has a lightness that is not a number`);
166
+
167
+ let a;
168
+ let b;
169
+ if (name === 'oklab') {
170
+ // a and b are roughly -0.4 to 0.4; a percentage is relative to 0.4.
171
+ const ab = [parts[1], parts[2]].map((t) =>
172
+ t.endsWith('%') ? percent(t) * 0.4 : NUMBER.test(t) ? parseFloat(t) : null,
173
+ );
174
+ if (ab.some((v) => v === null)) return err(`"${text}" has a component that is not a number`);
175
+ [a, b] = ab;
176
+ } else {
177
+ const C = parts[1].endsWith('%')
178
+ ? percent(parts[1]) * 0.4
179
+ : NUMBER.test(parts[1])
180
+ ? parseFloat(parts[1])
181
+ : null;
182
+ if (C === null) return err(`"${text}" has a chroma that is not a number`);
183
+ const h = hue(parts[2]);
184
+ if (h === null) return err(`"${text}" has a hue that is not an angle`);
185
+ const radians = h * 2 * Math.PI;
186
+ a = Math.max(C, 0) * Math.cos(radians);
187
+ b = Math.max(C, 0) * Math.sin(radians);
188
+ }
189
+
190
+ let alphaValue = 1;
191
+ if (alphaToken !== undefined) {
192
+ const parsed = alpha(alphaToken);
193
+ if (parsed === null) return err(`"${text}" has an alpha that is not a number`);
194
+ alphaValue = parsed;
195
+ }
196
+ return ok([...oklabToRgb(clamp(L, 0, 1), a, b), clamp(alphaValue, 0, 1)]);
197
+ }
198
+
127
199
  const polar = name === 'hsl' || name === 'hsla' || name === 'hwb';
128
200
  if (polar) {
129
201
  // Same two syntaxes as rgb(): comma separated, or space separated with a
package/src/contrast.mjs CHANGED
@@ -119,6 +119,8 @@ export function runContrast(config, cwd) {
119
119
  name: 'contrast',
120
120
  samples,
121
121
  problems,
122
- summary: `${samples.length} pairs across ${themes.length} ${themes.length === 1 ? 'theme' : 'themes'}`,
122
+ summary: `${samples.length} ${samples.length === 1 ? 'pair' : 'pairs'} across ${
123
+ themes.length
124
+ } ${themes.length === 1 ? 'theme' : 'themes'}`,
123
125
  };
124
126
  }
package/src/css.mjs CHANGED
@@ -66,10 +66,15 @@ export function parseDeclarations(css) {
66
66
  if (colon === -1) return;
67
67
  const prop = text.slice(0, colon).trim();
68
68
  if (!prop.startsWith('--')) return;
69
+ // !important is cascade information, not part of the value. A browser
70
+ // strips it before anyone reads the property back, and leaving it on
71
+ // would hand "#111 !important" to the colour parser as if it were a
72
+ // colour. Found by diffing this parser against a real CSSOM.
73
+ const value = text.slice(colon + 1).trim().replace(/\s*!\s*important\s*$/i, '').trim();
69
74
  const selector = [...stack].reverse().find((s) => !s.startsWith('@')) ?? '';
70
75
  decls.push({
71
76
  prop,
72
- value: text.slice(colon + 1).trim(),
77
+ value,
73
78
  selector,
74
79
  atRules: stack.filter((s) => s.startsWith('@')),
75
80
  index: bufferStart + (source.slice(bufferStart, end).length - source.slice(bufferStart, end).trimStart().length),