@josueavalosjim/taste-check 0.3.1 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +95 -26
  2. package/package.json +3 -2
  3. package/src/color.mjs +73 -1
package/README.md CHANGED
@@ -1,22 +1,27 @@
1
1
  # taste-check
2
2
 
3
- Two deterministic checks for a design system you already have. It computes WCAG
4
- contrast ratios from your own custom properties, and flags class names and
5
- literal values that are not on your own approved list.
3
+ Design review in CI, with a line down the middle of it.
6
4
 
7
- It has no opinion about which colours you use or which classes are allowed.
8
- You supply both.
5
+ Some of design review is measurable. Contrast ratios, values that should have
6
+ been tokens, class names nobody approved. taste-check measures those and fails
7
+ your build on them.
9
8
 
10
- ```bash
11
- npx @josueavalosjim/taste-check --config tastecheck.config.json
12
- ```
9
+ The rest is judgment, and it cannot be measured. For that it sends your
10
+ screenshots and your checklist to a model, and reports what comes back as an
11
+ opinion rather than a result.
13
12
 
14
- Installed as a dependency, the command is just `taste-check`:
13
+ Keeping those two apart is the whole design. A tool that blurs them either
14
+ blocks your pipeline on a coin flip or quietly downgrades a real failure to a
15
+ suggestion.
15
16
 
16
17
  ```bash
17
18
  npm i -D @josueavalosjim/taste-check
18
19
  ```
19
20
 
21
+ ```bash
22
+ npx @josueavalosjim/taste-check
23
+ ```
24
+
20
25
  ```
21
26
  contrast ok, 10 pairs across 2 themes
22
27
  ok 17.76:1 needs 4.5 light --text-strong on --surface body text
@@ -28,20 +33,37 @@ treatments FAILED
28
33
  FAIL src/Promo.jsx:8 inline value "#ff0055" on <a> is a one-off. Use a token, or add it to approvedValues.
29
34
  ```
30
35
 
36
+ ```bash
37
+ npx @josueavalosjim/taste-check judge
38
+ ```
39
+
40
+ ```
41
+ judge ok, 5 lines against 2 screenshots, 1 to read
42
+ NOTE fail Nothing important is cut off at the edge: the third card runs
43
+ past the right edge of the frame at roughly x=798 of 800.
44
+ ```
45
+
31
46
  Zero runtime dependencies. Node 22 or newer.
32
47
 
33
48
  ## Why this exists
34
49
 
35
- Most quality tooling checks compliance and has no point of view. axe tells you
36
- an element fails 4.5:1. It cannot tell you that your borders answer to 3:1
37
- while your captions answer to 4.5:1, because that distinction is yours, not the
38
- spec's.
50
+ Linters have a gate and no taste. They enforce what someone could write down
51
+ as a rule, which is why they can tell you an element fails 4.5:1 but not that
52
+ your borders answer to 3:1 while your captions answer to 4.5:1. That
53
+ distinction is yours, not the spec's.
54
+
55
+ The newer AI design reviewers have taste and no gate. They will tell you what
56
+ is wrong with a screen, in prose, in a chat window, and some of them arrive
57
+ with a few hundred opinions already loaded about what good looks like.
39
58
 
40
- Design systems drift in a specific way: a value gets hardcoded because the
41
- token did not quite fit, a class gets invented because nobody knew the approved
42
- one existed. Written rules do not stop it. A rule in a stylesheet comment is
43
- enforced by people re-reading stylesheets, and nobody re-reads a stylesheet
44
- while writing markup. So the rules get a check that runs instead.
59
+ Both halves are useful and they need to stay separable. Run the same model
60
+ twice over the same unchanged screen and you can get two different answers,
61
+ which is fine for advice and disqualifying for a build gate. So the measured
62
+ half here gates, the judged half does not, and the tool will not let you
63
+ confuse one for the other by accident.
64
+
65
+ It also ships no design rules of its own. Not a palette, not a class list, not
66
+ a contrast floor, not a checklist. You supply all of it.
45
67
 
46
68
  ## What counts as a failure
47
69
 
@@ -58,6 +80,12 @@ Each of these exits 1 rather than passing quietly:
58
80
  A check that cannot fail is worse than no check, because it goes green and gets
59
81
  quoted as evidence.
60
82
 
83
+ The judge is held to the same rule from the other side. Its verdicts never
84
+ affect the exit code by default, but a judge that could not run does: no
85
+ screenshots, a command that died, a reply that was not JSON or that skipped a
86
+ checklist line. "Did not run" and "found nothing" must not print the same
87
+ thing.
88
+
61
89
  ## The contrast check
62
90
 
63
91
  Give it your custom properties, describe your themes, and list what must clear
@@ -252,6 +280,33 @@ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
252
280
  Exit code is 1 if any check fails, 0 if every check ran and passed. The judge
253
281
  plays by the rules in its own section above.
254
282
 
283
+ ## Where it sits next to other tools
284
+
285
+ This is a small tool with a narrow claim, and several of these are better than
286
+ it at the thing they do. Reach for them.
287
+
288
+ **axe, pa11y.** They run against a real rendered page and catch far more than
289
+ contrast. taste-check checks pairs you declare in a config, before a page
290
+ exists and without a browser. Use both. If you only run one accessibility
291
+ tool, run axe.
292
+
293
+ **stylelint, eslint.** General code quality, with an enormous rule ecosystem.
294
+ Nothing here replaces them.
295
+
296
+ **@lapidist/design-lint.** More thorough than taste-check on the token and
297
+ component side: it parses properly rather than scanning, knows about
298
+ frameworks, autofixes, and manages deprecations. If enforcing tokens in code is
299
+ the whole of your problem, it is the better fit.
300
+
301
+ **Checklist Design and similar agent skills.** They arrive with a hundred or
302
+ more published checklists and review conversationally. If you want good
303
+ opinions supplied, take theirs. taste-check supplies none on purpose and runs
304
+ in CI with an exit code instead.
305
+
306
+ What is left, and the reason this exists: nothing above draws a line between
307
+ the part that can gate a build and the part that cannot. The linters have no
308
+ judgment, the judges have no gate.
309
+
255
310
  ## What this does not do
256
311
 
257
312
  Read this before trusting a green run.
@@ -265,10 +320,15 @@ values in your token file relate to each other the way you said they should. It
265
320
  does not prove what a visitor sees.
266
321
 
267
322
  **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.
323
+ `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()` and `oklab()`; and `white` /
324
+ `black` / `transparent`. `lab()`, `lch()` and `color-mix()` are not parsed yet.
325
+ A value it cannot parse fails, so you hear about it immediately.
326
+
327
+ An `oklch()` outside the sRGB gamut is clipped rather than gamut-mapped, which
328
+ is what a browser canvas does with it. That was checked rather than assumed:
329
+ the test corpus has fifty deliberately out-of-gamut colours painted in a real
330
+ browser and read back as pixels, and the parser agrees with all of them to
331
+ within one channel unit.
272
332
 
273
333
  **There is no specificity resolution.** Scopes apply in the order you list
274
334
  them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
@@ -283,13 +343,22 @@ invisible to it.
283
343
  Not built. Written down so the shape is clear.
284
344
 
285
345
  **A runtime mode**, closing the gap named above by measuring `getComputedStyle`
286
- in a real browser, as an optional peer dependency so the core stays free of one.
346
+ in a real browser, as an optional peer dependency so the core stays free of
347
+ one. This is the one that matters most. An advisory judge is only worth
348
+ listening to if the measured half beside it is genuinely measured, and reading
349
+ a token file is the weaker version of that.
287
350
 
288
351
  **YAML configs**, once there is a reason to take on a parser.
289
352
 
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.
353
+ **SARIF output**, so findings land in a code scanning tab rather than only in
354
+ a log.
355
+
356
+ **A way to run the judge from an agent skill**, not only from a shell.
357
+
358
+ **`lab()` and `lch()`**, which need the D50 white point and a chromatic
359
+ adaptation step that `oklch()` does not. Completeness rather than reach, so
360
+ it sits behind the others. Worth doing the same way when it happens: derive it,
361
+ then check every case against a browser rather than trusting the matrices.
293
362
 
294
363
  ## Development
295
364
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.3.1",
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.",
3
+ "version": "0.4.1",
4
+ "description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast over your own tokens, one-off values in your own markup) and a fresh-eyes model judge whose verdicts stay advisory. Ships no design rules of its own.",
5
5
  "keywords": [
6
6
  "accessibility",
7
7
  "cli",
8
8
  "contrast",
9
+ "design-review",
9
10
  "design-system",
10
11
  "design-tokens",
11
12
  "lint",
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