@josueavalosjim/taste-check 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@ Design review in CI, with a line down the middle of it.
4
4
 
5
5
  Some of design review is measurable. Contrast ratios, values that should have
6
6
  been tokens, class names nobody approved. taste-check measures those and fails
7
- your build on them.
7
+ your build on them, either from your token file or from a page it renders in a
8
+ real browser.
8
9
 
9
10
  The rest is judgment, and it cannot be measured. For that it sends your
10
11
  screenshots and your checklist to a model, and reports what comes back as an
@@ -180,6 +181,60 @@ direction to be wrong in.
180
181
  Template literal holes are read into rather than blanked, so a class written
181
182
  inside `` `card ${on ? 'card--on' : ''}` `` is seen.
182
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
+
183
238
  ## The judge
184
239
 
185
240
  Everything above measures. This asks the question a measurement cannot: not
@@ -266,9 +321,15 @@ from anywhere.
266
321
  | `judge.shotCommand` | Optional command run first to produce those screenshots. |
267
322
  | `judge.command` | The model command. Prompt on stdin, image paths as arguments. |
268
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. |
269
329
 
270
330
  ```
271
- 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
272
333
  taste-check judge [options] Ask a fresh-eyes judge about your screenshots
273
334
 
274
335
  -c, --config <path> Config file (default: tastecheck.config.json)
@@ -342,12 +403,6 @@ invisible to it.
342
403
 
343
404
  Not built. Written down so the shape is clear.
344
405
 
345
- **A runtime mode**, closing the gap named above by measuring `getComputedStyle`
346
- in a real browser, as an optional peer dependency so the core stays free of
347
- one. This is the one that matters most. An advisory judge is only worth
348
- listening to if the measured half beside it is genuinely measured, and reading
349
- a token file is the weaker version of that.
350
-
351
406
  **YAML configs**, once there is a reason to take on a parser.
352
407
 
353
408
  **SARIF output**, so findings land in a code scanning tab rather than only in
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.4.1",
4
- "description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast over your own tokens, one-off values in your own markup) and a fresh-eyes model judge whose verdicts stay advisory. Ships no design rules of its own.",
3
+ "version": "0.5.1",
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",
@@ -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,272 @@
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
+ * Waits for the browser to actually exit before removing its profile.
228
+ *
229
+ * Killing and deleting in the same breath loses a race with Chrome, which
230
+ * is still flushing its profile when the signal arrives. Best effort was
231
+ * not good enough: it left a directory behind on nearly every run, and a
232
+ * tool that litters the temp directory every time CI runs is not one to
233
+ * inflict on anybody.
234
+ *
235
+ * Still incapable of throwing. Teardown must never take down a run whose
236
+ * measurements already succeeded.
237
+ */
238
+ async close() {
239
+ try {
240
+ ws.close();
241
+ } catch {
242
+ /* already gone */
243
+ }
244
+ if (!child) return;
245
+ try {
246
+ if (child.exitCode === null && child.signalCode === null) {
247
+ const exited = new Promise((resolve) => child.once('exit', resolve));
248
+ child.kill();
249
+ // SIGTERM is usually enough. If it is not, stop being polite.
250
+ const forced = new Promise((resolve) => setTimeout(resolve, 2000)).then(() => {
251
+ try {
252
+ child.kill('SIGKILL');
253
+ } catch {
254
+ /* already gone */
255
+ }
256
+ return new Promise((resolve) => setTimeout(resolve, 300));
257
+ });
258
+ await Promise.race([exited, forced]);
259
+ }
260
+ } catch {
261
+ /* already gone */
262
+ }
263
+ if (profile) {
264
+ try {
265
+ rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
266
+ } catch {
267
+ /* the OS will collect it */
268
+ }
269
+ }
270
+ },
271
+ };
272
+ }
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
+ await 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
+ }