@josueavalosjim/taste-check 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -152,6 +152,69 @@ direction to be wrong in.
152
152
  Template literal holes are read into rather than blanked, so a class written
153
153
  inside `` `card ${on ? 'card--on' : ''}` `` is seen.
154
154
 
155
+ ## The judge
156
+
157
+ Everything above measures. This asks the question a measurement cannot: not
158
+ whether the screen is correct, but whether it is any good.
159
+
160
+ That question cannot be put to whoever just built the thing. The context that
161
+ made the choices is the worst placed to find them wanting, because the
162
+ reasoning that justified each one is still there ready to justify it again. So
163
+ the judge is a separate process that sees your screenshots and your checklist
164
+ and nothing else.
165
+
166
+ ```json
167
+ {
168
+ "judge": {
169
+ "checklist": "design-checklist.md",
170
+ "shots": ["shots/*.png"],
171
+ "shotCommand": "node scripts/shots.mjs",
172
+ "command": "claude -p",
173
+ "failOn": "never"
174
+ }
175
+ }
176
+ ```
177
+
178
+ ```bash
179
+ taste-check judge
180
+ ```
181
+
182
+ Your `command` receives the prompt on stdin and the image paths as arguments,
183
+ so any model CLI works and no API key ever touches this tool. It must reply
184
+ with JSON:
185
+
186
+ ```json
187
+ { "findings": [ { "line": "<the checklist line, verbatim>", "verdict": "pass|fail|unsure", "why": "..." } ] }
188
+ ```
189
+
190
+ taste-check supplies the framing: you are seeing this cold, you have not been
191
+ told what changed, answer every line, prefer unsure to a guess, do not be
192
+ agreeable, and name the specific thing you are looking at. That part is method
193
+ and it is the same for everyone.
194
+
195
+ The checklist is yours. taste-check ships none, and there is a test asserting
196
+ the framing mentions no design vocabulary at all, because a rule that arrives
197
+ inside a tool is somebody else's taste with the tool's authority behind it.
198
+ Checklist lines are list items in your file; a heading or a paragraph is prose
199
+ and is not judged.
200
+
201
+ ### What the exit code means here
202
+
203
+ A verdict is an opinion, so a `fail` prints as a note and the command exits 0.
204
+ Set `failOn` to `"fail"` if you want it to block, knowing that two runs on the
205
+ same screenshot can disagree.
206
+
207
+ Whether the judge ran is not an opinion, and never advisory. Each of these
208
+ exits 1 whatever `failOn` says:
209
+
210
+ - no screenshots were produced or matched
211
+ - the command exited non-zero
212
+ - the reply was not parseable JSON
213
+ - the reply skipped a checklist line, invented one, or answered one twice
214
+
215
+ Without that split, "the judge did not run" and "the judge found nothing" print
216
+ the same thing.
217
+
155
218
  ## Config
156
219
 
157
220
  Point your editor at `schema/config.schema.json` for completion and inline
@@ -170,9 +233,15 @@ from anywhere.
170
233
  | `treatments.approvedClasses` | Every class allowed to appear. |
171
234
  | `treatments.allowPrefixes` | Prefixes that are always allowed, as a deliberate escape hatch. |
172
235
  | `treatments.approvedValues` | Literal values allowed inside inline styles. |
236
+ | `judge.checklist` | Your checklist file. List items are judged, prose is not. |
237
+ | `judge.shots` | Screenshots to hand the judge. Matching nothing is a failure. |
238
+ | `judge.shotCommand` | Optional command run first to produce those screenshots. |
239
+ | `judge.command` | The model command. Prompt on stdin, image paths as arguments. |
240
+ | `judge.failOn` | `"never"` (default) or `"fail"`. Whether a verdict blocks. |
173
241
 
174
242
  ```
175
- taste-check [options]
243
+ taste-check [options] Run the deterministic checks
244
+ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
176
245
 
177
246
  -c, --config <path> Config file (default: tastecheck.config.json)
178
247
  --only <name> Run one check: contrast or treatments
@@ -180,7 +249,8 @@ taste-check [options]
180
249
  --version Print the version
181
250
  ```
182
251
 
183
- Exit code is 1 if any check fails, 0 if every check ran and passed.
252
+ Exit code is 1 if any check fails, 0 if every check ran and passed. The judge
253
+ plays by the rules in its own section above.
184
254
 
185
255
  ## What this does not do
186
256
 
@@ -194,11 +264,11 @@ component are all invisible here. What this gives you is that the
194
264
  values in your token file relate to each other the way you said they should. It
195
265
  does not prove what a visitor sees.
196
266
 
197
- **Only some colour formats parse.** Hex in 3, 4, 6 and 8 digits, `rgb()` and
198
- `rgba()` in both the comma and the space syntax, and `white` / `black` /
199
- `transparent`. `hsl()`, `oklch()` and `color-mix()` are not parsed yet, and a
200
- value it cannot parse is a failure rather than a skip, so you will hear about
201
- it immediately.
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.
202
272
 
203
273
  **There is no specificity resolution.** Scopes apply in the order you list
204
274
  them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
@@ -212,22 +282,14 @@ invisible to it.
212
282
 
213
283
  Not built. Written down so the shape is clear.
214
284
 
215
- **A fresh-eyes checklist hook.** The deterministic checks here cover what can
216
- be measured. The judgment half of design review cannot be, and the useful
217
- pattern for it is a fresh context: a separate model call that sees a screenshot
218
- and a checklist, and nothing else. Judging in the same context that produced
219
- the work is unreliable, because the reasoning that justified a choice is still
220
- sitting there to justify it again. The plan is a plugin hook that takes your
221
- screenshot command and your checklist file and reports back in the same format
222
- as the checks above. The checklist stays yours: a shipped one would just be
223
- somebody else's taste.
224
-
225
285
  **A runtime mode**, closing the gap named above by measuring `getComputedStyle`
226
286
  in a real browser, as an optional peer dependency so the core stays free of one.
227
287
 
228
288
  **YAML configs**, once there is a reason to take on a parser.
229
289
 
230
- **More colour formats**, `hsl()` first.
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.
231
293
 
232
294
  ## Development
233
295
 
@@ -235,6 +297,14 @@ in a real browser, as an optional peer dependency so the core stays free of one.
235
297
  npm test
236
298
  ```
237
299
 
300
+ Releases are automated. `npm run release` runs the suite, bumps the patch
301
+ version, tags it and pushes. The tag triggers a workflow that runs the suite
302
+ again, checks both fixtures still produce the exit codes they should, verifies
303
+ the tag matches `package.json`, and publishes. Publishing uses npm's trusted
304
+ publisher over OIDC, so no npm token is stored in the repository and every
305
+ release carries a provenance attestation. For a minor or major bump, run
306
+ `npm version minor` or `npm version major` and `git push --follow-tags`.
307
+
238
308
  34 tests. Most of them plant a violation into a fixture that was passing a
239
309
  moment earlier and demand it gets caught: a token darkened below its floor, an
240
310
  unapproved class added to a clean file, a class buried in a template literal
@@ -8,12 +8,13 @@
8
8
  * not a quiet skip.
9
9
  */
10
10
  import { load } from '../src/config.mjs';
11
- import { run } from '../src/index.mjs';
11
+ import { judge, run } from '../src/index.mjs';
12
12
  import { failed, toJson, toText } from '../src/report.mjs';
13
13
 
14
14
  const USAGE = `taste-check
15
15
 
16
- taste-check [options]
16
+ taste-check [options] Run the deterministic checks
17
+ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
17
18
 
18
19
  Options:
19
20
  -c, --config <path> Config file (default: tastecheck.config.json)
@@ -22,10 +23,22 @@ Options:
22
23
  -h, --help This
23
24
  --version Print the version
24
25
 
25
- Exit code is 1 if any check fails, 0 if every check ran and passed.`;
26
+ Exit code is 1 if any check fails, 0 if every check ran and passed.
27
+
28
+ The judge is a separate command because it runs a model, and a model's
29
+ verdict is not reproducible. Its verdicts print as notes and do not affect
30
+ the exit code unless judge.failOn is set to "fail". Whether the judge ran
31
+ at all is a different question: no screenshots, a command that failed, or
32
+ a reply that skipped a checklist line all exit 1 either way.`;
26
33
 
27
34
  function parseArgs(argv) {
28
- const options = { config: 'tastecheck.config.json', only: null, json: false };
35
+ const options = { config: 'tastecheck.config.json', only: null, json: false, command: 'check' };
36
+ // One positional, and only in first position, so a stray argument is an
37
+ // error rather than something silently ignored.
38
+ if (argv[0] === 'judge') {
39
+ options.command = 'judge';
40
+ argv = argv.slice(1);
41
+ }
29
42
  for (let i = 0; i < argv.length; i += 1) {
30
43
  const arg = argv[i];
31
44
  const next = () => {
@@ -43,6 +56,7 @@ function parseArgs(argv) {
43
56
  throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
44
57
  }
45
58
  } else if (arg === '--json') options.json = true;
59
+ else if (!arg.startsWith('-')) throw new Error(`unknown command "${arg}"`);
46
60
  else throw new Error(`unknown option "${arg}"`);
47
61
  }
48
62
  return options;
@@ -81,7 +95,19 @@ if (!loaded.ok) {
81
95
  process.exit(1);
82
96
  }
83
97
 
84
- const results = run(loaded.config, loaded.dir, { only: options.only });
98
+ if (options.command === 'judge' && options.only) {
99
+ die('--only applies to the deterministic checks, not to judge');
100
+ }
101
+
102
+ if (options.command === 'judge' && !loaded.config.judge) {
103
+ die(`${options.config} defines no "judge" block.`);
104
+ }
105
+
106
+ const results =
107
+ options.command === 'judge'
108
+ ? judge(loaded.config, loaded.dir)
109
+ : run(loaded.config, loaded.dir, { only: options.only });
110
+
85
111
  if (!results.length) {
86
112
  die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or treatments'} check.`);
87
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -37,7 +37,8 @@
37
37
  "LICENSE"
38
38
  ],
39
39
  "scripts": {
40
- "test": "node --test \"test/**/*.test.mjs\""
40
+ "test": "node --test \"test/**/*.test.mjs\"",
41
+ "release": "npm test && npm version patch -m \"Release %s\" && git push --follow-tags"
41
42
  },
42
43
  "engines": {
43
44
  "node": ">=22"
@@ -6,20 +6,44 @@
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "minProperties": 1,
9
- "anyOf": [{ "required": ["contrast"] }, { "required": ["treatments"] }],
9
+ "anyOf": [
10
+ {
11
+ "required": [
12
+ "contrast"
13
+ ]
14
+ },
15
+ {
16
+ "required": [
17
+ "treatments"
18
+ ]
19
+ },
20
+ {
21
+ "required": [
22
+ "judge"
23
+ ]
24
+ }
25
+ ],
10
26
  "properties": {
11
- "$schema": { "type": "string" },
27
+ "$schema": {
28
+ "type": "string"
29
+ },
12
30
  "contrast": {
13
31
  "type": "object",
14
32
  "description": "WCAG contrast ratios computed from your own custom properties.",
15
33
  "additionalProperties": false,
16
- "required": ["tokens", "themes", "pairs"],
34
+ "required": [
35
+ "tokens",
36
+ "themes",
37
+ "pairs"
38
+ ],
17
39
  "properties": {
18
40
  "tokens": {
19
41
  "type": "array",
20
42
  "minItems": 1,
21
43
  "description": "CSS files holding the custom properties. Paths resolve against this config file. Later files override earlier ones.",
22
- "items": { "type": "string" }
44
+ "items": {
45
+ "type": "string"
46
+ }
23
47
  },
24
48
  "themes": {
25
49
  "type": "array",
@@ -28,21 +52,35 @@
28
52
  "items": {
29
53
  "type": "object",
30
54
  "additionalProperties": false,
31
- "required": ["name", "scopes"],
55
+ "required": [
56
+ "name",
57
+ "scopes"
58
+ ],
32
59
  "properties": {
33
- "name": { "type": "string", "minLength": 1 },
60
+ "name": {
61
+ "type": "string",
62
+ "minLength": 1
63
+ },
34
64
  "scopes": {
35
65
  "type": "array",
36
66
  "minItems": 1,
37
67
  "items": {
38
68
  "oneOf": [
39
- { "type": "string", "description": "A selector, matched exactly against one entry in a rule's selector list." },
69
+ {
70
+ "type": "string",
71
+ "description": "A selector, matched exactly against one entry in a rule's selector list."
72
+ },
40
73
  {
41
74
  "type": "object",
42
75
  "additionalProperties": false,
43
- "required": ["selector"],
76
+ "required": [
77
+ "selector"
78
+ ],
44
79
  "properties": {
45
- "selector": { "type": "string", "minLength": 1 },
80
+ "selector": {
81
+ "type": "string",
82
+ "minLength": 1
83
+ },
46
84
  "atRule": {
47
85
  "type": "string",
48
86
  "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."
@@ -62,17 +100,38 @@
62
100
  "items": {
63
101
  "type": "object",
64
102
  "additionalProperties": false,
65
- "required": ["fg", "bg", "min"],
103
+ "required": [
104
+ "fg",
105
+ "bg",
106
+ "min"
107
+ ],
66
108
  "properties": {
67
- "fg": { "type": "string", "minLength": 1, "description": "A token name like --text, or a literal colour." },
68
- "bg": { "type": "string", "minLength": 1, "description": "As fg, but it must resolve to an opaque colour." },
69
- "min": { "type": "number", "exclusiveMinimum": 0, "description": "The floor this pair must clear. No default: you decide what the rule is." },
70
- "label": { "type": "string", "description": "Why this pair matters. Printed with the result." },
109
+ "fg": {
110
+ "type": "string",
111
+ "minLength": 1,
112
+ "description": "A token name like --text, or a literal colour."
113
+ },
114
+ "bg": {
115
+ "type": "string",
116
+ "minLength": 1,
117
+ "description": "As fg, but it must resolve to an opaque colour."
118
+ },
119
+ "min": {
120
+ "type": "number",
121
+ "exclusiveMinimum": 0,
122
+ "description": "The floor this pair must clear. No default: you decide what the rule is."
123
+ },
124
+ "label": {
125
+ "type": "string",
126
+ "description": "Why this pair matters. Printed with the result."
127
+ },
71
128
  "themes": {
72
129
  "type": "array",
73
130
  "minItems": 1,
74
131
  "description": "Limit the pair to these themes. Omit to check it in all of them.",
75
- "items": { "type": "string" }
132
+ "items": {
133
+ "type": "string"
134
+ }
76
135
  }
77
136
  }
78
137
  }
@@ -83,34 +142,86 @@
83
142
  "type": "object",
84
143
  "description": "Class names and literal values in markup, against a list you supply.",
85
144
  "additionalProperties": false,
86
- "required": ["files"],
145
+ "required": [
146
+ "files"
147
+ ],
87
148
  "properties": {
88
149
  "files": {
89
150
  "type": "array",
90
151
  "minItems": 1,
91
152
  "description": "Markup to scan. A pattern matching nothing is a failure, never a clean run.",
92
- "items": { "type": "string" }
153
+ "items": {
154
+ "type": "string"
155
+ }
93
156
  },
94
157
  "elements": {
95
158
  "type": "array",
96
159
  "minItems": 1,
97
160
  "description": "Element names to scan. Use [\"*\"] or omit for every element.",
98
- "items": { "type": "string" }
161
+ "items": {
162
+ "type": "string"
163
+ }
99
164
  },
100
165
  "approvedClasses": {
101
166
  "type": "array",
102
167
  "description": "Every class name allowed to appear. Anything else is reported.",
103
- "items": { "type": "string" }
168
+ "items": {
169
+ "type": "string"
170
+ }
104
171
  },
105
172
  "allowPrefixes": {
106
173
  "type": "array",
107
174
  "description": "Class name prefixes that are always allowed, for a deliberate escape hatch.",
108
- "items": { "type": "string" }
175
+ "items": {
176
+ "type": "string"
177
+ }
109
178
  },
110
179
  "approvedValues": {
111
180
  "type": "array",
112
181
  "description": "Literal values allowed in inline styles. Everything else that looks like a colour or a length is reported.",
113
- "items": { "type": "string" }
182
+ "items": {
183
+ "type": "string"
184
+ }
185
+ }
186
+ }
187
+ },
188
+ "judge": {
189
+ "type": "object",
190
+ "description": "A fresh-eyes review of your screenshots against your checklist, run by a model command you supply. Run with `taste-check judge`. Verdicts are advisory unless failOn says otherwise.",
191
+ "additionalProperties": false,
192
+ "required": [
193
+ "checklist",
194
+ "shots",
195
+ "command"
196
+ ],
197
+ "properties": {
198
+ "checklist": {
199
+ "type": "string",
200
+ "description": "A file of checklist lines. List items (\"- ...\" or \"1. ...\") are what gets judged; everything else is treated as prose. taste-check ships no checklist."
201
+ },
202
+ "shots": {
203
+ "type": "array",
204
+ "minItems": 1,
205
+ "description": "Screenshots to hand the judge. Matching nothing is a failure: a judge with nothing to look at cannot fail, so it does not get to pass.",
206
+ "items": {
207
+ "type": "string"
208
+ }
209
+ },
210
+ "shotCommand": {
211
+ "type": "string",
212
+ "description": "Optional command run before the judge to produce the screenshots. Runs from this config file's directory."
213
+ },
214
+ "command": {
215
+ "type": "string",
216
+ "description": "The model command. It receives the prompt on stdin and the image paths as arguments, and must reply with the documented JSON. Runs from this config file's directory."
217
+ },
218
+ "failOn": {
219
+ "enum": [
220
+ "never",
221
+ "fail"
222
+ ],
223
+ "default": "never",
224
+ "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."
114
225
  }
115
226
  }
116
227
  }
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 = ['hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color-mix', 'color'];
28
+ const KNOWN_UNSUPPORTED = ['lab', 'lch', 'oklab', 'oklch', 'color-mix', 'color'];
29
29
 
30
30
  const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)%?$/;
31
31
 
@@ -48,12 +48,55 @@ function alpha(token) {
48
48
 
49
49
  const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
50
50
 
51
+ const TURNS = { deg: 1 / 360, grad: 1 / 400, rad: 1 / (2 * Math.PI), turn: 1 };
52
+
53
+ /** A hue token to a fraction of a turn, or null. A bare number is degrees. */
54
+ function hue(token) {
55
+ const m = token.match(/^([+-]?(?:\d+\.?\d*|\.\d+))(deg|grad|rad|turn)?$/);
56
+ if (!m) return null;
57
+ const turns = parseFloat(m[1]) * TURNS[m[2] ?? 'deg'];
58
+ // Wrap into [0, 1). Hue is an angle, so 480deg and -240deg are both 120deg.
59
+ return ((turns % 1) + 1) % 1;
60
+ }
61
+
62
+ /** A percentage token to 0-1, or null. */
63
+ function percent(token) {
64
+ if (!/^[+-]?(?:\d+\.?\d*|\.\d+)%$/.test(token)) return null;
65
+ return parseFloat(token) / 100;
66
+ }
67
+
68
+ /** HSL to sRGB. h in turns, s and l in 0-1. */
69
+ function hslToRgb(h, s, l) {
70
+ const f = (n) => {
71
+ const k = (n + h * 12) % 12;
72
+ const a = s * Math.min(l, 1 - l);
73
+ return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
74
+ };
75
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
76
+ }
77
+
78
+ /**
79
+ * HWB to sRGB. h in turns, w and b in 0-1.
80
+ *
81
+ * When whiteness and blackness sum to 1 or more the hue drops out entirely and
82
+ * the result is the grey at w / (w + b). Missing that case is the usual bug,
83
+ * because every other input hides it.
84
+ */
85
+ function hwbToRgb(h, w, b) {
86
+ if (w + b >= 1) {
87
+ const grey = (w / (w + b)) * 255;
88
+ return [grey, grey, grey];
89
+ }
90
+ return hslToRgb(h, 1, 0.5).map((c) => (c / 255) * (1 - w - b) * 255 + w * 255);
91
+ }
92
+
51
93
  /**
52
94
  * Parse a CSS colour into [r, g, b, a] with r/g/b in 0-255 and a in 0-1.
53
95
  *
54
- * Supported: #rgb, #rgba, #rrggbb, #rrggbbaa; rgb()/rgba() in both the legacy
55
- * comma syntax and the modern space syntax with a slash alpha; and the three
56
- * named colours above. Anything else fails loudly.
96
+ * Supported: #rgb, #rgba, #rrggbb, #rrggbbaa; rgb(), rgba(), hsl(), hsla()
97
+ * and hwb(), each in both the legacy comma syntax and the modern space syntax
98
+ * with a slash alpha; and the three named colours above. Anything else fails
99
+ * loudly.
57
100
  */
58
101
  export function parseColor(input) {
59
102
  if (typeof input !== 'string') return err('not a string');
@@ -81,6 +124,40 @@ export function parseColor(input) {
81
124
  const fn = text.match(/^([a-zA-Z-]+)\s*\(([\s\S]*)\)$/);
82
125
  if (fn) {
83
126
  const name = fn[1].toLowerCase();
127
+ const polar = name === 'hsl' || name === 'hsla' || name === 'hwb';
128
+ if (polar) {
129
+ // Same two syntaxes as rgb(): comma separated, or space separated with a
130
+ // slash alpha. Split on the slash first so a modern alpha is never read
131
+ // as a fourth component.
132
+ const [head, ...tail] = fn[2].split('/');
133
+ if (tail.length > 1) return err(`"${text}" has more than one slash`);
134
+ const parts = head.trim().split(/[,\s]+/).filter(Boolean);
135
+ const alphaToken = tail.length ? tail[0].trim() : parts[3];
136
+ if (parts.length < 3) return err(`"${text}" needs a hue and two components`);
137
+ if (tail.length === 0 && parts.length > 4) return err(`"${text}" has too many components`);
138
+ if (tail.length === 1 && parts.length !== 3) return err(`"${text}" has too many components`);
139
+
140
+ const h = hue(parts[0]);
141
+ if (h === null) return err(`"${text}" has a hue that is not an angle`);
142
+ // hsl() allows bare numbers for s and l in the modern syntax; hwb()
143
+ // requires percentages, but accepting a bare number there costs nothing
144
+ // and refusing it would only ever reject something a browser renders.
145
+ const one = percent(parts[1]) ?? (NUMBER.test(parts[1]) ? parseFloat(parts[1]) / 100 : null);
146
+ const two = percent(parts[2]) ?? (NUMBER.test(parts[2]) ? parseFloat(parts[2]) / 100 : null);
147
+ if (one === null || two === null) return err(`"${text}" has a component that is not a number`);
148
+
149
+ let a = 1;
150
+ if (alphaToken !== undefined) {
151
+ const parsed = alpha(alphaToken);
152
+ if (parsed === null) return err(`"${text}" has an alpha that is not a number`);
153
+ a = parsed;
154
+ }
155
+ const rgb =
156
+ name === 'hwb'
157
+ ? hwbToRgb(h, clamp(one, 0, 1), clamp(two, 0, 1))
158
+ : hslToRgb(h, clamp(one, 0, 1), clamp(two, 0, 1));
159
+ return ok([...rgb.map((c) => clamp(c, 0, 255)), clamp(a, 0, 1)]);
160
+ }
84
161
  if (name !== 'rgb' && name !== 'rgba') {
85
162
  const hint = KNOWN_UNSUPPORTED.includes(name)
86
163
  ? `${name}() is not supported in v1. Convert the token to hex or rgb(), or open an issue.`
package/src/config.mjs CHANGED
@@ -131,14 +131,34 @@ function validateTreatments(treatments, errors) {
131
131
  }
132
132
  }
133
133
 
134
+ function validateJudge(judge, errors) {
135
+ rejectUnknown(judge, ['checklist', 'shots', 'shotCommand', 'command', 'failOn'], 'judge', errors);
136
+ for (const key of ['checklist', 'command']) {
137
+ if (typeof judge[key] !== 'string' || !judge[key]) {
138
+ errors.push(`judge.${key} must be a non-empty string`);
139
+ }
140
+ }
141
+ if (judge.shotCommand !== undefined && (typeof judge.shotCommand !== 'string' || !judge.shotCommand)) {
142
+ errors.push('judge.shotCommand must be a non-empty string');
143
+ }
144
+ stringArray(judge.shots, 'judge.shots', errors);
145
+ if (judge.failOn !== undefined && judge.failOn !== 'never' && judge.failOn !== 'fail') {
146
+ errors.push(
147
+ `judge.failOn must be "never" or "fail", not ${JSON.stringify(judge.failOn)}. ` +
148
+ `It defaults to "never": a model's verdict is an opinion, so it does not gate a build ` +
149
+ `unless you say it should.`,
150
+ );
151
+ }
152
+ }
153
+
134
154
  /** Validate a parsed config, returning a list of human-readable problems. */
135
155
  export function validate(config) {
136
156
  const errors = [];
137
157
  if (!isPlainObject(config)) return ['the config must be a JSON object'];
138
- rejectUnknown(config, ['$schema', 'contrast', 'treatments'], 'the config', errors);
158
+ rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge'], 'the config', errors);
139
159
 
140
- if (config.contrast === undefined && config.treatments === undefined) {
141
- errors.push('the config must define "contrast", "treatments", or both');
160
+ if (config.contrast === undefined && config.treatments === undefined && config.judge === undefined) {
161
+ errors.push('the config must define at least one of "contrast", "treatments" or "judge"');
142
162
  }
143
163
  if (config.contrast !== undefined) {
144
164
  if (isPlainObject(config.contrast)) validateContrast(config.contrast, errors);
@@ -148,6 +168,10 @@ export function validate(config) {
148
168
  if (isPlainObject(config.treatments)) validateTreatments(config.treatments, errors);
149
169
  else errors.push('treatments must be an object');
150
170
  }
171
+ if (config.judge !== undefined) {
172
+ if (isPlainObject(config.judge)) validateJudge(config.judge, errors);
173
+ else errors.push('judge must be an object');
174
+ }
151
175
  return errors;
152
176
  }
153
177
 
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),
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { runContrast } from './contrast.mjs';
6
6
  export { runTreatments } from './treatments.mjs';
7
+ export { runJudge, buildPrompt, checklistLines, extractJson } from './judge.mjs';
7
8
  export { load, validate } from './config.mjs';
8
9
  export { toText, toJson, failed } from './report.mjs';
9
10
  export { parseColor, contrastRatio, composite, luminance } from './color.mjs';
@@ -12,10 +13,15 @@ export { openTags, classesOf } from './treatments.mjs';
12
13
 
13
14
  import { runContrast } from './contrast.mjs';
14
15
  import { runTreatments } from './treatments.mjs';
16
+ import { runJudge } from './judge.mjs';
15
17
 
16
18
  /**
17
- * Run the checks a config asks for. `only` narrows to one check by name.
18
- * Returns the raw results; formatting and exit codes are the caller's.
19
+ * Run the deterministic checks a config asks for. `only` narrows to one by
20
+ * name. Returns the raw results; formatting and exit codes are the caller's.
21
+ *
22
+ * The judge is deliberately not here. It runs a model, so it belongs behind
23
+ * its own subcommand rather than inside the run whose exit code people wire
24
+ * into CI.
19
25
  */
20
26
  export function run(config, cwd, { only = null } = {}) {
21
27
  const results = [];
@@ -27,3 +33,8 @@ export function run(config, cwd, { only = null } = {}) {
27
33
  }
28
34
  return results;
29
35
  }
36
+
37
+ /** Run the judge. Separate from `run` on purpose: see the note above. */
38
+ export function judge(config, cwd) {
39
+ return [runJudge(config.judge, cwd)];
40
+ }
package/src/judge.mjs ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The fresh-eyes judge.
3
+ *
4
+ * Everything else in this tool measures. This part asks the question a
5
+ * measurement cannot answer: not whether the screen is correct, but whether it
6
+ * is any good. That question cannot be put to whoever just built the thing.
7
+ * The context that made the choices is the worst-placed context to find them
8
+ * wanting, because the reasoning that justified each one is still sitting
9
+ * there ready to justify it again.
10
+ *
11
+ * So the judge is a separate process that sees the screenshots and the
12
+ * checklist and nothing else. No summary of what changed, no statement of
13
+ * intent, no prior conversation. It should see what a stranger sees.
14
+ *
15
+ * ── What ships here and what does not ───────────────────────────────────
16
+ *
17
+ * This module ships the FRAMING: fresh context, do not lead the judge, answer
18
+ * every line, prefer unsure to a guess, quote what you actually see. That part
19
+ * is method and it is portable.
20
+ *
21
+ * The CHECKLIST is yours. taste-check contains no design rules and never will,
22
+ * because a shipped checklist is just somebody else's taste wearing the
23
+ * authority of a tool.
24
+ *
25
+ * ── Where the determinism line falls ────────────────────────────────────
26
+ *
27
+ * A model's verdict is an opinion and cannot gate a build by default, so a
28
+ * "fail" prints as a NOTE and the command still exits 0 unless failOn says
29
+ * otherwise.
30
+ *
31
+ * Whether the judge RAN is not an opinion. No screenshots, a command that
32
+ * exited non-zero, output that was not JSON, a reply that skipped a checklist
33
+ * line or invented one: each of those is a fact, each exits 1 regardless of
34
+ * failOn. Without that split, "the judge did not run" and "the judge found
35
+ * nothing" produce the same green output, which is the failure this whole
36
+ * tool exists to prevent, one level up.
37
+ */
38
+ import { execFileSync } from 'node:child_process';
39
+ import { readFileSync } from 'node:fs';
40
+ import { resolve } from 'node:path';
41
+ import { expand, label } from './files.mjs';
42
+
43
+ /**
44
+ * The instructions wrapped around the user's checklist.
45
+ *
46
+ * Every line here is about how to look, never about what to value. If you find
47
+ * yourself wanting to add a rule about spacing or colour or type, it belongs
48
+ * in a checklist file, not in this constant.
49
+ */
50
+ const FRAMING_HEAD = `You are looking at these images for the first time.
51
+
52
+ You have not been told what changed, what it is for, or what the author was
53
+ trying to do, and none of that is coming. Judge only what is in front of you,
54
+ the way someone landing on this screen cold would see it.
55
+
56
+ Answer every line of the checklist below. For each one reply with exactly one
57
+ verdict: "pass", "fail", or "unsure".
58
+
59
+ Prefer "unsure" to a guess. An honest "I cannot tell from this image" is more
60
+ useful than a confident answer that happens to be wrong, and you are not being
61
+ asked to be agreeable. If a line does not apply to what you can see, that is
62
+ "unsure", not "pass".
63
+
64
+ For anything that is not a pass, name the specific thing you are looking at:
65
+ which element, where on the screen, and what about it. A general impression is
66
+ not enough to act on.
67
+
68
+ `;
69
+
70
+ const FRAMING_TAIL = `Reply with JSON and nothing else, in this shape:
71
+
72
+ {"findings":[{"line":"<the checklist line, copied exactly>","verdict":"pass|fail|unsure","why":"<one or two sentences>"}]}
73
+
74
+ Return exactly one finding per checklist line, with "line" copied verbatim.
75
+
76
+ CHECKLIST:
77
+ `;
78
+
79
+ /**
80
+ * The lines a verdict is expected for: list items only.
81
+ *
82
+ * A checklist file is a document, not a list of strings. It will have a
83
+ * heading, and a paragraph saying what it is for, and probably a note to
84
+ * whoever edits it next. Treating every non-heading line as something to
85
+ * judge turns that prose into checklist items, and the judge then dutifully
86
+ * returns a verdict on your explanatory paragraph.
87
+ */
88
+ export function checklistLines(text) {
89
+ return text
90
+ .split('\n')
91
+ .filter((l) => /^\s*(?:[-*+]|\d+[.)])\s+\S/.test(l))
92
+ .map((l) => l.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim());
93
+ }
94
+
95
+ /**
96
+ * The prompt handed to the judge: framing, the images, then the checklist.
97
+ *
98
+ * The image paths go in the prompt as well as on the command line, because
99
+ * the two families of tool want them in different places. Some accept image
100
+ * files as arguments; others read the prompt and open what it names. Naming
101
+ * them both ways costs a line and means the contract does not quietly exclude
102
+ * half the tools someone might reach for.
103
+ */
104
+ export function buildPrompt(checklist, images = []) {
105
+ const shots = images.length
106
+ ? `IMAGES (open each one before answering):\n${images.map((i) => `- ${i}`).join('\n')}\n\n`
107
+ : '';
108
+ return `${FRAMING_HEAD}${shots}${FRAMING_TAIL}${checklist.map((l) => `- ${l}`).join('\n')}\n`;
109
+ }
110
+
111
+ /**
112
+ * Run a configured command from the config file's own directory, because
113
+ * every other path in a config resolves that way and a command that did not
114
+ * would be a trap: `node shots.mjs` would mean something different depending
115
+ * on where you happened to be standing.
116
+ */
117
+ function runCommand(command, args, input, cwd) {
118
+ const [bin, ...rest] = command.split(/\s+/).filter(Boolean);
119
+ try {
120
+ const stdout = execFileSync(bin, [...rest, ...args], {
121
+ cwd,
122
+ input,
123
+ encoding: 'utf8',
124
+ maxBuffer: 32 * 1024 * 1024,
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ });
127
+ return { ok: true, stdout };
128
+ } catch (error) {
129
+ const detail = (error.stderr || error.message || '').toString().trim().split('\n')[0];
130
+ return { ok: false, reason: `\`${command}\` failed: ${detail}` };
131
+ }
132
+ }
133
+
134
+ /** Pull the JSON object out of a reply that may be fenced or padded with prose. */
135
+ export function extractJson(stdout) {
136
+ const fenced = stdout.match(/```(?:json)?\s*([\s\S]*?)```/);
137
+ const candidate = fenced ? fenced[1] : stdout;
138
+ const start = candidate.indexOf('{');
139
+ const end = candidate.lastIndexOf('}');
140
+ if (start === -1 || end <= start) return { ok: false, reason: 'no JSON object in the reply' };
141
+ try {
142
+ return { ok: true, value: JSON.parse(candidate.slice(start, end + 1)) };
143
+ } catch (error) {
144
+ return { ok: false, reason: `the reply was not valid JSON: ${error.message}` };
145
+ }
146
+ }
147
+
148
+ const VERDICTS = new Set(['pass', 'fail', 'unsure']);
149
+
150
+ export function runJudge(config, cwd) {
151
+ const findings = [];
152
+ const problems = [];
153
+ const { checklist: checklistPath, shots = [], shotCommand, command, failOn = 'never' } = config;
154
+
155
+ if (shotCommand) {
156
+ const made = runCommand(shotCommand, [], '', cwd);
157
+ if (!made.ok) {
158
+ problems.push(made.reason);
159
+ return { name: 'judge', findings, problems, failOn, summary: '' };
160
+ }
161
+ }
162
+
163
+ const images = expand(shots, cwd);
164
+ if (!images.length) {
165
+ problems.push(
166
+ `no screenshots matched ${shots.map((s) => `"${s}"`).join(', ')}. ` +
167
+ `A judge with nothing to look at cannot fail, so it does not get to pass either.`,
168
+ );
169
+ return { name: 'judge', findings, problems, failOn, summary: '' };
170
+ }
171
+
172
+ let lines;
173
+ try {
174
+ lines = checklistLines(readFileSync(resolve(cwd, checklistPath), 'utf8'));
175
+ } catch {
176
+ problems.push(`cannot read the checklist at ${checklistPath}`);
177
+ return { name: 'judge', findings, problems, failOn, summary: '' };
178
+ }
179
+ if (!lines.length) {
180
+ problems.push(
181
+ `${checklistPath} has no checklist lines in it. Lines to judge are list ` +
182
+ `items ("- ..." or "1. ..."); everything else is treated as prose.`,
183
+ );
184
+ return { name: 'judge', findings, problems, failOn, summary: '' };
185
+ }
186
+
187
+ const reply = runCommand(command, images, buildPrompt(lines, images.map((i) => label(i, cwd))), cwd);
188
+ if (!reply.ok) {
189
+ problems.push(reply.reason);
190
+ return { name: 'judge', findings, problems, failOn, summary: '' };
191
+ }
192
+
193
+ const parsed = extractJson(reply.stdout);
194
+ if (!parsed.ok) {
195
+ problems.push(`${parsed.reason}. The judge must reply with the documented JSON shape.`);
196
+ return { name: 'judge', findings, problems, failOn, summary: '' };
197
+ }
198
+ if (!Array.isArray(parsed.value.findings)) {
199
+ problems.push('the reply has no "findings" array');
200
+ return { name: 'judge', findings, problems, failOn, summary: '' };
201
+ }
202
+
203
+ // Cross-check both directions. A judge that quietly drops the hardest line
204
+ // is the failure mode to guard: the remaining verdicts all say pass, and
205
+ // the line nobody answered is the one that mattered.
206
+ const wanted = new Set(lines);
207
+ const answered = new Set();
208
+ for (const f of parsed.value.findings) {
209
+ if (!f || typeof f.line !== 'string' || !VERDICTS.has(f.verdict)) {
210
+ problems.push(`a finding is malformed: ${JSON.stringify(f)}`);
211
+ continue;
212
+ }
213
+ if (!wanted.has(f.line)) {
214
+ problems.push(`the judge answered a line that is not in the checklist: "${f.line}"`);
215
+ continue;
216
+ }
217
+ if (answered.has(f.line)) {
218
+ problems.push(`the judge answered "${f.line}" more than once`);
219
+ continue;
220
+ }
221
+ answered.add(f.line);
222
+ findings.push({ line: f.line, verdict: f.verdict, why: (f.why ?? '').trim() });
223
+ }
224
+ for (const line of lines) {
225
+ if (!answered.has(line)) problems.push(`the judge did not answer "${line}"`);
226
+ }
227
+
228
+ return {
229
+ name: 'judge',
230
+ findings,
231
+ problems,
232
+ failOn,
233
+ summary: `${lines.length} ${lines.length === 1 ? 'line' : 'lines'} against ${images.length} ${
234
+ images.length === 1 ? 'screenshot' : 'screenshots'
235
+ } (${images.map((i) => label(i, cwd)).join(', ')})`,
236
+ };
237
+ }
package/src/report.mjs CHANGED
@@ -33,17 +33,49 @@ function treatmentLines(result) {
33
33
  ];
34
34
  }
35
35
 
36
- export const linesFor = (result) =>
37
- result.name === 'contrast' ? contrastLines(result) : treatmentLines(result);
36
+ /**
37
+ * The judge's verdicts are opinions, so a "fail" is a note unless the config
38
+ * opted into blocking. Its problems are facts about whether it ran at all,
39
+ * and those stay errors either way.
40
+ */
41
+ function judgeLines(result) {
42
+ const level = result.failOn === 'fail' ? 'fail' : 'note';
43
+ return [
44
+ ...result.findings
45
+ .filter((f) => f.verdict !== 'pass')
46
+ .map((f) => ({
47
+ level: f.verdict === 'fail' ? level : 'note',
48
+ text: `${f.verdict.padEnd(6)} ${f.line}${f.why ? `: ${f.why}` : ''}`,
49
+ })),
50
+ ...result.problems.map((text) => ({ level: 'error', text })),
51
+ ];
52
+ }
53
+
54
+ export const linesFor = (result) => {
55
+ if (result.name === 'contrast') return contrastLines(result);
56
+ if (result.name === 'judge') return judgeLines(result);
57
+ return treatmentLines(result);
58
+ };
38
59
 
39
- const MARK = { ok: ' ok ', ' fail': 'FAIL ', fail: 'FAIL ', error: 'ERROR ' };
60
+ const MARK = { ok: ' ok ', fail: 'FAIL ', error: 'ERROR ', note: 'NOTE ' };
40
61
 
41
62
  export function toText(results) {
42
63
  const out = [];
43
64
  for (const result of results) {
44
65
  const lines = linesFor(result);
45
- const bad = lines.filter((l) => l.level !== 'ok');
46
- if (!bad.length) {
66
+ const blocking = lines.filter((l) => l.level !== 'ok' && l.level !== 'note');
67
+ const notes = lines.filter((l) => l.level === 'note');
68
+
69
+ // Notes are worth printing and not worth failing over, so a run with only
70
+ // notes gets its own heading. Calling it FAILED would train people to
71
+ // ignore the word.
72
+ if (!blocking.length && notes.length) {
73
+ out.push(`${result.name} ok, ${result.summary}, ${notes.length} to read`);
74
+ for (const l of notes) out.push(` ${MARK[l.level]}${l.text}`);
75
+ out.push('');
76
+ continue;
77
+ }
78
+ if (!blocking.length) {
47
79
  out.push(`${result.name} ok, ${result.summary}`);
48
80
  // A clean contrast run still shows its margins. Nothing else in the
49
81
  // report tells you which pair is one nudge away from failing.
@@ -58,8 +90,9 @@ export function toText(results) {
58
90
  return out.join('\n').trimEnd();
59
91
  }
60
92
 
93
+ /** A note is something to read, not something to block on. */
61
94
  export const failed = (results) =>
62
- results.some((r) => linesFor(r).some((l) => l.level !== 'ok'));
95
+ results.some((r) => linesFor(r).some((l) => l.level !== 'ok' && l.level !== 'note'));
63
96
 
64
97
  export function toJson(results) {
65
98
  return JSON.stringify(
@@ -68,7 +101,7 @@ export function toJson(results) {
68
101
  checks: results.map((r) => ({
69
102
  name: r.name,
70
103
  summary: r.summary,
71
- ok: !linesFor(r).some((l) => l.level !== 'ok'),
104
+ ok: !linesFor(r).some((l) => l.level !== 'ok' && l.level !== 'note'),
72
105
  samples: r.samples ?? [],
73
106
  failures: r.failures ?? [],
74
107
  problems: r.problems ?? [],