@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.
- package/README.md +20 -5
- package/bin/rhp-mcp.js +5 -1
- package/package.json +3 -3
- package/skill/SKILL.md +116 -26
- package/skill/recipes/area.html +6 -0
- package/skill/recipes/bar.html +9 -1
- package/skill/recipes/box-plot.html +9 -1
- package/skill/recipes/bubble.html +6 -0
- package/skill/recipes/bullet.html +6 -0
- package/skill/recipes/candlestick.html +9 -1
- package/skill/recipes/column.html +18 -5
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +6 -0
- package/skill/recipes/donut.html +9 -1
- package/skill/recipes/dumbbell.html +6 -0
- package/skill/recipes/gantt.html +17 -5
- package/skill/recipes/grouped-bars.html +6 -0
- package/skill/recipes/heatmap.html +9 -1
- package/skill/recipes/histogram.html +9 -1
- package/skill/recipes/line.html +7 -1
- package/skill/recipes/live.html +6 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +6 -0
- package/skill/recipes/multi-line.html +10 -5
- package/skill/recipes/pyramid.html +9 -1
- package/skill/recipes/race.html +6 -0
- package/skill/recipes/radial-bars.html +6 -0
- package/skill/recipes/scatter.html +23 -4
- package/skill/recipes/silhouettes.html +354 -0
- package/skill/recipes/slope.html +23 -4
- package/skill/recipes/sparklines.html +7 -1
- package/skill/recipes/stacked-100.html +6 -0
- package/skill/recipes/stacked-bars.html +6 -0
- package/skill/recipes/strip.html +9 -1
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +6 -0
- package/skill/recipes/waffle.html +6 -0
- package/skill/recipes/waterfall.html +6 -0
- package/skill/references/api.md +150 -22
- package/skill/references/design.md +303 -326
- package/skill/references/environments.md +11 -4
- package/skill/references/forms.md +41 -4
- package/skill/references/interaction.md +7 -2
- package/skill/references/looks.md +267 -0
- package/skill/references/performance.md +143 -0
- package/skill/references/pitfalls.md +52 -7
- package/src/check/README.md +33 -3
- package/src/check/browser.js +11 -5
- package/src/check/findings.js +15 -1
- package/src/check/index.js +182 -31
- package/src/check/inpage.js +82 -18
- package/src/check/interact.js +1 -19
- package/src/check/network.js +168 -0
- package/src/check/png.js +18 -0
- package/src/check/report.js +4 -1
- package/src/check/selection.js +123 -0
- package/src/guard/rhp.js +4 -0
- package/src/guard/rules.js +1 -1
- package/src/server.js +9 -8
package/src/check/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
}
|
|
258
|
+
}));
|
|
181
259
|
}
|
|
182
|
-
const got = await remote.get(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
};
|
package/src/check/inpage.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
972
|
-
|
|
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
|
}
|
package/src/check/interact.js
CHANGED
|
@@ -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 {
|
|
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).
|