@bezda/rhp-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +20 -5
  2. package/bin/rhp-mcp.js +5 -1
  3. package/package.json +3 -3
  4. package/skill/SKILL.md +116 -26
  5. package/skill/recipes/area.html +6 -0
  6. package/skill/recipes/bar.html +9 -1
  7. package/skill/recipes/box-plot.html +9 -1
  8. package/skill/recipes/bubble.html +6 -0
  9. package/skill/recipes/bullet.html +6 -0
  10. package/skill/recipes/candlestick.html +9 -1
  11. package/skill/recipes/column.html +18 -5
  12. package/skill/recipes/dial.html +349 -0
  13. package/skill/recipes/diverging-bars.html +6 -0
  14. package/skill/recipes/donut.html +9 -1
  15. package/skill/recipes/dumbbell.html +6 -0
  16. package/skill/recipes/gantt.html +17 -5
  17. package/skill/recipes/grouped-bars.html +6 -0
  18. package/skill/recipes/heatmap.html +9 -1
  19. package/skill/recipes/histogram.html +9 -1
  20. package/skill/recipes/line.html +7 -1
  21. package/skill/recipes/live.html +6 -0
  22. package/skill/recipes/log-line.html +493 -0
  23. package/skill/recipes/lollipop.html +6 -0
  24. package/skill/recipes/multi-line.html +10 -5
  25. package/skill/recipes/pyramid.html +9 -1
  26. package/skill/recipes/race.html +6 -0
  27. package/skill/recipes/radial-bars.html +6 -0
  28. package/skill/recipes/scatter.html +23 -4
  29. package/skill/recipes/silhouettes.html +354 -0
  30. package/skill/recipes/slope.html +23 -4
  31. package/skill/recipes/sparklines.html +7 -1
  32. package/skill/recipes/stacked-100.html +6 -0
  33. package/skill/recipes/stacked-bars.html +6 -0
  34. package/skill/recipes/strip.html +9 -1
  35. package/skill/recipes/unit-stack.html +276 -0
  36. package/skill/recipes/violin.html +6 -0
  37. package/skill/recipes/waffle.html +6 -0
  38. package/skill/recipes/waterfall.html +6 -0
  39. package/skill/references/api.md +150 -22
  40. package/skill/references/design.md +303 -326
  41. package/skill/references/environments.md +11 -4
  42. package/skill/references/forms.md +41 -4
  43. package/skill/references/interaction.md +7 -2
  44. package/skill/references/looks.md +267 -0
  45. package/skill/references/performance.md +143 -0
  46. package/skill/references/pitfalls.md +52 -7
  47. package/src/check/README.md +33 -3
  48. package/src/check/browser.js +11 -5
  49. package/src/check/findings.js +15 -1
  50. package/src/check/index.js +182 -31
  51. package/src/check/inpage.js +82 -18
  52. package/src/check/interact.js +1 -19
  53. package/src/check/network.js +168 -0
  54. package/src/check/png.js +18 -0
  55. package/src/check/report.js +4 -1
  56. package/src/check/selection.js +123 -0
  57. package/src/guard/rhp.js +4 -0
  58. package/src/guard/rules.js +1 -1
  59. package/src/server.js +9 -8
@@ -16,23 +16,26 @@
16
16
  // outDir where the screenshots go (a folder in the OS temp dir by default)
17
17
  // guard check how rhp is used, in the page (true by default; false is for proving the guard draws nothing)
18
18
  // freeze take the screenshots with CSS animations stopped (an endless one at its start), for comparing them
19
+ // settleTimeout readiness budget per settling wait in ms (10000 by default); an unsettled page fails explicitly
19
20
  // offline fetch nothing from the web (fonts fall back): for tests that must not depend on the network
20
21
  // browser a Playwright Browser to use instead of launching one (it is left open)
21
22
  // debug also save the screenshots the color probes read (as drawn, with the text hidden, with the marks hidden)
22
23
  //
23
- // Result: { ok, file, format, rhpVersion, browser, findings, charts, interactions, interactedAt, screenshots, timings,
24
+ // Result: { ok, file, format, rhpVersion, browser, findings, charts, interactions, interactedAt, swept, screenshots, timings,
24
25
  // text }
25
26
  // ok no finding is an error
26
27
  // findings [{ level: "error" | "warning" | "info", code, message, fix, width, widths }], errors first
27
28
  // charts [{ width, slats, blocks: { bar: 3, label: 6 }, box: { width, height } }]
28
29
  // interactions [{ width, kind, target, changed, how, error, moved, unseen }]
30
+ // swept [{ width, breakpoint, slats, jumps }]: the widths at which every slat was picked in turn (each width
31
+ // checked, and the page's own breakpoints between them), how many slats, and how many moved the layout
29
32
  // screenshots [{ width, dark, hover, path }]
30
33
  // text the report as plain text (what the CLI prints)
31
34
  import fs from "fs";
32
35
  import os from "os";
33
36
  import path from "path";
34
37
  import crypto from "crypto";
35
- import { pathToFileURL } from "url";
38
+ import { pathToFileURL, fileURLToPath } from "url";
36
39
  import { detectFormat, bundle, guardedStandalone, htmlProblems, packages, contentType } from "./build.js";
37
40
  import { launch, NO_BROWSER_FIX } from "./browser.js";
38
41
  import { install } from "./inpage.js";
@@ -40,9 +43,11 @@ import { fromProbe, fixForError, placeOf } from "./findings.js";
40
43
  import { textContrast, faintMarks, colorBlind } from "./colors.js";
41
44
  import { decode } from "./png.js";
42
45
  import { interact as interactionPass } from "./interact.js";
46
+ import { selectionBehavior } from "./selection.js";
43
47
  import { readMap, lookup } from "./sourcemap.js";
44
48
  import { sourceProblems } from "./source.js";
45
49
  import { report } from "./report.js";
50
+ import { fetchPublic, READ_METHODS, denyBrowserNetwork } from "./network.js";
46
51
 
47
52
  const ORIGIN = "http://rhp-check.localhost";
48
53
  const CDN = /^https?:\/\/(?:cdn\.jsdelivr\.net\/npm|unpkg\.com|esm\.sh)\/@bezda\/rhp(?:@([^/]+))?\/(?:dist\/)?([^?#]*)/;
@@ -51,6 +56,12 @@ const LEVELS = { error: 0, warning: 1, info: 2 };
51
56
  const PHONE = 600;
52
57
  // The height of what a phone shows of a page (Safari on a 390 by 844 iPhone, its bars shown): where the taps are tried
53
58
  const PHONE_WINDOW = 664;
59
+ // With motion on, the longest wait for a page to come to rest after it opens, and after a screenshot, in ms: a page
60
+ // that moves on its own never does
61
+ const MOVING_WAIT = 3000;
62
+ const MOVING_CALM = 500;
63
+ // The most breakpoint widths a width sweeps besides itself
64
+ const MAX_BREAKPOINTS = 6;
54
65
 
55
66
  const shortHash = (text) => crypto.createHash("sha1").update(text).digest("hex").slice(0, 8);
56
67
 
@@ -80,6 +91,39 @@ function source({ file, code, format }) {
80
91
  return { file: abs, text: code };
81
92
  }
82
93
 
94
+ // Picks every slat of the page's main Plot in turn, as a reader going from one to the next (a tap on a touch screen,
95
+ // the mouse elsewhere), on a page at rest with reduced motion asked for, where a pick shows at once; and lists what moved
96
+ // outside the plot (moved() in inpage.js): a readout that takes another line for one slat, at one width. What a pick
97
+ // throws is tagged with it in events, as the interaction pass does.
98
+ async function sweep(page, touch, events) {
99
+
100
+ const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets());
101
+ const jumps = [];
102
+ if (!touch) await page.mouse.move(0, 0);
103
+ await page.evaluate(() => window.__rhpProbe.place());
104
+ for (const s of slats) {
105
+ // (a slat that something covers has no point: pointing there would pick what covers it)
106
+ const point = await page.evaluate((n) => window.__rhpProbe.pointAt(n), s.target).catch(() => null);
107
+ if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
108
+ const name = `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
109
+ const before = events.length;
110
+ try {
111
+ if (touch) await page.touchscreen.tap(point.x, point.y);
112
+ else await page.mouse.move(point.x, point.y);
113
+ await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
114
+ } catch {
115
+ continue;
116
+ }
117
+ for (const e of events.slice(before)) {
118
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= `${touch ? "tap" : "hover"} ${name}`;
119
+ }
120
+ const moved = await page.evaluate((n) => window.__rhpProbe.moved(n, true), s.target).catch(() => []);
121
+ if (moved.length) jumps.push({ target: name, moved });
122
+ }
123
+ if (!touch) await page.mouse.move(0, 0);
124
+ return { slats: slats.length, jumps };
125
+ }
126
+
83
127
  export async function check(options = {}) {
84
128
 
85
129
  const started = performance.now();
@@ -89,6 +133,10 @@ export async function check(options = {}) {
89
133
  const dark = !!options.dark;
90
134
  const guard = options.guard !== false;
91
135
  const wantInteract = options.interact !== false;
136
+ const settleTimeout = options.settleTimeout ?? 10000;
137
+ const requireRest = (result) => {
138
+ if (!result.rest) throw Object.assign(new Error(`The page did not settle within ${settleTimeout}ms; layout and color measurements would be unreliable.`), { code: "page-unsettled" });
139
+ };
92
140
 
93
141
  const src = source(options);
94
142
  if (src.error) return finish({ file: options.file ?? null, format: options.format ?? null, findings: [{ level: "error", code: "no-file", message: src.error, fix: "Pass the path of the chart's file (an .html page, a .jsx component or a .js module)." }], timings, started });
@@ -138,18 +186,45 @@ export async function check(options = {}) {
138
186
  fs.mkdirSync(outDir, { recursive: true });
139
187
  const name = path.basename(file).replace(/\.[^.]+$/, "");
140
188
 
189
+ // Both the spelled path and the resolved path must remain below this root.
190
+ // This also rejects hidden paths and symlinks that lead outside the chart folder.
191
+ const scopedFile = (root, candidate) => {
192
+ try {
193
+ const inside = (base, name) => {
194
+ const rel = path.relative(base, name);
195
+ return rel && !rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel) && !rel.split(path.sep).some((part) => part.startsWith("."));
196
+ };
197
+ if (!inside(path.resolve(root), path.resolve(candidate))) return null;
198
+ const real = fs.realpathSync(candidate);
199
+ return inside(fs.realpathSync(root), real) && fs.statSync(real).isFile() ? real : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ };
204
+
141
205
  // Answers every request the page makes: the bundle and the user's files for a bundled chart, rhp from the CDN, and
142
206
  // the rest of the web (fonts) through a short timeout, kept for the next width
143
207
  const route = async (r) => {
144
208
  const url = r.request().url();
145
209
 
210
+ if (url.startsWith("file:")) {
211
+ let local = null;
212
+ try {
213
+ const requested = fileURLToPath(url);
214
+ local = path.resolve(requested) === path.resolve(file) ? file : scopedFile(path.dirname(file), requested);
215
+ } catch {
216
+ // A malformed file URL is not a local asset.
217
+ }
218
+ return local ? r.fulfill({ body: fs.readFileSync(local), contentType: contentType(local) }) : r.abort("blockedbyclient");
219
+ }
220
+
146
221
  if (url.startsWith(ORIGIN + "/")) {
147
222
  const p = decodeURIComponent(new URL(url).pathname);
148
- if (p === "/") return r.fulfill({ body: built.html, contentType: "text/html" });
149
- if (built.files.has(p)) return r.fulfill({ body: built.files.get(p).body, contentType: built.files.get(p).type });
223
+ if (p === "/" && built) return r.fulfill({ body: built.html, contentType: "text/html" });
224
+ if (built?.files.has(p)) return r.fulfill({ body: built.files.get(p).body, contentType: built.files.get(p).type });
150
225
  if (p === "/favicon.ico") return r.fulfill({ status: 204, body: "" });
151
- const local = path.join(path.dirname(file), p);
152
- if (local.startsWith(path.dirname(file)) && fs.existsSync(local) && fs.statSync(local).isFile()) return r.fulfill({ body: fs.readFileSync(local), contentType: contentType(local) });
226
+ const local = scopedFile(path.dirname(file), path.join(path.dirname(file), p));
227
+ if (local) return r.fulfill({ body: fs.readFileSync(local), contentType: contentType(local) });
153
228
  return r.fulfill({ status: 404, body: "not found: " + p });
154
229
  }
155
230
 
@@ -161,36 +236,43 @@ export async function check(options = {}) {
161
236
  const sub = rest === "standalone" || rest === "standalone.js" || rest === "" ? "standalone.js" : rest;
162
237
  const headers = { "access-control-allow-origin": "*" };
163
238
  if (sub === "standalone.js") return r.fulfill({ body: standalone ?? fs.readFileSync(path.join(pkgs.rhp.root, "dist/standalone.js")), contentType: "text/javascript", headers });
164
- const f = path.join(pkgs.rhp.root, "dist", sub.replace(/^dist\//, ""));
165
- if (fs.existsSync(f)) return r.fulfill({ body: fs.readFileSync(f), contentType: contentType(f), headers });
239
+ const f = scopedFile(path.join(pkgs.rhp.root, "dist"), path.join(pkgs.rhp.root, "dist", sub.replace(/^dist\//, "")));
240
+ if (f) return r.fulfill({ body: fs.readFileSync(f), contentType: contentType(f), headers });
166
241
  return r.fulfill({ status: 404, body: "not in @bezda/rhp: " + sub, headers });
167
242
  }
168
243
 
169
244
  if (!/^https?:/.test(url)) return r.continue();
170
245
  if (options.offline) return r.abort("internetdisconnected").catch(() => {});
171
246
 
172
- if (!remote.has(url)) {
173
- remote.set(url, fetched.has(url) ? Promise.resolve(fetched.get(url)) : r.fetch({ timeout: 5000, maxRedirects: 5 }).then(async (res) => {
174
- const got = { status: res.status(), headers: res.headers(), body: await res.body() };
175
- if (got.status >= 200 && got.status < 300 && fetchedBytes + got.body.length <= KEEP_BYTES) {
247
+ // The rest of the web, read only and from public addresses only (network.js); a GET answer is kept
248
+ const method = r.request().method().toUpperCase();
249
+ if (!READ_METHODS.has(method)) return r.abort("blockedbyclient").catch(() => {});
250
+ const key = method + " " + url;
251
+ if (!remote.has(key)) {
252
+ remote.set(key, method === "GET" && fetched.has(url) ? Promise.resolve(fetched.get(url)) : fetchPublic(url, { method, headers: r.request().headers() }).then((got) => {
253
+ if (method === "GET" && !got.error && got.status >= 200 && got.status < 300 && fetchedBytes + got.body.length <= KEEP_BYTES) {
176
254
  fetched.set(url, got);
177
255
  fetchedBytes += got.body.length;
178
256
  }
179
257
  return got;
180
- }, (e) => ({ error: String(e.message ?? e).split("\n")[0] })));
258
+ }));
181
259
  }
182
- const got = await remote.get(url);
183
- if (got.error) return r.abort("internetdisconnected").catch(() => {});
260
+ const got = await remote.get(key);
261
+ if (got.error) return r.abort(got.blocked ? "blockedbyclient" : "internetdisconnected").catch(() => {});
184
262
  return r.fulfill({ status: got.status, headers: got.headers, body: got.body }).catch(() => {});
185
263
  };
186
264
 
187
265
  const browser = launched.browser;
188
266
  const results = [];
189
267
  const screenshots = [];
268
+ const fenced = new Set();
269
+ const fence = await denyBrowserNetwork((url) => fenced.add(url));
190
270
  const isLocal = (u) => u.startsWith("file:") || u.startsWith(ORIGIN);
191
271
 
192
272
  // A page with the chart, the probe installed and at rest (in a window of another size when asked); what the page
193
- // throws or prints goes to events
273
+ // throws or prints goes to events. The page that is measured, with reduced motion, must come to rest. With motion on
274
+ // (the interaction pass), a page that moves on its own (a live feed, a race that plays) never does: it gets a few
275
+ // seconds for its fonts and entry animations, and is then tried as it moves, as a reader gets it.
194
276
  const open = async (context, events, reducedMotion, viewport = null) => {
195
277
  const page = await context.newPage();
196
278
  if (viewport) await page.setViewportSize(viewport);
@@ -210,23 +292,32 @@ export async function check(options = {}) {
210
292
  });
211
293
  await page.emulateMedia({ reducedMotion });
212
294
  await page.route("**/*", route);
295
+ await context.routeWebSocket(/.*/, (ws) => {
296
+ // A WebSocket can send arbitrary messages after its handshake, so it is not a read-only fetch.
297
+ events.push({ kind: "remote-failed", text: ws.url(), error: "net::ERR_BLOCKED_BY_CLIENT" });
298
+ ws.close({ code: 1008, reason: "WebSockets are blocked by the check" });
299
+ });
213
300
  await page.goto(format === "html" ? pathToFileURL(file).href : ORIGIN + "/", { waitUntil: "load", timeout: 20000 }).catch((e) => events.push({ kind: "pageerror", text: "The page did not finish loading: " + String(e.message).split("\n")[0] }));
214
301
  await page.evaluate(install);
215
- await page.evaluate(() => window.__rhpProbe.settle(2000));
302
+ const measured = reducedMotion === "reduce";
303
+ const rest = await page.evaluate((max) => window.__rhpProbe.settle(max), measured ? settleTimeout : Math.min(settleTimeout, MOVING_WAIT));
304
+ if (measured) requireRest(rest);
216
305
  return page;
217
306
  };
218
307
 
219
308
  // A screenshot, after which the page comes to rest again: taking one can lay the page out at another size for a
220
- // moment (see calm() in inpage.js)
221
- const capture = async (page, opts) => {
309
+ // moment (see calm() in inpage.js). In the interaction pass (moving: true) the page may move on its own, so it waits
310
+ // briefly and goes on.
311
+ const capture = async (page, opts, moving = false) => {
222
312
  const since = await page.evaluate(() => window.__rhpProbe.resized());
223
313
  const png = await page.screenshot(opts);
224
- await page.evaluate((n) => window.__rhpProbe.calm(n), since).catch(() => {});
314
+ const rest = await page.evaluate(([n, max]) => window.__rhpProbe.calm(n, max), [since, moving ? Math.min(settleTimeout, MOVING_CALM) : settleTimeout]);
315
+ if (!moving) requireRest(rest);
225
316
  return png;
226
317
  };
227
318
 
228
319
  // The screenshot the report gives, cropped to what the page draws
229
- const save = async (page, width, suffix) => {
320
+ const save = async (page, width, suffix, moving = false) => {
230
321
  const box = await page.evaluate(() => window.__rhpProbe.contentBox());
231
322
  const size = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight }));
232
323
  const pad = 16;
@@ -237,7 +328,7 @@ export async function check(options = {}) {
237
328
  height: Math.min(size.h, Math.ceil(box.bottom + pad)) - Math.max(0, Math.floor(box.top - pad)),
238
329
  };
239
330
  const png = path.join(outDir, `${name}-${width}${dark ? "-dark" : ""}${suffix ? "-" + suffix : ""}.png`);
240
- await capture(page, { type: "png", path: png, fullPage: true, animations: options.freeze ? "disabled" : "allow", ...(clip && clip.width > 0 && clip.height > 0 ? { clip } : {}) });
331
+ await capture(page, { type: "png", path: png, fullPage: true, animations: options.freeze ? "disabled" : "allow", ...(clip && clip.width > 0 && clip.height > 0 ? { clip } : {}) }, moving);
241
332
  screenshots.push({ width, dark, hover: suffix === "hover", path: png });
242
333
  };
243
334
 
@@ -246,15 +337,15 @@ export async function check(options = {}) {
246
337
  try {
247
338
  await Promise.all(widths.map(async (width, i) => {
248
339
  const phone = width <= PHONE;
249
- const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: phone ? 2 : 1, hasTouch: phone, colorScheme: dark ? "dark" : "light" });
340
+ // No service workers (they would answer requests outside the check's router) and no downloads
341
+ const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: phone ? 2 : 1, hasTouch: phone, colorScheme: dark ? "dark" : "light", serviceWorkers: "block", acceptDownloads: false, proxy: fence.proxy });
250
342
  const events = [];
251
343
  const w0 = performance.now();
344
+ try {
252
345
 
253
346
  // The page is measured and pictured with reduced motion asked for: rhp's moves end at once, and a page that moves
254
347
  // on its own (an autoplay, a live feed) waits or changes in steps, so the screenshots show what was measured
255
348
  const page = await open(context, events, "reduce");
256
- const seen = await page.evaluate(() => window.__rhpProbe.measure());
257
- const items = await page.evaluate(() => window.__rhpProbe.colorItems());
258
349
 
259
350
  // first, before any probe hides or restyles anything
260
351
  await save(page, width, "");
@@ -266,6 +357,10 @@ export async function check(options = {}) {
266
357
  return decode(png);
267
358
  };
268
359
  const drawn = keep(await shot(), "drawn");
360
+ // Full-page captures can trigger responsive layout handlers. Read geometry
361
+ // and text colors only after those handlers have come to rest again.
362
+ const seen = await page.evaluate(() => window.__rhpProbe.measure());
363
+ const items = await page.evaluate(() => window.__rhpProbe.colorItems());
269
364
  await page.evaluate(() => window.__rhpProbe.hide("text"));
270
365
  const noText = keep(await shot(), "no-text");
271
366
  let noMarks = null;
@@ -276,6 +371,26 @@ export async function check(options = {}) {
276
371
  await page.evaluate(() => window.__rhpProbe.show());
277
372
  const colorFindings = [...textContrast(items, noText, 1), ...(noMarks ? faintMarks(items, drawn, noMarks, 1) : []), ...colorBlind(items, noMarks, 1)];
278
373
 
374
+ // Every slat picked in turn, here and at the page's own breakpoints between this width and the next narrower
375
+ // one checked (a page of its own at each), where a layout that only fits at the widths checked jumps
376
+ const swept = [];
377
+ if (wantInteract) {
378
+ swept.push({ width, ...(await sweep(page, phone, events)) });
379
+ const next = [...widths].sort((a, b) => b - a).find((w) => w < width);
380
+ const between = next == null ? [] : (await page.evaluate(() => window.__rhpProbe.breakpoints())).filter((b) => b < width && b > next && !widths.includes(b));
381
+ for (const b of between.slice(0, MAX_BREAKPOINTS)) {
382
+ let at = null;
383
+ try {
384
+ at = await open(context, events, "reduce", { width: b, height: 900 });
385
+ swept.push({ width: b, breakpoint: true, ...(await sweep(at, phone, events)) });
386
+ } catch (error) {
387
+ if (error.code !== "page-unsettled") throw error;
388
+ } finally {
389
+ await at?.close();
390
+ }
391
+ }
392
+ }
393
+
279
394
  // with reduced motion rhp turns every block's transition off, so a slat's own is read with motion on
280
395
  await page.emulateMedia({ reducedMotion: "no-preference" });
281
396
  seen.found.push(...(await page.evaluate(() => window.__rhpProbe.transitions())));
@@ -286,10 +401,20 @@ export async function check(options = {}) {
286
401
  // and taps at a phone's, in the window a phone shows (not when the page never finished loading)
287
402
  const interactions = [];
288
403
  const unfocused = [];
404
+ let selection = { findings: [], tested: null };
289
405
  const loaded = !events.some((e) => e.kind === "pageerror" && /^The page did not finish loading/.test(e.text));
290
406
  if (wantInteract && loaded && (i === 0 || phone)) {
407
+ if (i === 0) {
408
+ const audit = await open(context, events, "no-preference");
409
+ try {
410
+ selection = await selectionBehavior(audit, { events });
411
+ guarded.push(...(await audit.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
412
+ } finally {
413
+ await audit.close();
414
+ }
415
+ }
291
416
  const fresh = await open(context, events, "no-preference", phone ? { width, height: PHONE_WINDOW } : null);
292
- const pass = { events, capture: (opts) => capture(fresh, opts), shoot: (suffix) => save(fresh, width, suffix) };
417
+ const pass = { events, capture: (opts) => capture(fresh, opts, true), shoot: (suffix) => save(fresh, width, suffix, true) };
293
418
  const passes = [];
294
419
  if (i === 0) passes.push(await interactionPass(fresh, pass));
295
420
  if (phone) passes.push(await interactionPass(fresh, { ...pass, taps: true }));
@@ -302,12 +427,22 @@ export async function check(options = {}) {
302
427
  guarded.push(...(await fresh.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
303
428
  }
304
429
 
305
- results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, ms: Math.round(performance.now() - w0) });
306
- await context.close();
430
+ results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, selection, swept, ms: Math.round(performance.now() - w0) });
431
+ } catch (error) {
432
+ if (error.code !== "page-unsettled") throw error;
433
+ findings.push({ level: "error", code: error.code, message: error.message, fix: "Let finite entry animations finish, honor prefers-reduced-motion for live updates, or increase the check's settleTimeout for a slow machine.", width });
434
+ } finally {
435
+ await context.close();
436
+ }
307
437
  }));
308
438
  } finally {
309
- if (!options.browser) await browser.close();
439
+ try {
440
+ if (!options.browser) await browser.close();
441
+ } finally {
442
+ await fence.close();
443
+ }
310
444
  }
445
+ for (const url of fenced) findings.push({ level: "info", code: "remote-failed", message: `The check blocked an unrouted browser connection to ${url}; no connection was forwarded.`, fix: "Use public HTTP assets or data requests; worker sockets and other direct browser connections are blocked." });
311
446
  lap("pages", t);
312
447
 
313
448
  results.sort((a, b) => a.index - b.index);
@@ -337,7 +472,8 @@ export async function check(options = {}) {
337
472
  } else if (e.kind === "request-failed") {
338
473
  findings.push({ level: "error", code: "request-failed", message: `${e.text.replace(ORIGIN, "").replace(/^file:\/\//, "")} failed to load (${e.error}).`, fix: "Fix the path, or add the file next to the chart.", width: r.width });
339
474
  } else if (e.kind === "remote-failed") {
340
- findings.push({ level: "info", code: "remote-failed", message: `${e.text} could not be fetched (${e.error}); the check went on without it.`, fix: "If the page needs it, check the URL; offline, fonts fall back to the next in the stack.", width: r.width });
475
+ if (/BLOCKED_BY_CLIENT/.test(e.error)) findings.push({ level: "info", code: "remote-failed", message: `${e.text} was not fetched: the check only reads from public addresses (GET, HEAD or OPTIONS), blocks WebSockets, and the page asked for something else; it went on without it.`, fix: "Load data and assets with plain GET requests from public URLs, or from files next to the chart; the checker does not forward request bodies or WebSocket messages.", width: r.width });
476
+ else findings.push({ level: "info", code: "remote-failed", message: `${e.text} could not be fetched (${e.error}); the check went on without it.`, fix: "If the page needs it, check the URL; offline, fonts fall back to the next in the stack.", width: r.width });
341
477
  }
342
478
  }
343
479
 
@@ -364,6 +500,13 @@ export async function check(options = {}) {
364
500
  if (jumps.has(key)) jumps.get(key).more++;
365
501
  else jumps.set(key, { code: "layout-jump", kind: x.kind, target: x.target, moved: x.moved, more: 0 });
366
502
  }
503
+ for (const sw of r.swept) {
504
+ for (const j of sw.jumps) {
505
+ const key = j.moved.map((m) => m.what).join("\n");
506
+ if (jumps.has(key)) jumps.get(key).more++;
507
+ else jumps.set(key, { code: "layout-jump", kind: "pick", target: sw.breakpoint ? `${j.target} at ${sw.width}px, beside one of the page's own breakpoints` : j.target, moved: j.moved, more: 0 });
508
+ }
509
+ }
367
510
  for (const j of jumps.values()) {
368
511
  findings.push({ ...fromProbe(j, {}), width: r.width });
369
512
  }
@@ -374,13 +517,19 @@ export async function check(options = {}) {
374
517
 
375
518
  // focus that shows nothing: one finding for the page
376
519
  if (r.unfocused.length) findings.push({ ...fromProbe({ code: "focus-invisible", what: r.unfocused[0], more: r.unfocused.length - 1 }, {}), width: r.width });
520
+
521
+ // Existing click selection with a temporary hover override, verified on an isolated page.
522
+ for (const f of r.selection.findings) findings.push({ ...fromProbe(f, {}), width: r.width });
523
+
377
524
  }
378
525
 
379
526
  const charts = results.flatMap((r) => r.seen.charts.map((c) => ({ width: r.width, ...c })));
527
+ const selectionAudits = results.flatMap((r) => r.selection.tested ? [{ width: r.width, ...r.selection.tested }] : []);
380
528
  const interactions = results.flatMap((r) => r.interactions);
529
+ const swept = results.flatMap((r) => r.swept.map(({ width, breakpoint, slats, jumps }) => ({ width, breakpoint: !!breakpoint, slats, jumps: jumps.length }))).sort((a, b) => b.width - a.width);
381
530
  timings.widths = Object.fromEntries(results.map((r) => [r.width, r.ms]));
382
531
 
383
- return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, screenshots, timings, started });
532
+ return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, selectionAudits, swept, screenshots, timings, started });
384
533
  }
385
534
 
386
535
  // Merges the same finding at several widths, sorts errors first, and writes the report
@@ -410,6 +559,8 @@ function finish({ findings, started, timings, ...rest }) {
410
559
  charts: rest.charts ?? [],
411
560
  interactions: rest.interactions ?? [],
412
561
  interactedAt: rest.interactedAt ?? null,
562
+ selectionAudits: rest.selectionAudits ?? [],
563
+ swept: rest.swept ?? [],
413
564
  screenshots: rest.screenshots ?? [],
414
565
  timings,
415
566
  };
@@ -22,12 +22,13 @@ export function install() {
22
22
  // Waits for fonts, two frames, the animations that end (CSS transitions, a slat's own animations) and a DOM that
23
23
  // stays still for two frames (the JS version moves numbers on its own clock), up to max ms. Says whether the page
24
24
  // came to rest.
25
- async function settle(max = 2000) {
25
+ async function settle(max = 10000) {
26
26
 
27
27
  const start = performance.now();
28
28
  const left = () => max - (performance.now() - start);
29
29
 
30
- await Promise.race([document.fonts?.ready, new Promise((r) => setTimeout(r, Math.max(0, left())))]);
30
+ let timer;
31
+ await Promise.race([document.fonts?.ready, new Promise((r) => { timer = setTimeout(r, Math.max(0, left())); })]).finally(() => clearTimeout(timer));
31
32
  await frame();
32
33
  await frame();
33
34
 
@@ -39,7 +40,7 @@ export function install() {
39
40
  still = !moving && mutations === before ? still + 1 : 0;
40
41
  }
41
42
 
42
- return { ms: Math.round(performance.now() - start), rest: still >= 2 };
43
+ return { ms: Math.round(performance.now() - start), rest: still >= 2 && document.fonts?.status !== "loading" };
43
44
  }
44
45
 
45
46
  // A full-page screenshot lays the page out at another size for a moment (Chromium does, to draw what is past the
@@ -48,11 +49,12 @@ export function install() {
48
49
  // since.
49
50
  let resizes = 0;
50
51
  addEventListener("resize", () => resizes++);
51
- async function calm([r, m]) {
52
+ async function calm([r, m], max = 10000) {
52
53
 
53
54
  await frame();
54
55
  const moving = document.getAnimations().some((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime ?? Infinity));
55
- if (resizes !== r && (mutations !== m || moving)) await settle(500);
56
+ if (resizes !== r || mutations !== m || moving || document.fonts?.status === "loading") return settle(max);
57
+ return { rest: true };
56
58
  }
57
59
 
58
60
  const visible = (el) => !!el && el.checkVisibility?.({ opacityProperty: true, visibilityProperty: true }) !== false;
@@ -958,6 +960,62 @@ export function install() {
958
960
  return { left: left + scrollX, top: top + scrollY, right: right + scrollX, bottom: bottom + scrollY };
959
961
  }
960
962
 
963
+ // The slats of the main Plot, in screen order: the largest chart's (a size key drawn as a chart of its own comes before
964
+ // the chart it explains) first Plot with slats that takes the pointer (an overlay of guides or a readout takes none)
965
+ function mainSlats() {
966
+
967
+ const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
968
+ const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
969
+ const plots = chart ? [...chart.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale") && [...p.children].some((s) => !s.hidden)) : [];
970
+ const plot = plots.find((p) => getComputedStyle(p).pointerEvents !== "none") ?? plots[0];
971
+ if (!plot) return [];
972
+ const vertical = plot.dataset.rhpO === "v";
973
+ const slats = [...plot.children].filter((s) => !s.hidden && visible(s));
974
+ slats.sort((a, b) => (vertical ? a.getBoundingClientRect().left - b.getBoundingClientRect().left : a.getBoundingClientRect().top - b.getBoundingClientRect().top));
975
+ return slats;
976
+ }
977
+
978
+ // The slats the sweep picks in turn (see sweep() in index.js): every slat of the main Plot, or max of them spread
979
+ // evenly from the first to the last
980
+ function sweepTargets(max = 32) {
981
+
982
+ const slats = mainSlats();
983
+ const list = (window.__rhpTargets ??= []);
984
+ const picks = slats.length <= max ? slats.map((_, i) => i) : [...new Set(Array.from({ length: max }, (_, k) => Math.round((k * (slats.length - 1)) / (max - 1))))];
985
+ return picks.map((i) => ({ index: i + 1, of: slats.length, name: snippet([...slats[i].querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? ""), target: list.push(slats[i]) - 1 }));
986
+ }
987
+
988
+ // The window widths at which the page's own media queries switch: the last width of one layout and the first of the
989
+ // next (for max-width: N, N and N + 1; for min-width: N, N - 1 and N; the range forms likewise). An em counts 16px.
990
+ function breakpoints() {
991
+
992
+ const out = new Set();
993
+ const px = (v, unit) => (unit === "px" ? +v : +v * 16);
994
+ // the first width of the wider layout, from a bound and whether the narrow layout includes it
995
+ const add = (n, narrowHasIt) => {
996
+ const first = narrowHasIt ? Math.floor(n) + 1 : Math.ceil(n);
997
+ out.add(first - 1);
998
+ out.add(first);
999
+ };
1000
+ const walk = (rules) => {
1001
+ for (const r of rules ?? []) {
1002
+ const text = r.media?.mediaText ?? "";
1003
+ for (const m of text.matchAll(/(min|max)-width\s*:\s*([\d.]+)(px|r?em)/g)) add(px(m[2], m[3]), m[1] === "max");
1004
+ for (const m of text.matchAll(/width\s*(<=|<|>=|>)\s*([\d.]+)(px|r?em)/g)) add(px(m[2], m[3]), m[1] === "<=" || m[1] === ">");
1005
+ for (const m of text.matchAll(/([\d.]+)(px|r?em)\s*(<=|<|>=|>)\s*width/g)) add(px(m[1], m[2]), m[3] === ">=" || m[3] === "<");
1006
+ if (r.cssRules) walk(r.cssRules);
1007
+ }
1008
+ };
1009
+ for (const sheet of [...document.styleSheets, ...(document.adoptedStyleSheets ?? [])]) {
1010
+ try {
1011
+ walk(sheet.cssRules);
1012
+ } catch {
1013
+ // a style sheet from another origin (a font's CSS) cannot be read, and holds no layout
1014
+ }
1015
+ }
1016
+ return [...out].filter((w) => w >= 200).sort((a, b) => b - a);
1017
+ }
1018
+
961
1019
  // What the interaction pass works on: slats to point at (the first, the middle and the last of the largest chart, and
962
1020
  // the one whose edge Label is widest: lit, a name that gets bolder can make room: "auto" measure again), buttons and
963
1021
  // toggles, ranges, selects, a slider for the arrow keys, Plots with keyboard. Controls go by their accessible name.
@@ -968,16 +1026,8 @@ export function install() {
968
1026
  const out = { slats: [], widest: null, buttons: [], ranges: [], selects: [], slider: null, keyboard: null };
969
1027
  const label = (el) => (nameOf(el) ? `"${snippet(nameOf(el))}"` : describe(el));
970
1028
 
971
- // the largest chart (a size key drawn as a chart of its own comes before the chart it explains)
972
- const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
973
- const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
974
- // its first Plot with slats that takes the pointer (an overlay of guides or a readout takes none)
975
- const plots = chart ? [...chart.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale") && [...p.children].some((s) => !s.hidden)) : [];
976
- const plot = plots.find((p) => getComputedStyle(p).pointerEvents !== "none") ?? plots[0];
977
- if (plot) {
978
- const vertical = plot.dataset.rhpO === "v";
979
- const slats = [...plot.children].filter((s) => !s.hidden && visible(s));
980
- slats.sort((a, b) => (vertical ? a.getBoundingClientRect().left - b.getBoundingClientRect().left : a.getBoundingClientRect().top - b.getBoundingClientRect().top));
1029
+ const slats = mainSlats();
1030
+ if (slats.length) {
981
1031
  const range = document.createRange();
982
1032
  const nameWidth = (s) => Math.max(0, ...[...s.querySelectorAll(".rhp-label[data-rhp-edge]")].map((l) => {
983
1033
  range.selectNodeContents(l);
@@ -1028,10 +1078,10 @@ export function install() {
1028
1078
  // point reaches it and something in the slat takes the pointer, something covers it: { covered } says what (another
1029
1079
  // slat of its Plot, or an element). When nothing in it takes the pointer (the page reads the pointer around the
1030
1080
  // chart), the first point on its first mark, or its center.
1031
- function pointAt(n, across = 0.5) {
1081
+ function pointAt(n, across = 0.5, scroll = true) {
1032
1082
 
1033
1083
  const slat = window.__rhpTargets[n];
1034
- slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1084
+ if (scroll) slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1035
1085
  const marks = [...slat.querySelectorAll(MARKS + ", .rhp-cell")].filter(visible)
1036
1086
  .map((m) => ({ m, r: m.getBoundingClientRect(), pointer: getComputedStyle(m).cursor === "pointer" }))
1037
1087
  .filter(({ r }) => r.width * r.height > 0);
@@ -1090,6 +1140,7 @@ export function install() {
1090
1140
  // moves with it (rows sorted or removed), so only what it moves apart from its slat counts. What is fixed or sticky
1091
1141
  // moves with scrolling, so it is left out.
1092
1142
  let placed = [];
1143
+ let sections = [];
1093
1144
  const at = (el) => {
1094
1145
  const r = el.getBoundingClientRect();
1095
1146
  return { el, x: r.left + scrollX, y: r.top + scrollY };
@@ -1118,6 +1169,13 @@ export function install() {
1118
1169
  const slat = chart ? null : el.closest(".rhp-plot > *");
1119
1170
  return { ...at(el), chart, slat: slat && at(slat), gutters: chart ? gutters(el) : null };
1120
1171
  });
1172
+ // the end of each chart's section (its outermost box inside the body): a readout after the chart that takes
1173
+ // another line makes it longer, even on a page shorter than the window
1174
+ sections = [...new Set(charts.map((c) => {
1175
+ let a = c;
1176
+ while (a.parentElement && a.parentElement !== document.body && a.parentElement !== document.documentElement) a = a.parentElement;
1177
+ return a;
1178
+ }))].map((el) => ({ el, end: el.getBoundingClientRect().bottom + scrollY }));
1121
1179
  }
1122
1180
 
1123
1181
  function moved(n, lit = false) {
@@ -1150,6 +1208,12 @@ export function install() {
1150
1208
  if (now.o === "v" && far(start)) out.push({ what: "the plot's bottom edge", dx: 0, dy: -start, plot: true });
1151
1209
  if (now.o === "v" && far(end)) out.push({ what: "the plot's top edge", dx: 0, dy: end, plot: true });
1152
1210
  }
1211
+ // lighting a slat makes no section longer or shorter: a readout after the chart that takes another line moves
1212
+ // whatever follows it, which no control may show
1213
+ for (const { el, end } of lit ? sections : []) {
1214
+ const grew = Math.round(el.getBoundingClientRect().bottom + scrollY - end);
1215
+ if (el.isConnected && far(grew)) out.push({ what: "the end of the chart's section", dx: 0, dy: grew });
1216
+ }
1153
1217
 
1154
1218
  return out;
1155
1219
  }
@@ -1264,5 +1328,5 @@ export function install() {
1264
1328
  return { what: focusName(el), clip: box };
1265
1329
  }
1266
1330
 
1267
- window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, pointAt, place, moved, record, stop, unseen, focused, focusStop };
1331
+ window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, sweepTargets, breakpoints, pointAt, place, moved, record, stop, unseen, focused, focusStop };
1268
1332
  }
@@ -5,7 +5,7 @@
5
5
  // look), what threw, and what moved: a control other than the one used, a chart's top, or the plot while a slat is
6
6
  // lit (the layout jumps); for a tap, also whether the reader sees none of the text it changed (unseen): all of it lies
7
7
  // outside the window.
8
- import { decode } from "./png.js";
8
+ import { seenPixels } from "./png.js";
9
9
 
10
10
  const changes = (seen) => {
11
11
 
@@ -20,24 +20,6 @@ const changes = (seen) => {
20
20
  return parts.join(", ");
21
21
  };
22
22
 
23
- // How many pixels of two screenshots of one box differ enough to see (their channels by more than 24 in all: a
24
- // tint of 6% of the ink over the paper shows, drawing one page twice changes nothing)
25
- function seenPixels(a, b) {
26
-
27
- if (a.equals(b)) return 0;
28
-
29
- const x = decode(a);
30
- const y = decode(b);
31
- if (x.width !== y.width || x.height !== y.height) return Infinity;
32
-
33
- let n = 0;
34
- for (let i = 0; i < x.data.length; i += 4) {
35
- if (Math.abs(x.data[i] - y.data[i]) + Math.abs(x.data[i + 1] - y.data[i + 1]) + Math.abs(x.data[i + 2] - y.data[i + 2]) > 24) n++;
36
- }
37
-
38
- return n;
39
- }
40
-
41
23
  // Tab through the page (up to 8 stops, until it comes back to one): each stop's box, with room around it for an
42
24
  // outline, is pictured focused and then blurred (Tab goes on from an element blurred). A stop where under 20 pixels
43
25
  // change shows the keyboard reader nothing (outline: none, and nothing in its place).