@josueavalosjim/taste-check 0.1.1 → 0.3.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
@@ -50,6 +50,8 @@ Each of these exits 1 rather than passing quietly:
50
50
  - a pair naming a token that does not exist
51
51
  - a file pattern matching no files
52
52
  - a theme whose scopes resolve no tokens
53
+ - a single scope inside a theme that selects nothing, even when the theme has
54
+ tokens from its other scopes
53
55
  - a colour value the parser does not understand
54
56
  - an unknown key in the config, which is usually a typo doing nothing
55
57
 
@@ -85,16 +87,24 @@ in code, because the floor for a decorative hairline and the floor for body
85
87
  text are different decisions and both are yours.
86
88
 
87
89
  A theme is an ordered list of scopes and later scopes win, which is the cascade
88
- for equal specificity. Declarations inside an at-rule are ignored unless a
89
- scope opts in:
90
+ for equal specificity.
91
+
92
+ `@layer` is transparent. A `:root` inside `@layer tokens` resolves exactly as a
93
+ top-level `:root` does, because a layer changes cascade priority rather than
94
+ whether the declarations apply at all.
95
+
96
+ Conditional at-rules are different. `@media`, `@supports`, `@container` and
97
+ `@scope` only apply when their condition holds, so their declarations are
98
+ ignored unless a scope opts in by name:
90
99
 
91
100
  ```json
92
101
  { "name": "dark-system", "scopes": [":root", { "selector": ":root", "atRule": "prefers-color-scheme: dark" }] }
93
102
  ```
94
103
 
95
- Without that rule a `@media (prefers-color-scheme: dark)` block containing
96
- `:root` would overwrite the light theme, and the light checks would silently
97
- measure against colours the light theme never paints.
104
+ Without that, a `@media (prefers-color-scheme: dark)` block containing `:root`
105
+ would overwrite the light theme, and the light checks would measure against
106
+ colours the light theme never paints. You can also name a layer this way to
107
+ narrow a scope to it, but you should never need to just to see your tokens.
98
108
 
99
109
  Two details make the numbers match a browser rather than approximate it.
100
110
  Translucent foregrounds are composited over their background before measuring,
@@ -142,6 +152,69 @@ direction to be wrong in.
142
152
  Template literal holes are read into rather than blanked, so a class written
143
153
  inside `` `card ${on ? 'card--on' : ''}` `` is seen.
144
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
+
145
218
  ## Config
146
219
 
147
220
  Point your editor at `schema/config.schema.json` for completion and inline
@@ -160,9 +233,15 @@ from anywhere.
160
233
  | `treatments.approvedClasses` | Every class allowed to appear. |
161
234
  | `treatments.allowPrefixes` | Prefixes that are always allowed, as a deliberate escape hatch. |
162
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. |
163
241
 
164
242
  ```
165
- taste-check [options]
243
+ taste-check [options] Run the deterministic checks
244
+ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
166
245
 
167
246
  -c, --config <path> Config file (default: tastecheck.config.json)
168
247
  --only <name> Run one check: contrast or treatments
@@ -170,7 +249,8 @@ taste-check [options]
170
249
  --version Print the version
171
250
  ```
172
251
 
173
- 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.
174
254
 
175
255
  ## What this does not do
176
256
 
@@ -184,11 +264,11 @@ component are all invisible here. What this gives you is that the
184
264
  values in your token file relate to each other the way you said they should. It
185
265
  does not prove what a visitor sees.
186
266
 
187
- **Only some colour formats parse.** Hex in 3, 4, 6 and 8 digits, `rgb()` and
188
- `rgba()` in both the comma and the space syntax, and `white` / `black` /
189
- `transparent`. `hsl()`, `oklch()` and `color-mix()` are not parsed yet, and a
190
- value it cannot parse is a failure rather than a skip, so you will hear about
191
- 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.
192
272
 
193
273
  **There is no specificity resolution.** Scopes apply in the order you list
194
274
  them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
@@ -202,22 +282,14 @@ invisible to it.
202
282
 
203
283
  Not built. Written down so the shape is clear.
204
284
 
205
- **A fresh-eyes checklist hook.** The deterministic checks here cover what can
206
- be measured. The judgment half of design review cannot be, and the useful
207
- pattern for it is a fresh context: a separate model call that sees a screenshot
208
- and a checklist, and nothing else. Judging in the same context that produced
209
- the work is unreliable, because the reasoning that justified a choice is still
210
- sitting there to justify it again. The plan is a plugin hook that takes your
211
- screenshot command and your checklist file and reports back in the same format
212
- as the checks above. The checklist stays yours: a shipped one would just be
213
- somebody else's taste.
214
-
215
285
  **A runtime mode**, closing the gap named above by measuring `getComputedStyle`
216
286
  in a real browser, as an optional peer dependency so the core stays free of one.
217
287
 
218
288
  **YAML configs**, once there is a reason to take on a parser.
219
289
 
220
- **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.
221
293
 
222
294
  ## Development
223
295
 
@@ -225,6 +297,14 @@ in a real browser, as an optional peer dependency so the core stays free of one.
225
297
  npm test
226
298
  ```
227
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
+
228
308
  34 tests. Most of them plant a violation into a fixture that was passing a
229
309
  moment earlier and demand it gets caught: a token darkened below its floor, an
230
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.1.1",
3
+ "version": "0.3.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",
@@ -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,24 +52,38 @@
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
- "description": "Opt into declarations nested in a matching at-rule prelude, for example \"prefers-color-scheme: dark\". Without this, at-rule declarations are ignored."
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."
49
87
  }
50
88
  }
51
89
  }
@@ -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
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { readFileSync } from 'node:fs';
20
20
  import { contrastRatio, isOpaque, parseColor } from './color.mjs';
21
- import { parseDeclarations, resolveScopes, resolveValue } from './css.mjs';
21
+ import { parseDeclarations, resolveScopes, resolveValue, unmatchedScopes } from './css.mjs';
22
22
  import { expand, label } from './files.mjs';
23
23
 
24
24
  /** A token name, or a literal colour, resolved to rgba for one theme. */
@@ -62,6 +62,20 @@ export function runContrast(config, cwd) {
62
62
  continue;
63
63
  }
64
64
 
65
+ // A scope that selects nothing is not a smaller theme, it is a typo. The
66
+ // theme above still has tokens, so nothing else here would notice.
67
+ const dead = unmatchedScopes(decls, theme.scopes);
68
+ if (dead.length) {
69
+ for (const scope of dead) {
70
+ const shown = typeof scope === 'string' ? scope : JSON.stringify(scope);
71
+ problems.push(
72
+ `theme "${theme.name}": the scope ${shown} matched no declaration. ` +
73
+ `Every token it was meant to contribute is coming from another scope instead.`,
74
+ );
75
+ }
76
+ continue;
77
+ }
78
+
65
79
  for (const pair of pairs) {
66
80
  if (pair.themes && !pair.themes.includes(theme.name)) continue;
67
81
  const where = `${pair.fg} on ${pair.bg}`;
package/src/css.mjs CHANGED
@@ -13,11 +13,31 @@
13
13
  * relies on `.a.b` beating `.b`, list the scopes in the order you want them
14
14
  * applied and the result is the one you asked for.
15
15
  *
16
- * Declarations inside an at-rule are ignored unless a scope opts into that
17
- * at-rule by name. Without that rule, a `@media (prefers-color-scheme: dark)`
18
- * block containing `:root` would silently overwrite the light theme, and the
19
- * light theme would be checked against colours it never paints.
16
+ * At-rules split into two kinds, and they are treated differently because
17
+ * they mean different things.
18
+ *
19
+ * A CONDITIONAL at-rule (`@media`, `@supports`, `@container`, `@scope`) only
20
+ * applies when its condition holds, so its declarations are ignored unless a
21
+ * scope opts into it by name. Without that, a
22
+ * `@media (prefers-color-scheme: dark)` block containing `:root` would
23
+ * overwrite the light theme, and light would be checked against colours it
24
+ * never paints.
25
+ *
26
+ * A GROUPING at-rule, `@layer` above all, always applies. It changes cascade
27
+ * priority, not whether the declarations exist. So it is transparent here: a
28
+ * `:root` inside `@layer tokens` resolves exactly as a top-level `:root`
29
+ * would. A scope can still name a layer to narrow to it, but nobody should
30
+ * have to write that just to see their own tokens.
31
+ *
32
+ * Layer order is not modelled. Within the scopes a theme lists, the last
33
+ * declaration still wins, same as everywhere else here.
34
+ */
35
+
36
+ /**
37
+ * At-rules whose contents are conditional, and so must be opted into.
38
+ * Anything else wrapping a rule is grouping, and is looked straight through.
20
39
  */
40
+ const CONDITIONAL = /^@(media|supports|container|scope|document)\b/;
21
41
 
22
42
  /** Blank out comments, keeping every offset and newline so lines stay true. */
23
43
  function blankComments(css) {
@@ -104,22 +124,42 @@ function selectorMatches(list, scope) {
104
124
  * The token table for one theme: a Map of `--name` to { value, selector }.
105
125
  * Scopes are applied in order, later winning.
106
126
  */
127
+ /** The declarations one scope selects, in source order. */
128
+ function declsForScope(decls, scope) {
129
+ const selector = typeof scope === 'string' ? scope : scope.selector;
130
+ const atRule = typeof scope === 'string' ? null : (scope.atRule ?? null);
131
+ return decls.filter((d) => {
132
+ if (!selectorMatches(d.selector, selector)) return false;
133
+ if (atRule === null) {
134
+ // Only a conditional wrapper hides a declaration by default. A grouping
135
+ // one like @layer does not.
136
+ return !d.atRules.some((a) => CONDITIONAL.test(a));
137
+ }
138
+ return d.atRules.some((a) => a.includes(atRule));
139
+ });
140
+ }
141
+
107
142
  export function resolveScopes(decls, scopes) {
108
143
  const table = new Map();
109
144
  for (const scope of scopes) {
110
- const selector = typeof scope === 'string' ? scope : scope.selector;
111
- const atRule = typeof scope === 'string' ? null : (scope.atRule ?? null);
112
- for (const d of decls) {
113
- if (!selectorMatches(d.selector, selector)) continue;
114
- if (atRule === null) {
115
- if (d.atRules.length) continue;
116
- } else if (!d.atRules.some((a) => a.includes(atRule))) continue;
117
- table.set(d.prop, d);
118
- }
145
+ for (const d of declsForScope(decls, scope)) table.set(d.prop, d);
119
146
  }
120
147
  return table;
121
148
  }
122
149
 
150
+ /**
151
+ * The scopes in a theme that selected nothing at all.
152
+ *
153
+ * A theme can resolve plenty of tokens while one of its scopes is quietly
154
+ * dead: write `[data-theme="dark"]` when the stylesheet says
155
+ * `:root[data-theme="dark"]` and the base scope still fills the table, so the
156
+ * dark theme gets measured against the light values and reports a pass. The
157
+ * numbers look right, they are just the wrong theme's numbers.
158
+ */
159
+ export function unmatchedScopes(decls, scopes) {
160
+ return scopes.filter((scope) => declsForScope(decls, scope).length === 0);
161
+ }
162
+
123
163
  /**
124
164
  * Follow `var(--other)` indirection to a concrete value.
125
165
  *
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 ?? [],