@bezda/rhp-mcp 0.0.0-stage → 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,266 @@
1
+ // The interaction pass: point at a mark of the first, middle and last slat (and of the slat whose edge Label is
2
+ // widest), click each button and toggle (up to 12), change each range and select, press an arrow key on a slider, and
3
+ // press Tab and the arrow keys when slats take focus. Then Tab through the page, and note each stop where focus shows
4
+ // nothing. On a phone (taps), tap a mark of the first and the last slat. For each, what changed (the DOM, or only the
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
+ // lit (the layout jumps); for a tap, also whether the reader sees none of the text it changed (unseen): all of it lies
7
+ // outside the window.
8
+ import { decode } from "./png.js";
9
+
10
+ const changes = (seen) => {
11
+
12
+ if (!seen) return "";
13
+
14
+ const parts = [];
15
+ if (seen.text) parts.push(`${seen.text} text${seen.text > 1 ? "s" : ""}`);
16
+ if (seen.elements) parts.push(`${seen.elements} element${seen.elements > 1 ? "s" : ""}`);
17
+ if (seen.attributes) parts.push(`${seen.attributes} attribute${seen.attributes > 1 ? "s" : ""}`);
18
+ if (seen.styles) parts.push(`${seen.styles} style${seen.styles > 1 ? "s" : ""}`);
19
+
20
+ return parts.join(", ");
21
+ };
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
+ // 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
+ // outline, is pictured focused and then blurred (Tab goes on from an element blurred). A stop where under 20 pixels
43
+ // change shows the keyboard reader nothing (outline: none, and nothing in its place).
44
+ async function focusRings(page) {
45
+
46
+ const hidden = [];
47
+ const settle = () => page.evaluate(() => window.__rhpProbe.settle(1000)).catch(() => {});
48
+ const shot = (clip) => page.screenshot({ clip, scale: "css", animations: "allow" }).catch(() => null);
49
+
50
+ // (the pointer away from the page, so that no slat is lit by it)
51
+ await page.mouse.move(0, 0);
52
+ await page.evaluate(() => {
53
+ document.activeElement?.blur?.();
54
+ window.__rhpProbe.focusStop(true);
55
+ });
56
+
57
+ for (let press = 0, stops = 0; press < 30 && stops < 8; press++) {
58
+ await page.keyboard.press("Tab");
59
+ const at = await page.evaluate(() => window.__rhpProbe.focusStop()).catch(() => null);
60
+ if (!at) continue;
61
+ if (at.again) break;
62
+ stops++;
63
+ if (!at.clip) continue;
64
+ await settle();
65
+ const focused = await shot(at.clip);
66
+ await page.evaluate(() => document.activeElement?.blur?.());
67
+ await settle();
68
+ const blurred = await shot(at.clip);
69
+ if (focused && blurred && seenPixels(focused, blurred) < 20) hidden.push(at.what);
70
+ }
71
+
72
+ return hidden;
73
+ }
74
+
75
+ // interact(page, { events, capture, shoot, taps }) -> { interactions: [{ kind, target, changed, how, error, moved,
76
+ // unseen }], unfocused: [what focus showed nothing on] }
77
+ // events the page's list of errors, which this tags with the interaction that raised them
78
+ // capture takes a screenshot of the page (and lets it come to rest after)
79
+ // shoot saves the report's screenshot of the page as it is ("hover")
80
+ // taps tap the slats (a phone) instead of the whole pass
81
+ export async function interact(page, { events, capture, shoot, taps = false }) {
82
+
83
+ const list = [];
84
+ const t = await page.evaluate(() => window.__rhpProbe.targets());
85
+ const handle = (i) => page.evaluateHandle((n) => window.__rhpTargets[n], i);
86
+ const look = () => capture({ type: "png", fullPage: true, animations: "allow" }).catch(() => null);
87
+ // (a slat that something covers is not pointed at: the pointer would land on what covers it)
88
+ let covered = null;
89
+ const pointAt = async (n, across) => {
90
+ const point = await page.evaluate(([i, x]) => window.__rhpProbe.pointAt(i, x), [n, across]);
91
+ covered = point.covered ?? null;
92
+ if (covered) throw new Error("covered");
93
+ return point;
94
+ };
95
+
96
+ // ready runs before the layout is noted (it scrolls the target into view), act is the interaction itself. lit: the
97
+ // interaction lights a slat (no data changes), so the plot must stay where it is; texts: note the texts it changes
98
+ const step = async (kind, target, { ready, act, compareLook = false, used = null, lit = false, texts = false }) => {
99
+ const before = events.length;
100
+ const rest = compareLook ? await look() : null;
101
+ let error = null;
102
+ const attempt = async (f) => {
103
+ try {
104
+ await f?.();
105
+ } catch (e) {
106
+ error = String(e.message ?? e).split("\n")[0];
107
+ }
108
+ };
109
+ await attempt(ready);
110
+ await page.evaluate((keep) => {
111
+ window.__rhpProbe.place();
112
+ window.__rhpProbe.record(keep);
113
+ }, texts);
114
+ if (!error) await attempt(act);
115
+ await page.evaluate(() => window.__rhpProbe.settle(1500)).catch(() => {});
116
+ const seen = await page.evaluate(() => window.__rhpProbe.stop()).catch(() => null);
117
+ const moved = await page.evaluate(([n, l]) => window.__rhpProbe.moved(n, l), [used, lit]).catch(() => []);
118
+ // a value a slat read once, and that this interaction changed, is shown stale (the guard reads them again)
119
+ await page.evaluate(() => globalThis.__rhpCheck?.recheck?.()).catch(() => {});
120
+ const thrown = events.slice(before).filter((e) => e.kind === "pageerror" || e.kind === "console-error");
121
+ for (const e of thrown) {
122
+ e.during = `${kind} ${target}`;
123
+ }
124
+ const dom = changes(seen);
125
+ let how = dom ? `changed ${dom}` : "";
126
+ if (!dom && compareLook) {
127
+ const now = await look();
128
+ if (rest && now && !rest.equals(now)) how = "changed the look (CSS only)";
129
+ }
130
+ const entry = { kind, target, changed: !!how, how: how || "nothing changed", error: thrown[0]?.text ?? null, moved, could: error };
131
+ list.push(entry);
132
+ return entry;
133
+ };
134
+
135
+ const slatName = (s) => `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
136
+
137
+ // a slat no point of which is under the pointer, and what covers it
138
+ const unreached = (entry, s) => {
139
+ if (entry.could !== "covered") return;
140
+ entry.how = covered === "slat"
141
+ ? `could not reach slat ${s.index}: the other slats of its Plot cover it (overlap slats that take the pointer: give the slat's root pointer-events: none and its marks auto)`
142
+ : `could not reach slat ${s.index}: ${covered} covers its marks`;
143
+ entry.changed = false;
144
+ entry.missed = true;
145
+ };
146
+
147
+ // (two taps in a row, with no rest between them as a pointer gets: one left of the middle, one right of it, so a
148
+ // chart that reads the value under the finger shows another. The last slat is the one furthest from a readout above
149
+ // the chart.)
150
+ if (taps) {
151
+ for (const [k, s] of [...new Set([t.slats[0], t.slats.at(-1)])].filter(Boolean).entries()) {
152
+ let point = null;
153
+ const entry = await step("tap", slatName(s), {
154
+ ready: async () => {
155
+ point = await pointAt(s.target, k ? 0.7 : 0.3);
156
+ },
157
+ act: () => page.touchscreen.tap(point.x, point.y),
158
+ compareLook: true,
159
+ lit: true,
160
+ texts: true,
161
+ });
162
+ unreached(entry, s);
163
+ // a tap the reader sees none of the text of: all the text it changed lies outside the window
164
+ entry.unseen = entry.changed ? await page.evaluate((n) => window.__rhpProbe.unseen(n), s.target).catch(() => null) : null;
165
+ if (entry.unseen) entry.how += ", all of its text out of view";
166
+ }
167
+ return { interactions: list.map(({ could, ...rest }) => rest), unfocused: [] };
168
+ }
169
+
170
+ for (const [k, s] of [...t.slats, t.widest].filter(Boolean).entries()) {
171
+ let point = null;
172
+ const entry = await step("hover", slatName(s), {
173
+ ready: async () => {
174
+ await page.mouse.move(0, 0);
175
+ point = await pointAt(s.target);
176
+ },
177
+ act: () => page.mouse.move(point.x, point.y, { steps: 2 }),
178
+ compareLook: true,
179
+ lit: true,
180
+ });
181
+ unreached(entry, s);
182
+ if (k === Math.min(1, t.slats.length - 1) && shoot) await shoot("hover");
183
+ await page.mouse.move(0, 0);
184
+ await page.evaluate(() => window.__rhpProbe.settle(1000)).catch(() => {});
185
+ }
186
+
187
+ for (const b of t.buttons) {
188
+ const el = await handle(b.target);
189
+ const role = b.role === "checkbox" || b.role === "radio" || b.role === "switch" ? b.role : "button";
190
+ const entry = await step("click", `${role} ${b.label}`, {
191
+ ready: () => el.asElement().scrollIntoViewIfNeeded({ timeout: 1500 }),
192
+ act: () => el.asElement().click({ timeout: 1500 }),
193
+ used: b.target,
194
+ });
195
+ if (entry.could) entry.how = `could not be clicked (${/intercepts pointer|covered|not visible|outside of the viewport/.test(entry.could) ? "covered or out of view" : entry.could})`;
196
+ }
197
+
198
+ for (const r of t.ranges) {
199
+ const el = await handle(r.target);
200
+ await step("input", `range ${r.label} to ${r.value}`, {
201
+ act: () => el.asElement().evaluate((input, v) => {
202
+ input.value = String(v);
203
+ input.dispatchEvent(new Event("input", { bubbles: true }));
204
+ input.dispatchEvent(new Event("change", { bubbles: true }));
205
+ }, r.value),
206
+ used: r.target,
207
+ });
208
+ }
209
+
210
+ for (const s of t.selects) {
211
+ const el = await handle(s.target);
212
+ await step("select", `select ${s.label} to ${JSON.stringify(s.value)}`, { act: () => el.asElement().selectOption(s.value), used: s.target });
213
+ }
214
+
215
+ // an arrow key on a slider, away from the end it is at
216
+ if (t.slider) {
217
+ const el = (await handle(t.slider.target)).asElement();
218
+ const key = await el.evaluate((s) => {
219
+ const range = s.localName === "input";
220
+ const now = parseFloat(range ? s.value : s.getAttribute("aria-valuenow"));
221
+ const max = parseFloat(range ? s.max || "100" : s.getAttribute("aria-valuemax"));
222
+ return now >= max ? "ArrowLeft" : "ArrowRight";
223
+ });
224
+ await step("key", `${key} on slider ${t.slider.label}`, {
225
+ ready: () => el.focus(),
226
+ act: () => page.keyboard.press(key),
227
+ used: t.slider.target,
228
+ });
229
+ }
230
+
231
+ if (t.keyboard) {
232
+ await page.evaluate(() => document.activeElement?.blur?.());
233
+ let at = null;
234
+ let presses = 0;
235
+ const tab = await step("key", "Tab", {
236
+ act: async () => {
237
+ for (presses = 1; presses <= 25; presses++) {
238
+ await page.keyboard.press("Tab");
239
+ at = await page.evaluate(() => window.__rhpProbe.focused());
240
+ if (at?.slat) break;
241
+ }
242
+ },
243
+ lit: true,
244
+ });
245
+ tab.how = at?.slat ? `reached slat ${at.index}${at.name ? ` (${at.name})` : ""} after ${presses} press${presses > 1 ? "es" : ""}` : "never reached a slat";
246
+ tab.changed = !!at?.slat;
247
+ if (at?.slat) {
248
+ const key = t.keyboard.vertical ? "ArrowRight" : "ArrowDown";
249
+ let next = null;
250
+ const arrow = await step("key", key, {
251
+ act: async () => {
252
+ await page.keyboard.press(key);
253
+ next = await page.evaluate(() => window.__rhpProbe.focused());
254
+ },
255
+ lit: true,
256
+ });
257
+ const moved = next?.slat && next.index !== at.index;
258
+ arrow.how = moved ? `focus moved to slat ${next.index}${next.name ? ` (${next.name})` : ""}${arrow.how !== "nothing changed" ? `, ${arrow.how}` : ""}` : "focus did not move";
259
+ arrow.changed = !!moved;
260
+ }
261
+ }
262
+
263
+ const unfocused = await focusRings(page);
264
+
265
+ return { interactions: list.map(({ could, ...rest }) => rest), unfocused };
266
+ }
@@ -0,0 +1,98 @@
1
+ // PNG to pixels, for the screenshots the probes read colors from (8-bit gray, RGB, gray + alpha or RGBA, not
2
+ // interlaced: what Chromium writes).
3
+ import zlib from "zlib";
4
+
5
+ const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
6
+
7
+ const paeth = (a, b, c) => {
8
+ const p = a + b - c;
9
+ const pa = Math.abs(p - a);
10
+ const pb = Math.abs(p - b);
11
+ const pc = Math.abs(p - c);
12
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
13
+ };
14
+
15
+ // Returns { width, height, data } with data as RGBA bytes, row by row
16
+ export function decode(buffer) {
17
+
18
+ if (buffer.readUInt32BE(0) !== 0x89504e47) throw new Error("not a PNG");
19
+
20
+ let width = 0;
21
+ let height = 0;
22
+ let depth = 0;
23
+ let type = 0;
24
+ let interlace = 0;
25
+ const idat = [];
26
+
27
+ for (let at = 8; at < buffer.length;) {
28
+ const length = buffer.readUInt32BE(at);
29
+ const name = buffer.toString("ascii", at + 4, at + 8);
30
+ const body = buffer.subarray(at + 8, at + 8 + length);
31
+ if (name === "IHDR") {
32
+ width = body.readUInt32BE(0);
33
+ height = body.readUInt32BE(4);
34
+ depth = body[8];
35
+ type = body[9];
36
+ interlace = body[12];
37
+ } else if (name === "IDAT") {
38
+ idat.push(body);
39
+ } else if (name === "IEND") {
40
+ break;
41
+ }
42
+ at += 12 + length;
43
+ }
44
+
45
+ const channels = CHANNELS[type];
46
+ if (depth !== 8 || !channels || interlace) throw new Error(`unsupported PNG (depth ${depth}, type ${type}, interlace ${interlace})`);
47
+
48
+ const raw = zlib.inflateSync(Buffer.concat(idat));
49
+ const stride = width * channels;
50
+ const rows = Buffer.alloc(stride * height);
51
+
52
+ for (let y = 0; y < height; y++) {
53
+ const filter = raw[y * (stride + 1)];
54
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
55
+ const out = rows.subarray(y * stride, (y + 1) * stride);
56
+ const prev = y ? rows.subarray((y - 1) * stride, y * stride) : null;
57
+ for (let x = 0; x < stride; x++) {
58
+ const a = x >= channels ? out[x - channels] : 0;
59
+ const b = prev ? prev[x] : 0;
60
+ const c = prev && x >= channels ? prev[x - channels] : 0;
61
+ const v = line[x];
62
+ out[x] = filter === 0 ? v : filter === 1 ? v + a : filter === 2 ? v + b : filter === 3 ? v + ((a + b) >> 1) : v + paeth(a, b, c);
63
+ }
64
+ }
65
+
66
+ if (channels === 4) return { width, height, data: rows };
67
+
68
+ const data = Buffer.alloc(width * height * 4);
69
+
70
+ for (let i = 0, j = 0; i < width * height; i++, j += channels) {
71
+ const gray = channels <= 2;
72
+ data[i * 4] = rows[j];
73
+ data[i * 4 + 1] = gray ? rows[j] : rows[j + 1];
74
+ data[i * 4 + 2] = gray ? rows[j] : rows[j + 2];
75
+ data[i * 4 + 3] = channels === 2 ? rows[j + 1] : channels === 4 ? rows[j + 3] : 255;
76
+ }
77
+
78
+ return { width, height, data };
79
+ }
80
+
81
+ // The pixels of an image inside a box (in image pixels), as [r, g, b] lists
82
+ export function pixelsIn(image, { left, top, right, bottom }) {
83
+
84
+ const out = [];
85
+ const x0 = Math.max(0, Math.floor(left));
86
+ const y0 = Math.max(0, Math.floor(top));
87
+ const x1 = Math.min(image.width, Math.ceil(right));
88
+ const y1 = Math.min(image.height, Math.ceil(bottom));
89
+
90
+ for (let y = y0; y < y1; y++) {
91
+ for (let x = x0; x < x1; x++) {
92
+ const i = (y * image.width + x) * 4;
93
+ out.push([image.data[i], image.data[i + 1], image.data[i + 2]]);
94
+ }
95
+ }
96
+
97
+ return out;
98
+ }
@@ -0,0 +1,106 @@
1
+ // The report as text: errors first, each with what to change, then warnings, notes, what passed in one line, the
2
+ // interactions and the screenshots.
3
+
4
+ // What each passed probe proves, by the codes that would have failed it
5
+ const PASSES = [
6
+ [["runtime-error", "console-error", "syntax-error", "missing-import", "missing-import-map", "plain-script-tag", "interaction-error", "missing-export"], "no errors thrown"],
7
+ [["rhp-warning"], "no rhp warnings"],
8
+ [["unknown-prop", "bad-prop", "bad-scale", "dot-size", "missing-group", "plot-typo", "group-lengths", "no-component", "bare-boolean", "handler-at-render", "style-string", "class-name", "two-solids", "series-past-theme", "page-css-ignored", "block-transition", "stale-read", "static-chart"], "rhp used as documented"],
9
+ [["no-chart", "empty-plot", "small-plot", "collapsed-chart", "plot-outside-chart"], null],
10
+ [["nan-value"], "no NaN values"],
11
+ [["value-past-scale", "points-past-scale"], "values inside the scale"],
12
+ [["sticks-out", "text-outside-poster"], "nothing sticks out"],
13
+ [["cramped"], "slats have room"],
14
+ [["text-overlap"], "no overlapping text"],
15
+ [["ambiguous-label"], "names by their own marks"],
16
+ [["text-cut-off"], "no text cut off"],
17
+ [["page-scrolls-sideways"], "no sideways scroll"],
18
+ [["tiny-text"], "text 9px or larger"],
19
+ [["low-contrast"], "text contrast meets WCAG AA"],
20
+ [["faint-marks"], "marks stand out"],
21
+ [["colorblind"], "series colors tell apart"],
22
+ [["request-failed"], "every file loaded"],
23
+ [["font-not-loaded"], "fonts loaded"],
24
+ ];
25
+
26
+ const widthsOf = (f) => (f.widths?.length ? ` (${f.widths.map((w) => w + "px").join(", ")})` : "");
27
+
28
+ export function report(r) {
29
+
30
+ const lines = [];
31
+ const count = (level) => r.findings.filter((f) => f.level === level).length;
32
+ const errors = count("error");
33
+ const warnings = count("warning");
34
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
35
+ const name = r.file ? r.file.split(/[\\/]/).pop() : "chart";
36
+ const about = [r.format, r.rhpVersion && `rhp ${r.rhpVersion}`, r.browser].filter(Boolean).join(", ");
37
+
38
+ lines.push(`rhp check of ${name}${about ? ` (${about})` : ""}: ${errors || warnings ? [errors && plural(errors, "error"), warnings && plural(warnings, "warning")].filter(Boolean).join(", ") : "no problems found"}.`);
39
+
40
+ const section = (level, title) => {
41
+ const list = r.findings.filter((f) => f.level === level);
42
+ if (!list.length) return;
43
+ lines.push("", title);
44
+ list.forEach((f, i) => {
45
+ lines.push(`${i + 1}. [${f.code}] ${f.message}${widthsOf(f)}`);
46
+ if (f.fix) lines.push(` Fix: ${f.fix}`);
47
+ });
48
+ };
49
+
50
+ section("error", "Errors");
51
+ section("warning", "Warnings");
52
+ section("info", "Notes");
53
+
54
+ const codes = new Set(r.findings.map((f) => f.code));
55
+ if (!codes.has("page-unsettled") && (r.charts?.length || r.screenshots?.length)) {
56
+ const passed = [];
57
+ for (const [fail, text] of PASSES) {
58
+ if (fail.some((c) => codes.has(c))) continue;
59
+ if (text) {
60
+ passed.push(text);
61
+ } else if (r.charts.length) {
62
+ const first = r.charts.filter((c) => c.width === r.charts[0].width);
63
+ passed.push(`${plural(first.length, "chart")} drawn (${first.map((c) => plural(c.slats, "slat")).join(", ")})`);
64
+ }
65
+ }
66
+ if (passed.length) lines.push("", `Passed: ${passed.join(", ")}.`);
67
+ }
68
+
69
+ // a chart with no controls whose slats change nothing when pointed at or tapped has no interaction: one line, since
70
+ // that is right for a chart asked to have none (and then slats that others cover don't matter, as long as one slat
71
+ // was reached)
72
+ const none = r.interactions?.length && r.interactions.every((x) => (x.kind === "hover" || x.kind === "tap") && !x.changed && !x.error) && r.interactions.some((x) => !x.missed);
73
+
74
+ if (none) {
75
+ lines.push("", "Interactions: no interaction found (fine when none was asked for).");
76
+ } else if (r.interactions?.length) {
77
+ // pointing at slats (and tapping them) that changes nothing is no failure when buttons or a slider carry the
78
+ // interaction: one line
79
+ const still = (kind) => r.interactions.filter((x) => x.kind === kind).every((x) => !x.changed && !x.error) && r.interactions.some((x) => x.kind === kind && !x.missed);
80
+ const hovers = r.interactions.filter((x) => x.kind === "hover").length;
81
+ const taps = r.interactions.filter((x) => x.kind === "tap").length;
82
+ const controls = r.interactions.some((x) => x.changed && (/^(click|input|select)$/.test(x.kind) || / on slider /.test(x.target)));
83
+ const quiet = controls && hovers > 0 && still("hover") ? (taps && still("tap") ? ["hover", "tap"] : ["hover"]) : [];
84
+ lines.push("", `Interactions${r.interactedAt ? ` (at ${r.interactedAt}px)` : ""}`);
85
+ if (quiet.length > 1) lines.push(`- hover and tap: no interaction on the slats (pointing at ${plural(hovers, "slat")} and tapping ${taps} changed nothing); buttons or a slider carry it`);
86
+ else if (quiet.length) lines.push(`- hover: no hover interaction (pointing at ${plural(hovers, "slat")} changed nothing); buttons or a slider carry it`);
87
+ for (const x of r.interactions) {
88
+ if (quiet.includes(x.kind)) continue;
89
+ lines.push(`- ${x.kind} ${x.target}${x.width !== r.interactedAt ? ` at ${x.width}px` : ""}: ${x.how}${x.error ? `, and it threw: ${x.error}` : ""}`);
90
+ }
91
+ // the sweep: one line, every width at which every slat was picked in turn
92
+ const swept = (r.swept ?? []).filter((x) => x.slats);
93
+ if (swept.length) lines.push(`- every slat picked in turn (${swept[0].slats}) at ${swept.map((x) => `${x.width}px`).join(", ")}: ${swept.some((x) => x.jumps) ? "the layout jumped (see the warnings)" : "nothing else moved"}`);
94
+ } else if (r.charts?.length && r.interactedAt) {
95
+ lines.push("", "Interactions: nothing to interact with (no slats to point at, no buttons, ranges or selects).");
96
+ }
97
+
98
+ if (r.screenshots?.length) {
99
+ lines.push("", "Screenshots (look at them)");
100
+ for (const s of [...r.screenshots].sort((a, b) => b.width - a.width || a.hover - b.hover)) {
101
+ lines.push(`- ${s.width}px${s.dark ? ", dark" : ""}${s.hover ? ", pointing at a slat" : ""}: ${s.path}`);
102
+ }
103
+ }
104
+
105
+ return lines.join("\n") + "\n";
106
+ }