@josueavalosjim/taste-check 0.4.0 → 0.5.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
@@ -1,22 +1,28 @@
1
1
  # taste-check
2
2
 
3
- Two deterministic checks for a design system you already have. It computes WCAG
4
- contrast ratios from your own custom properties, and flags class names and
5
- literal values that are not on your own approved list.
3
+ Design review in CI, with a line down the middle of it.
6
4
 
7
- It has no opinion about which colours you use or which classes are allowed.
8
- You supply both.
5
+ Some of design review is measurable. Contrast ratios, values that should have
6
+ been tokens, class names nobody approved. taste-check measures those and fails
7
+ your build on them, either from your token file or from a page it renders in a
8
+ real browser.
9
9
 
10
- ```bash
11
- npx @josueavalosjim/taste-check --config tastecheck.config.json
12
- ```
10
+ The rest is judgment, and it cannot be measured. For that it sends your
11
+ screenshots and your checklist to a model, and reports what comes back as an
12
+ opinion rather than a result.
13
13
 
14
- Installed as a dependency, the command is just `taste-check`:
14
+ Keeping those two apart is the whole design. A tool that blurs them either
15
+ blocks your pipeline on a coin flip or quietly downgrades a real failure to a
16
+ suggestion.
15
17
 
16
18
  ```bash
17
19
  npm i -D @josueavalosjim/taste-check
18
20
  ```
19
21
 
22
+ ```bash
23
+ npx @josueavalosjim/taste-check
24
+ ```
25
+
20
26
  ```
21
27
  contrast ok, 10 pairs across 2 themes
22
28
  ok 17.76:1 needs 4.5 light --text-strong on --surface body text
@@ -28,20 +34,37 @@ treatments FAILED
28
34
  FAIL src/Promo.jsx:8 inline value "#ff0055" on <a> is a one-off. Use a token, or add it to approvedValues.
29
35
  ```
30
36
 
37
+ ```bash
38
+ npx @josueavalosjim/taste-check judge
39
+ ```
40
+
41
+ ```
42
+ judge ok, 5 lines against 2 screenshots, 1 to read
43
+ NOTE fail Nothing important is cut off at the edge: the third card runs
44
+ past the right edge of the frame at roughly x=798 of 800.
45
+ ```
46
+
31
47
  Zero runtime dependencies. Node 22 or newer.
32
48
 
33
49
  ## Why this exists
34
50
 
35
- Most quality tooling checks compliance and has no point of view. axe tells you
36
- an element fails 4.5:1. It cannot tell you that your borders answer to 3:1
37
- while your captions answer to 4.5:1, because that distinction is yours, not the
38
- spec's.
51
+ Linters have a gate and no taste. They enforce what someone could write down
52
+ as a rule, which is why they can tell you an element fails 4.5:1 but not that
53
+ your borders answer to 3:1 while your captions answer to 4.5:1. That
54
+ distinction is yours, not the spec's.
39
55
 
40
- Design systems drift in a specific way: a value gets hardcoded because the
41
- token did not quite fit, a class gets invented because nobody knew the approved
42
- one existed. Written rules do not stop it. A rule in a stylesheet comment is
43
- enforced by people re-reading stylesheets, and nobody re-reads a stylesheet
44
- while writing markup. So the rules get a check that runs instead.
56
+ The newer AI design reviewers have taste and no gate. They will tell you what
57
+ is wrong with a screen, in prose, in a chat window, and some of them arrive
58
+ with a few hundred opinions already loaded about what good looks like.
59
+
60
+ Both halves are useful and they need to stay separable. Run the same model
61
+ twice over the same unchanged screen and you can get two different answers,
62
+ which is fine for advice and disqualifying for a build gate. So the measured
63
+ half here gates, the judged half does not, and the tool will not let you
64
+ confuse one for the other by accident.
65
+
66
+ It also ships no design rules of its own. Not a palette, not a class list, not
67
+ a contrast floor, not a checklist. You supply all of it.
45
68
 
46
69
  ## What counts as a failure
47
70
 
@@ -58,6 +81,12 @@ Each of these exits 1 rather than passing quietly:
58
81
  A check that cannot fail is worse than no check, because it goes green and gets
59
82
  quoted as evidence.
60
83
 
84
+ The judge is held to the same rule from the other side. Its verdicts never
85
+ affect the exit code by default, but a judge that could not run does: no
86
+ screenshots, a command that died, a reply that was not JSON or that skipped a
87
+ checklist line. "Did not run" and "found nothing" must not print the same
88
+ thing.
89
+
61
90
  ## The contrast check
62
91
 
63
92
  Give it your custom properties, describe your themes, and list what must clear
@@ -152,6 +181,60 @@ direction to be wrong in.
152
181
  Template literal holes are read into rather than blanked, so a class written
153
182
  inside `` `card ${on ? 'card--on' : ''}` `` is seen.
154
183
 
184
+ ## Measuring the rendered page
185
+
186
+ The contrast check above reads your token file, which tells you what a colour
187
+ is declared to be. `taste-check runtime` opens the page and reads what is
188
+ actually painted.
189
+
190
+ ```json
191
+ {
192
+ "runtime": {
193
+ "url": "http://localhost:3000",
194
+ "states": [
195
+ { "name": "light" },
196
+ { "name": "dark", "before": "localStorage.setItem('theme', 'dark')" }
197
+ ],
198
+ "targets": [
199
+ { "selector": ".caption", "prop": "color", "min": 4.5 },
200
+ { "selector": ".panel", "prop": "borderTopColor", "min": 3.0 },
201
+ { "selector": ".row.is-selected", "prop": "backgroundColor", "min": 1.25, "againstParent": true }
202
+ ]
203
+ }
204
+ }
205
+ ```
206
+
207
+ ```bash
208
+ taste-check runtime
209
+ ```
210
+
211
+ It launches a headless Chromium, or connects to one you already have if you
212
+ give it an `endpoint`, and it never closes a browser it did not start. There is
213
+ no dependency for this. The Chrome DevTools Protocol is JSON over the WebSocket
214
+ Node already ships, and the part of it needed here is two methods.
215
+
216
+ Two things this does that reading tokens cannot.
217
+
218
+ **It composites the whole background stack.** White text on a 75% black scrim
219
+ over a near-white page measures 10.57:1. Stop at the first opaque ancestor, the
220
+ way the check this was ported from did, and you measure the white against the
221
+ page instead and get 1.04:1. That is a failure the design has not earned, and
222
+ with the colours the other way round the same shortcut hands you a pass it has
223
+ not earned either.
224
+
225
+ **It can measure a state.** `before` runs on every navigation ahead of the
226
+ page's own scripts, which is where a theme belongs because the page reads it at
227
+ boot. `after` runs once there is a document, for opening a panel or focusing a
228
+ field. Each state's setup is removed before the next one, so they cannot leak.
229
+
230
+ Use `againstParent` when the thing being measured is a fill rather than a
231
+ foreground. A selected row with its own background measured against itself
232
+ scores 1.00, which is a pass that means nothing.
233
+
234
+ A selector that matches nothing is a failure. So is a border colour on an edge
235
+ with no width: you asked for the contrast of something that is not being drawn,
236
+ and the question is wrong rather than the answer being zero.
237
+
155
238
  ## The judge
156
239
 
157
240
  Everything above measures. This asks the question a measurement cannot: not
@@ -238,9 +321,15 @@ from anywhere.
238
321
  | `judge.shotCommand` | Optional command run first to produce those screenshots. |
239
322
  | `judge.command` | The model command. Prompt on stdin, image paths as arguments. |
240
323
  | `judge.failOn` | `"never"` (default) or `"fail"`. Whether a verdict blocks. |
324
+ | `runtime.url` | The page to measure. A `file://` URL works. |
325
+ | `runtime.endpoint` | An existing CDP endpoint. Given one, taste-check connects rather than launching, and never closes a browser it did not start. |
326
+ | `runtime.browserPath` | Path to a Chromium. Falls back to `CHROME_PATH`, then the usual locations. |
327
+ | `runtime.states[]` | `before` runs ahead of the page's scripts, `after` once loaded, `waitFor` is a selector. |
328
+ | `runtime.targets[]` | `selector`, `prop`, `min`, and `againstParent` when measuring a fill. |
241
329
 
242
330
  ```
243
- taste-check [options] Run the deterministic checks
331
+ taste-check [options] Run the deterministic checks over your files
332
+ taste-check runtime [options] Measure contrast on a rendered page
244
333
  taste-check judge [options] Ask a fresh-eyes judge about your screenshots
245
334
 
246
335
  -c, --config <path> Config file (default: tastecheck.config.json)
@@ -252,6 +341,33 @@ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
252
341
  Exit code is 1 if any check fails, 0 if every check ran and passed. The judge
253
342
  plays by the rules in its own section above.
254
343
 
344
+ ## Where it sits next to other tools
345
+
346
+ This is a small tool with a narrow claim, and several of these are better than
347
+ it at the thing they do. Reach for them.
348
+
349
+ **axe, pa11y.** They run against a real rendered page and catch far more than
350
+ contrast. taste-check checks pairs you declare in a config, before a page
351
+ exists and without a browser. Use both. If you only run one accessibility
352
+ tool, run axe.
353
+
354
+ **stylelint, eslint.** General code quality, with an enormous rule ecosystem.
355
+ Nothing here replaces them.
356
+
357
+ **@lapidist/design-lint.** More thorough than taste-check on the token and
358
+ component side: it parses properly rather than scanning, knows about
359
+ frameworks, autofixes, and manages deprecations. If enforcing tokens in code is
360
+ the whole of your problem, it is the better fit.
361
+
362
+ **Checklist Design and similar agent skills.** They arrive with a hundred or
363
+ more published checklists and review conversationally. If you want good
364
+ opinions supplied, take theirs. taste-check supplies none on purpose and runs
365
+ in CI with an exit code instead.
366
+
367
+ What is left, and the reason this exists: nothing above draws a line between
368
+ the part that can gate a build and the part that cannot. The linters have no
369
+ judgment, the judges have no gate.
370
+
255
371
  ## What this does not do
256
372
 
257
373
  Read this before trusting a green run.
@@ -287,13 +403,16 @@ invisible to it.
287
403
 
288
404
  Not built. Written down so the shape is clear.
289
405
 
290
- **A runtime mode**, closing the gap named above by measuring `getComputedStyle`
291
- in a real browser, as an optional peer dependency so the core stays free of one.
292
-
293
406
  **YAML configs**, once there is a reason to take on a parser.
294
407
 
408
+ **SARIF output**, so findings land in a code scanning tab rather than only in
409
+ a log.
410
+
411
+ **A way to run the judge from an agent skill**, not only from a shell.
412
+
295
413
  **`lab()` and `lch()`**, which need the D50 white point and a chromatic
296
- adaptation step that `oklch()` does not. Worth doing the same way: derive it,
414
+ adaptation step that `oklch()` does not. Completeness rather than reach, so
415
+ it sits behind the others. Worth doing the same way when it happens: derive it,
297
416
  then check every case against a browser rather than trusting the matrices.
298
417
 
299
418
  ## Development
@@ -8,12 +8,13 @@
8
8
  * not a quiet skip.
9
9
  */
10
10
  import { load } from '../src/config.mjs';
11
- import { judge, run } from '../src/index.mjs';
11
+ import { judge, run, runtime } 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] Run the deterministic checks
16
+ taste-check [options] Run the deterministic checks over your files
17
+ taste-check runtime [options] Measure contrast on a rendered page
17
18
  taste-check judge [options] Ask a fresh-eyes judge about your screenshots
18
19
 
19
20
  Options:
@@ -25,6 +26,11 @@ Options:
25
26
 
26
27
  Exit code is 1 if any check fails, 0 if every check ran and passed.
27
28
 
29
+ runtime is a separate command because it needs a browser and a server that
30
+ is already up. It measures what is actually painted, compositing every
31
+ background layer behind an element rather than stopping at the first opaque
32
+ one, and it can put the page into a state first.
33
+
28
34
  The judge is a separate command because it runs a model, and a model's
29
35
  verdict is not reproducible. Its verdicts print as notes and do not affect
30
36
  the exit code unless judge.failOn is set to "fail". Whether the judge ran
@@ -35,8 +41,8 @@ function parseArgs(argv) {
35
41
  const options = { config: 'tastecheck.config.json', only: null, json: false, command: 'check' };
36
42
  // One positional, and only in first position, so a stray argument is an
37
43
  // error rather than something silently ignored.
38
- if (argv[0] === 'judge') {
39
- options.command = 'judge';
44
+ if (argv[0] === 'judge' || argv[0] === 'runtime') {
45
+ options.command = argv[0];
40
46
  argv = argv.slice(1);
41
47
  }
42
48
  for (let i = 0; i < argv.length; i += 1) {
@@ -95,18 +101,18 @@ if (!loaded.ok) {
95
101
  process.exit(1);
96
102
  }
97
103
 
98
- if (options.command === 'judge' && options.only) {
99
- die('--only applies to the deterministic checks, not to judge');
104
+ if (options.command !== 'check' && options.only) {
105
+ die(`--only applies to the deterministic checks, not to ${options.command}`);
100
106
  }
101
107
 
102
- if (options.command === 'judge' && !loaded.config.judge) {
103
- die(`${options.config} defines no "judge" block.`);
108
+ if (options.command !== 'check' && !loaded.config[options.command]) {
109
+ die(`${options.config} defines no "${options.command}" block.`);
104
110
  }
105
111
 
106
- const results =
107
- options.command === 'judge'
108
- ? judge(loaded.config, loaded.dir)
109
- : run(loaded.config, loaded.dir, { only: options.only });
112
+ let results;
113
+ if (options.command === 'judge') results = judge(loaded.config, loaded.dir);
114
+ else if (options.command === 'runtime') results = await runtime(loaded.config, loaded.dir);
115
+ else results = run(loaded.config, loaded.dir, { only: options.only });
110
116
 
111
117
  if (!results.length) {
112
118
  die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or treatments'} check.`);
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.4.0",
4
- "description": "Deterministic design-system checks: WCAG contrast over your own tokens, and a one-off value linter over your own approved list. No palette, no class list, no opinions shipped.",
3
+ "version": "0.5.0",
4
+ "description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
5
5
  "keywords": [
6
6
  "accessibility",
7
7
  "cli",
8
8
  "contrast",
9
+ "design-review",
9
10
  "design-system",
10
11
  "design-tokens",
11
12
  "lint",
@@ -17,6 +17,11 @@
17
17
  "treatments"
18
18
  ]
19
19
  },
20
+ {
21
+ "required": [
22
+ "runtime"
23
+ ]
24
+ },
20
25
  {
21
26
  "required": [
22
27
  "judge"
@@ -224,6 +229,100 @@
224
229
  "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."
225
230
  }
226
231
  }
232
+ },
233
+ "runtime": {
234
+ "type": "object",
235
+ "description": "Contrast measured on a rendered page rather than derived from a token file. Run with `taste-check runtime`. Needs a Chromium and a server that is already up.",
236
+ "additionalProperties": false,
237
+ "required": [
238
+ "url",
239
+ "targets"
240
+ ],
241
+ "properties": {
242
+ "url": {
243
+ "type": "string",
244
+ "description": "The page to measure. A file:// URL works."
245
+ },
246
+ "endpoint": {
247
+ "type": "string",
248
+ "description": "An existing CDP endpoint, for example http://127.0.0.1:9222. Given this, taste-check connects instead of launching, and never closes a browser it did not start."
249
+ },
250
+ "browserPath": {
251
+ "type": "string",
252
+ "description": "Path to a Chromium. Falls back to CHROME_PATH, then the usual locations for the platform."
253
+ },
254
+ "timeout": {
255
+ "type": "number",
256
+ "exclusiveMinimum": 0,
257
+ "description": "Milliseconds to wait for the browser and for each page operation. Default 15000."
258
+ },
259
+ "states": {
260
+ "type": "array",
261
+ "minItems": 1,
262
+ "description": "States to measure the page in. Omit for a single default state.",
263
+ "items": {
264
+ "type": "object",
265
+ "additionalProperties": false,
266
+ "required": [
267
+ "name"
268
+ ],
269
+ "properties": {
270
+ "name": {
271
+ "type": "string",
272
+ "minLength": 1
273
+ },
274
+ "before": {
275
+ "type": "string",
276
+ "description": "JavaScript run on every navigation ahead of the page's own scripts. Where a theme goes, because the page reads it at boot."
277
+ },
278
+ "after": {
279
+ "type": "string",
280
+ "description": "JavaScript run once the page has loaded. For opening a panel or focusing a field."
281
+ },
282
+ "waitFor": {
283
+ "type": "string",
284
+ "description": "A selector to wait for before measuring."
285
+ }
286
+ }
287
+ }
288
+ },
289
+ "targets": {
290
+ "type": "array",
291
+ "minItems": 1,
292
+ "description": "What to measure. A selector matching nothing is a failure, as is a border colour on an edge with no width.",
293
+ "items": {
294
+ "type": "object",
295
+ "additionalProperties": false,
296
+ "required": [
297
+ "selector",
298
+ "prop",
299
+ "min"
300
+ ],
301
+ "properties": {
302
+ "selector": {
303
+ "type": "string",
304
+ "minLength": 1
305
+ },
306
+ "prop": {
307
+ "type": "string",
308
+ "minLength": 1,
309
+ "description": "A computed style property carrying the colour, for example color or borderTopColor."
310
+ },
311
+ "min": {
312
+ "type": "number",
313
+ "exclusiveMinimum": 0
314
+ },
315
+ "label": {
316
+ "type": "string"
317
+ },
318
+ "againstParent": {
319
+ "type": "boolean",
320
+ "description": "Measure against what is behind the element rather than its own background. Use when the thing being measured is a fill: measuring a fill against itself scores 1.00 and means nothing."
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
227
326
  }
228
327
  }
229
328
  }
package/src/cdp.mjs ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * A very small Chrome DevTools Protocol client, over the WebSocket Node ships
3
+ * with. No dependencies.
4
+ *
5
+ * The alternative was a peer dependency on a browser automation library, which
6
+ * would have been less code here and several hundred megabytes there. CDP is
7
+ * JSON over a socket; the part of it this needs is Page.navigate and
8
+ * Runtime.evaluate, and that part is small enough to own.
9
+ *
10
+ * This is not a browser automation library and should not grow into one. If a
11
+ * feature here starts wanting selectors, waiting strategies or a frame tree,
12
+ * that is the point to take the dependency instead.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ /** Where a Chromium lives on each platform, in the order worth trying. */
20
+ const BROWSERS = {
21
+ darwin: [
22
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
23
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
24
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
25
+ ],
26
+ linux: [
27
+ '/usr/bin/google-chrome',
28
+ '/usr/bin/google-chrome-stable',
29
+ '/usr/bin/chromium',
30
+ '/usr/bin/chromium-browser',
31
+ '/snap/bin/chromium',
32
+ ],
33
+ win32: [
34
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
35
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
36
+ ],
37
+ };
38
+
39
+ export function findBrowser(configured) {
40
+ const candidates = [configured, process.env.CHROME_PATH, ...(BROWSERS[process.platform] ?? [])];
41
+ for (const path of candidates) {
42
+ if (path && existsSync(path)) return path;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+
49
+ /** Poll the endpoint until Chrome answers, or give up with a useful message. */
50
+ async function waitForEndpoint(base, timeout) {
51
+ const deadline = Date.now() + timeout;
52
+ let last;
53
+ while (Date.now() < deadline) {
54
+ try {
55
+ const res = await fetch(`${base}/json/version`);
56
+ if (res.ok) return await res.json();
57
+ } catch (error) {
58
+ last = error;
59
+ }
60
+ await sleep(100);
61
+ }
62
+ throw new Error(`no CDP endpoint at ${base} after ${timeout}ms${last ? `: ${last.message}` : ''}`);
63
+ }
64
+
65
+ /**
66
+ * A page to evaluate against, plus a close() that tears down whatever this
67
+ * function created and nothing it did not. Connecting to a browser somebody
68
+ * else started must not kill it.
69
+ */
70
+ export async function connect({ endpoint, browserPath, timeout = 15000 }) {
71
+ let child = null;
72
+ let profile = null;
73
+ let base = endpoint;
74
+
75
+ if (!base) {
76
+ const binary = findBrowser(browserPath);
77
+ if (!binary) {
78
+ throw new Error(
79
+ 'no Chrome or Chromium found. Set runtime.browserPath, or CHROME_PATH, or ' +
80
+ 'start a browser with --remote-debugging-port and set runtime.endpoint.',
81
+ );
82
+ }
83
+ // Port 0 lets the OS choose, so two runs never collide. Chrome writes the
84
+ // port it actually took into the profile directory.
85
+ profile = mkdtempSync(join(tmpdir(), 'taste-check-cdp-'));
86
+ child = spawn(
87
+ binary,
88
+ [
89
+ '--headless=new',
90
+ '--remote-debugging-port=0',
91
+ `--user-data-dir=${profile}`,
92
+ '--no-first-run',
93
+ '--no-default-browser-check',
94
+ '--disable-extensions',
95
+ '--disable-background-networking',
96
+ '--hide-scrollbars',
97
+ 'about:blank',
98
+ ],
99
+ { stdio: ['ignore', 'ignore', 'pipe'] },
100
+ );
101
+ const port = await new Promise((resolve, reject) => {
102
+ let buffer = '';
103
+ const onData = (chunk) => {
104
+ buffer += chunk;
105
+ const match = buffer.match(/ws:\/\/127\.0\.0\.1:(\d+)\//);
106
+ if (match) settle(resolve, Number(match[1]));
107
+ };
108
+ const onExit = (code) => settle(reject, new Error(`the browser exited with code ${code}`));
109
+ const timer = setTimeout(
110
+ () => settle(reject, new Error('the browser did not report a debugging port')),
111
+ timeout,
112
+ );
113
+ const settle = (fn, value) => {
114
+ clearTimeout(timer);
115
+ child.stderr.off('data', onData);
116
+ child.off('exit', onExit);
117
+ fn(value);
118
+ };
119
+ child.stderr.on('data', onData);
120
+ child.on('exit', onExit);
121
+ });
122
+ // Nothing reads the browser's stderr after this, and a piped stream with
123
+ // a live child on the other end holds the event loop open. Without both
124
+ // of these the process sits idle until the browser happens to exit, which
125
+ // reads as a mysteriously slow run rather than as a leak.
126
+ child.stderr.destroy();
127
+ child.unref();
128
+ base = `http://127.0.0.1:${port}`;
129
+ }
130
+
131
+ await waitForEndpoint(base, timeout);
132
+ const list = await (await fetch(`${base}/json/list`)).json();
133
+ const target =
134
+ list.find((t) => t.type === 'page') ??
135
+ (await (await fetch(`${base}/json/new?about:blank`, { method: 'PUT' })).json());
136
+
137
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
138
+ await new Promise((resolve, reject) => {
139
+ ws.onopen = resolve;
140
+ ws.onerror = () => reject(new Error(`could not open a socket to ${target.webSocketDebuggerUrl}`));
141
+ });
142
+
143
+ let id = 0;
144
+ const pending = new Map();
145
+ ws.onmessage = (event) => {
146
+ const message = JSON.parse(event.data);
147
+ if (!message.id || !pending.has(message.id)) return;
148
+ const { resolve, reject } = pending.get(message.id);
149
+ pending.delete(message.id);
150
+ if (message.error) reject(new Error(message.error.message));
151
+ else resolve(message.result);
152
+ };
153
+
154
+ // Every timer here is cleared on the happy path. An uncancelled one keeps
155
+ // the event loop alive until it fires, which turns a fast run into a wait
156
+ // for the full timeout and looks exactly like a slow browser.
157
+ const send = (method, params = {}) =>
158
+ new Promise((resolve, reject) => {
159
+ const n = ++id;
160
+ const timer = setTimeout(() => {
161
+ if (pending.delete(n)) reject(new Error(`${method} timed out after ${timeout}ms`));
162
+ }, timeout);
163
+ pending.set(n, {
164
+ resolve: (value) => {
165
+ clearTimeout(timer);
166
+ resolve(value);
167
+ },
168
+ reject: (error) => {
169
+ clearTimeout(timer);
170
+ reject(error);
171
+ },
172
+ });
173
+ ws.send(JSON.stringify({ id: n, method, params }));
174
+ });
175
+
176
+ await send('Page.enable');
177
+ await send('Runtime.enable');
178
+
179
+ return {
180
+ send,
181
+ /** Navigate and wait for the load event rather than for a fixed delay. */
182
+ async goto(url) {
183
+ let onMessage;
184
+ let timer;
185
+ const loaded = new Promise((resolve) => {
186
+ onMessage = (event) => {
187
+ if (JSON.parse(event.data).method === 'Page.loadEventFired') resolve();
188
+ };
189
+ ws.addEventListener('message', onMessage);
190
+ timer = setTimeout(resolve, timeout);
191
+ });
192
+ try {
193
+ await send('Page.navigate', { url });
194
+ await loaded;
195
+ } finally {
196
+ ws.removeEventListener('message', onMessage);
197
+ clearTimeout(timer);
198
+ }
199
+ },
200
+ /**
201
+ * Register a script to run on every navigation, before the page's own
202
+ * scripts. This is where a theme goes: setting localStorage after load and
203
+ * reloading would wipe anything the reload undoes, and setting it without
204
+ * a reload is too late for a page that reads it on boot.
205
+ */
206
+ async onNewDocument(source) {
207
+ const { identifier } = await send('Page.addScriptToEvaluateOnNewDocument', { source });
208
+ return identifier;
209
+ },
210
+ /** Remove one, so a state cannot leak into the next one. */
211
+ async removeNewDocumentScript(identifier) {
212
+ await send('Page.removeScriptToEvaluateOnNewDocument', { identifier });
213
+ },
214
+ /** Evaluate in the page and return the value, awaiting a promise result. */
215
+ async evaluate(expression) {
216
+ const { result, exceptionDetails } = await send('Runtime.evaluate', {
217
+ expression,
218
+ returnByValue: true,
219
+ awaitPromise: true,
220
+ });
221
+ if (exceptionDetails) {
222
+ throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text);
223
+ }
224
+ return result.value;
225
+ },
226
+ /**
227
+ * Best effort, and deliberately incapable of throwing. Teardown failing
228
+ * must never take down a run whose measurements already succeeded, and a
229
+ * temp directory the browser is still writing into is not worth a crash.
230
+ */
231
+ close() {
232
+ try {
233
+ ws.close();
234
+ } catch {
235
+ /* already gone */
236
+ }
237
+ if (!child) return;
238
+ try {
239
+ child.kill();
240
+ } catch {
241
+ /* already gone */
242
+ }
243
+ if (profile) {
244
+ try {
245
+ rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
246
+ } catch {
247
+ /* the OS will collect it */
248
+ }
249
+ }
250
+ },
251
+ };
252
+ }
package/src/color.mjs CHANGED
@@ -262,6 +262,22 @@ export function parseColor(input) {
262
262
  return err(`"${text}" is not a colour this tool can parse`);
263
263
  }
264
264
 
265
+ /**
266
+ * Flatten a stack of backgrounds, nearest the element first, into one opaque
267
+ * colour.
268
+ *
269
+ * The check this ports from walked up to the first ancestor with alpha over
270
+ * 0.99 and measured against that, which quietly discards every translucent
271
+ * layer in between. A caption on a dark scrim over a light page is measured
272
+ * against the light page, and reports a pass it has not earned. Compositing
273
+ * the whole stack is what is actually painted.
274
+ */
275
+ export function flatten(layers) {
276
+ let ground = layers[layers.length - 1].slice(0, 3);
277
+ for (let i = layers.length - 2; i >= 0; i -= 1) ground = composite(layers[i], ground);
278
+ return ground;
279
+ }
280
+
265
281
  /** Composite a translucent foreground over an opaque ground. */
266
282
  export function composite(fg, bg) {
267
283
  return [0, 1, 2].map((i) => fg[3] * fg[i] + (1 - fg[3]) * bg[i]);
package/src/config.mjs CHANGED
@@ -151,14 +151,88 @@ function validateJudge(judge, errors) {
151
151
  }
152
152
  }
153
153
 
154
+ function validateRuntime(runtime, errors) {
155
+ rejectUnknown(
156
+ runtime,
157
+ ['url', 'endpoint', 'browserPath', 'timeout', 'states', 'targets'],
158
+ 'runtime',
159
+ errors,
160
+ );
161
+ if (typeof runtime.url !== 'string' || !runtime.url) {
162
+ errors.push('runtime.url must be the page to measure');
163
+ }
164
+ for (const key of ['endpoint', 'browserPath']) {
165
+ if (runtime[key] !== undefined && (typeof runtime[key] !== 'string' || !runtime[key])) {
166
+ errors.push(`runtime.${key} must be a non-empty string`);
167
+ }
168
+ }
169
+ if (runtime.timeout !== undefined && (typeof runtime.timeout !== 'number' || runtime.timeout <= 0)) {
170
+ errors.push('runtime.timeout must be a positive number of milliseconds');
171
+ }
172
+ if (runtime.states !== undefined) {
173
+ if (!Array.isArray(runtime.states) || !runtime.states.length) {
174
+ errors.push('runtime.states must be a non-empty array. Omit it for a single default state.');
175
+ } else {
176
+ const names = new Set();
177
+ runtime.states.forEach((state, i) => {
178
+ const where = `runtime.states[${i}]`;
179
+ if (!isPlainObject(state)) {
180
+ errors.push(`${where} must be an object`);
181
+ return;
182
+ }
183
+ rejectUnknown(state, ['name', 'before', 'after', 'waitFor'], where, errors);
184
+ if (typeof state.name !== 'string' || !state.name) errors.push(`${where}.name must be a string`);
185
+ else if (names.has(state.name)) errors.push(`${where}.name "${state.name}" is used twice`);
186
+ else names.add(state.name);
187
+ for (const key of ['before', 'after', 'waitFor']) {
188
+ if (state[key] !== undefined && typeof state[key] !== 'string') {
189
+ errors.push(`${where}.${key} must be a string`);
190
+ }
191
+ }
192
+ });
193
+ }
194
+ }
195
+ if (!Array.isArray(runtime.targets) || !runtime.targets.length) {
196
+ errors.push('runtime.targets must be a non-empty array. A check with no targets cannot fail.');
197
+ return;
198
+ }
199
+ runtime.targets.forEach((target, i) => {
200
+ const where = `runtime.targets[${i}]`;
201
+ if (!isPlainObject(target)) {
202
+ errors.push(`${where} must be an object`);
203
+ return;
204
+ }
205
+ rejectUnknown(target, ['selector', 'prop', 'min', 'label', 'againstParent'], where, errors);
206
+ for (const key of ['selector', 'prop']) {
207
+ if (typeof target[key] !== 'string' || !target[key]) {
208
+ errors.push(`${where}.${key} must be a non-empty string`);
209
+ }
210
+ }
211
+ if (typeof target.min !== 'number' || !Number.isFinite(target.min) || target.min <= 0) {
212
+ errors.push(`${where}.min must be a positive number`);
213
+ }
214
+ if (target.label !== undefined && typeof target.label !== 'string') {
215
+ errors.push(`${where}.label must be a string`);
216
+ }
217
+ if (target.againstParent !== undefined && typeof target.againstParent !== 'boolean') {
218
+ errors.push(`${where}.againstParent must be a boolean`);
219
+ }
220
+ });
221
+ }
222
+
154
223
  /** Validate a parsed config, returning a list of human-readable problems. */
155
224
  export function validate(config) {
156
225
  const errors = [];
157
226
  if (!isPlainObject(config)) return ['the config must be a JSON object'];
158
- rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge'], 'the config', errors);
227
+ rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge', 'runtime'], 'the config', errors);
159
228
 
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"');
229
+ if (
230
+ config.contrast === undefined &&
231
+ config.treatments === undefined &&
232
+ config.judge === undefined &&
233
+ config.runtime === undefined
234
+ ) {
235
+ errors.push('the config must define at least one of "contrast", "treatments", "runtime" or "judge"');
162
236
  }
163
237
  if (config.contrast !== undefined) {
164
238
  if (isPlainObject(config.contrast)) validateContrast(config.contrast, errors);
@@ -172,6 +246,10 @@ export function validate(config) {
172
246
  if (isPlainObject(config.judge)) validateJudge(config.judge, errors);
173
247
  else errors.push('judge must be an object');
174
248
  }
249
+ if (config.runtime !== undefined) {
250
+ if (isPlainObject(config.runtime)) validateRuntime(config.runtime, errors);
251
+ else errors.push('runtime must be an object');
252
+ }
175
253
  return errors;
176
254
  }
177
255
 
package/src/index.mjs CHANGED
@@ -5,6 +5,8 @@
5
5
  export { runContrast } from './contrast.mjs';
6
6
  export { runTreatments } from './treatments.mjs';
7
7
  export { runJudge, buildPrompt, checklistLines, extractJson } from './judge.mjs';
8
+ export { runRuntime } from './runtime.mjs';
9
+ export { connect, findBrowser } from './cdp.mjs';
8
10
  export { load, validate } from './config.mjs';
9
11
  export { toText, toJson, failed } from './report.mjs';
10
12
  export { parseColor, contrastRatio, composite, luminance } from './color.mjs';
@@ -14,6 +16,7 @@ export { openTags, classesOf } from './treatments.mjs';
14
16
  import { runContrast } from './contrast.mjs';
15
17
  import { runTreatments } from './treatments.mjs';
16
18
  import { runJudge } from './judge.mjs';
19
+ import { runRuntime } from './runtime.mjs';
17
20
 
18
21
  /**
19
22
  * Run the deterministic checks a config asks for. `only` narrows to one by
@@ -38,3 +41,12 @@ export function run(config, cwd, { only = null } = {}) {
38
41
  export function judge(config, cwd) {
39
42
  return [runJudge(config.judge, cwd)];
40
43
  }
44
+
45
+ /**
46
+ * Measure a rendered page. Separate from `run` because it needs a browser and
47
+ * a server that is already up, which is a heavier precondition than reading
48
+ * files off disk, and not one to impose on the check people put in a hook.
49
+ */
50
+ export async function runtime(config, cwd) {
51
+ return [await runRuntime(config.runtime, cwd)];
52
+ }
package/src/report.mjs CHANGED
@@ -52,7 +52,7 @@ function judgeLines(result) {
52
52
  }
53
53
 
54
54
  export const linesFor = (result) => {
55
- if (result.name === 'contrast') return contrastLines(result);
55
+ if (result.name === 'contrast' || result.name === 'runtime') return contrastLines(result);
56
56
  if (result.name === 'judge') return judgeLines(result);
57
57
  return treatmentLines(result);
58
58
  };
@@ -79,7 +79,9 @@ export function toText(results) {
79
79
  out.push(`${result.name} ok, ${result.summary}`);
80
80
  // A clean contrast run still shows its margins. Nothing else in the
81
81
  // report tells you which pair is one nudge away from failing.
82
- if (result.name === 'contrast') for (const l of lines) out.push(` ${MARK.ok}${l.text}`);
82
+ if (result.name === 'contrast' || result.name === 'runtime') {
83
+ for (const l of lines) out.push(` ${MARK.ok}${l.text}`);
84
+ }
83
85
  out.push('');
84
86
  continue;
85
87
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Contrast measured off a rendered page.
3
+ *
4
+ * This is the check the contrast module is a static approximation of. A token
5
+ * file says what a colour is declared to be. A rendered page says what is
6
+ * actually painted, which is a different question the moment anything is
7
+ * translucent, inherited, set by a component, or overridden by a state.
8
+ *
9
+ * Two things it does that reading tokens cannot:
10
+ *
11
+ * 1. It composites the whole background stack, nearest layer first, rather
12
+ * than measuring against the first opaque ancestor. White text on a dark
13
+ * scrim over a light page reads 1.04:1 if you skip the scrim and 10.57:1
14
+ * if you do not. One of those is a false failure, and the same shortcut
15
+ * produces a false pass with the colours the other way round.
16
+ *
17
+ * 2. It can measure a state, because it can put the page into one first.
18
+ * A theme, an opened panel, a focused input.
19
+ *
20
+ * A selector that matches nothing is a failure, not a skipped target. So is a
21
+ * border with no width: you asked for the contrast of an edge that is not
22
+ * being drawn, and the honest answer is that the question is wrong.
23
+ */
24
+ import { contrastRatio, flatten, isOpaque, parseColor } from './color.mjs';
25
+ import { connect } from './cdp.mjs';
26
+
27
+ /**
28
+ * Runs in the page. Returns the foreground and the stack of backgrounds behind
29
+ * it, and leaves every judgement to the caller so that the reasoning lives in
30
+ * one place rather than half here and half in a string.
31
+ */
32
+ const MEASURE = `(targets) => targets.map(({ selector, prop, againstParent }) => {
33
+ const el = document.querySelector(selector);
34
+ if (!el) return { error: 'matched no element' };
35
+ if (!el.getClientRects().length) return { error: 'matched an element that is not rendered' };
36
+ const cs = getComputedStyle(el);
37
+ const fg = cs[prop];
38
+ if (!fg) return { error: 'has no ' + prop };
39
+ if (prop.startsWith('border')) {
40
+ const side = prop.replace(/^border|Color$/g, '');
41
+ const width = parseFloat(cs['border' + (side || 'Top') + 'Width']);
42
+ if (!width) return { error: 'has a ' + prop + ' but no width, so no edge is drawn' };
43
+ }
44
+ const layers = [];
45
+ let node = againstParent ? el.parentElement : el;
46
+ if (!node) return { error: 'has no parent to measure against' };
47
+ while (node) {
48
+ const bg = getComputedStyle(node).backgroundColor;
49
+ const m = bg && bg.match(/rgba?\\(([^)]+)\\)/);
50
+ if (m) {
51
+ const p = m[1].split(/[,\\/]/).map((v) => parseFloat(v.trim()));
52
+ const alpha = p.length > 3 ? p[3] : 1;
53
+ if (alpha > 0) {
54
+ layers.push(bg);
55
+ if (alpha > 0.999) break;
56
+ }
57
+ }
58
+ node = node.parentElement;
59
+ }
60
+ const root = getComputedStyle(document.documentElement).backgroundColor;
61
+ return { fg, layers, root, text: (el.textContent || '').trim().slice(0, 40) };
62
+ })`;
63
+
64
+ export async function runRuntime(config, cwd, { connect: open = connect } = {}) {
65
+ const samples = [];
66
+ const problems = [];
67
+ const { url, endpoint, browserPath, timeout, states = [{ name: 'default' }], targets } = config;
68
+
69
+ let page;
70
+ try {
71
+ page = await open({ endpoint, browserPath, timeout });
72
+ } catch (error) {
73
+ problems.push(error.message);
74
+ return { name: 'runtime', samples, problems, summary: '' };
75
+ }
76
+
77
+ try {
78
+ for (const state of states) {
79
+ // `before` runs on every navigation ahead of the page's own scripts, so
80
+ // a theme read at boot sees it. Running it after load and reloading
81
+ // would undo any DOM change; running it after load without reloading is
82
+ // too late for anything the page reads once. It is removed again after
83
+ // the state is measured, or the next state inherits it.
84
+ let initScript = null;
85
+ if (state.before) initScript = await page.onNewDocument(state.before);
86
+
87
+ await page.goto(url);
88
+ // `after` is the other half: opening a panel, focusing a field, anything
89
+ // that only exists once there is a document to act on.
90
+ if (state.after) await page.evaluate(state.after);
91
+ if (state.waitFor) {
92
+ await page.evaluate(
93
+ `new Promise((resolve, reject) => {
94
+ const done = () => document.querySelector(${JSON.stringify(state.waitFor)});
95
+ if (done()) return resolve(true);
96
+ const t = setInterval(() => { if (done()) { clearInterval(t); resolve(true); } }, 50);
97
+ setTimeout(() => { clearInterval(t); reject(new Error('waitFor ${state.waitFor} never appeared')); }, 10000);
98
+ })`,
99
+ );
100
+ }
101
+
102
+ const measured = await page.evaluate(`(${MEASURE})(${JSON.stringify(targets)})`);
103
+
104
+ targets.forEach((target, i) => {
105
+ const found = measured[i];
106
+ const where = `${target.selector} { ${target.prop} }`;
107
+ if (found.error) {
108
+ problems.push(`state "${state.name}": ${where} ${found.error}`);
109
+ return;
110
+ }
111
+ const fg = parseColor(found.fg);
112
+ if (!fg.ok) {
113
+ problems.push(`state "${state.name}": ${where} ${fg.reason}`);
114
+ return;
115
+ }
116
+ const layers = [];
117
+ for (const raw of found.layers) {
118
+ const parsed = parseColor(raw);
119
+ if (!parsed.ok) {
120
+ problems.push(`state "${state.name}": ${where} background ${parsed.reason}`);
121
+ return;
122
+ }
123
+ layers.push(parsed.rgba);
124
+ }
125
+ // Nothing opaque anywhere up the tree means the canvas is showing
126
+ // through, and the canvas is whatever the root paints.
127
+ if (!layers.length || !isOpaque(layers[layers.length - 1])) {
128
+ const root = parseColor(found.root);
129
+ layers.push(root.ok && isOpaque(root.rgba) ? root.rgba : [255, 255, 255, 1]);
130
+ }
131
+
132
+ const ground = flatten(layers);
133
+ const ratio = contrastRatio(fg.rgba, ground);
134
+ samples.push({
135
+ theme: state.name,
136
+ fg: target.selector,
137
+ bg: `${target.prop}, ${layers.length} ${layers.length === 1 ? 'layer' : 'layers'}`,
138
+ note: target.label ?? found.text ?? '',
139
+ ratio,
140
+ min: target.min,
141
+ pass: ratio >= target.min,
142
+ });
143
+ });
144
+
145
+ if (initScript) await page.removeNewDocumentScript(initScript);
146
+ }
147
+ } catch (error) {
148
+ problems.push(`the page could not be measured: ${error.message}`);
149
+ } finally {
150
+ page.close();
151
+ }
152
+
153
+ return {
154
+ name: 'runtime',
155
+ samples,
156
+ problems,
157
+ summary: `${samples.length} ${samples.length === 1 ? 'target' : 'targets'} across ${
158
+ states.length
159
+ } ${states.length === 1 ? 'state' : 'states'}`,
160
+ };
161
+ }