@bezda/rhp-mcp 0.1.5 → 0.1.7

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 (57) hide show
  1. package/package.json +6 -1
  2. package/skill/SKILL.md +113 -22
  3. package/skill/recipes/area.html +129 -33
  4. package/skill/recipes/bar.html +129 -33
  5. package/skill/recipes/box-plot.html +129 -33
  6. package/skill/recipes/bubble.html +129 -33
  7. package/skill/recipes/bullet.html +129 -33
  8. package/skill/recipes/candlestick.html +129 -33
  9. package/skill/recipes/column.html +129 -33
  10. package/skill/recipes/dial.html +129 -33
  11. package/skill/recipes/diverging-bars.html +129 -33
  12. package/skill/recipes/donut.html +129 -33
  13. package/skill/recipes/dumbbell.html +129 -33
  14. package/skill/recipes/gantt.html +129 -33
  15. package/skill/recipes/grouped-bars.html +129 -33
  16. package/skill/recipes/heatmap.html +129 -33
  17. package/skill/recipes/histogram.html +129 -33
  18. package/skill/recipes/line.html +129 -33
  19. package/skill/recipes/live.html +129 -33
  20. package/skill/recipes/log-line.html +129 -33
  21. package/skill/recipes/lollipop.html +129 -33
  22. package/skill/recipes/multi-line.html +129 -33
  23. package/skill/recipes/pyramid.html +129 -33
  24. package/skill/recipes/race.html +129 -33
  25. package/skill/recipes/radial-bars.html +129 -33
  26. package/skill/recipes/scatter.html +129 -33
  27. package/skill/recipes/silhouettes.html +129 -33
  28. package/skill/recipes/slope.html +129 -33
  29. package/skill/recipes/sparklines.html +129 -33
  30. package/skill/recipes/stacked-100.html +129 -33
  31. package/skill/recipes/stacked-bars.html +129 -33
  32. package/skill/recipes/strip.html +129 -33
  33. package/skill/recipes/tape.html +578 -0
  34. package/skill/recipes/unit-stack.html +129 -33
  35. package/skill/recipes/violin.html +129 -33
  36. package/skill/recipes/waffle.html +129 -33
  37. package/skill/recipes/waterfall.html +129 -33
  38. package/skill/references/api.md +6 -6
  39. package/skill/references/design.md +18 -0
  40. package/skill/references/download.md +135 -35
  41. package/skill/references/forms.md +3 -2
  42. package/skill/references/interaction.md +8 -4
  43. package/skill/references/pitfalls.md +3 -2
  44. package/skill/scripts/add-download.mjs +33 -0
  45. package/src/check/README.md +29 -5
  46. package/src/check/build.js +4 -3
  47. package/src/check/colors.js +4 -2
  48. package/src/check/findings.js +99 -3
  49. package/src/check/index.js +158 -17
  50. package/src/check/inpage.js +292 -5
  51. package/src/check/interact.js +22 -4
  52. package/src/check/png.js +6 -1
  53. package/src/check/report.js +9 -2
  54. package/src/check/selection-controls.js +105 -0
  55. package/src/check/selection.js +54 -12
  56. package/src/check/source.js +21 -0
  57. package/src/server.js +4 -4
@@ -4,11 +4,65 @@
4
4
  const q = (text) => `"${text}"`;
5
5
 
6
6
  // What an interaction is, said as something a reader does ("clicking button "Q2"")
7
- const DOING = { hover: "pointing at", tap: "tapping", click: "clicking", input: "setting", select: "setting", key: "pressing", pick: "picking" };
7
+ const DOING = { hover: "pointing at", tap: "tapping", click: "clicking", input: "setting", select: "setting", key: "pressing", pick: "picking", scrub: "moving the pointer along" };
8
8
 
9
9
  // How far something moved: "88px down", "12px right"
10
10
  const shift = ({ dx, dy }) => [dy && `${Math.abs(dy)}px ${dy > 0 ? "down" : "up"}`, dx && `${Math.abs(dx)}px ${dx > 0 ? "right" : "left"}`].filter(Boolean).join(" and ");
11
11
 
12
+ // How a box changed size: "grows 18px taller", "shrinks 6px narrower"
13
+ const grows = ({ dw, dh }) => [dh && `${dh > 0 ? "grows" : "shrinks"} ${Math.abs(dh)}px ${dh > 0 ? "taller" : "shorter"}`, dw && `${dw > 0 ? "grows" : "shrinks"} ${Math.abs(dw)}px ${dw > 0 ? "wider" : "narrower"}`].filter(Boolean).join(" and ");
14
+
15
+ // The section of the skill's references that explains a finding's fix, for an agent whose fix did not hold: the
16
+ // report names it ("See: ..."), so the agent reads that section, not the whole file
17
+ const PITFALLS = (title) => `pitfalls.md, "${title}"`;
18
+ export const SEE = {
19
+ "two-solids": PITFALLS("Mistakes that come from other libraries"),
20
+ "cdn-version": PITFALLS("Mistakes that come from other libraries"),
21
+ "no-chart": PITFALLS("Nothing draws, or the chart is empty"),
22
+ "empty-plot": PITFALLS("Nothing draws, or the chart is empty"),
23
+ "missing-import-map": PITFALLS("Nothing draws, or the chart is empty"),
24
+ "plain-script-tag": PITFALLS("Nothing draws, or the chart is empty"),
25
+ "missing-export": PITFALLS("Nothing draws, or the chart is empty"),
26
+ "bad-scale": PITFALLS("Bars, dots or labels in the wrong place"),
27
+ "value-past-scale": PITFALLS("Bars, dots or labels in the wrong place"),
28
+ "points-past-scale": PITFALLS("Bars, dots or labels in the wrong place"),
29
+ "nan-value": PITFALLS("Bars, dots or labels in the wrong place"),
30
+ "dot-size": PITFALLS("Bars, dots or labels in the wrong place"),
31
+ "sticks-out": PITFALLS("Text sticks out, is cut off or overlaps"),
32
+ "text-cut-off": PITFALLS("Text sticks out, is cut off or overlaps"),
33
+ "text-overlap": PITFALLS("Text sticks out, is cut off or overlaps"),
34
+ "text-outside-poster": PITFALLS("Text sticks out, is cut off or overlaps"),
35
+ "page-scrolls-sideways": PITFALLS("Text sticks out, is cut off or overlaps"),
36
+ "tiny-text": PITFALLS("Text sticks out, is cut off or overlaps"),
37
+ "small-plot": PITFALLS("The chart is tiny, squashed or the wrong size"),
38
+ "collapsed-chart": PITFALLS("The chart is tiny, squashed or the wrong size"),
39
+ "cramped": PITFALLS("The chart is tiny, squashed or the wrong size"),
40
+ "plot-outside-chart": PITFALLS("The chart is tiny, squashed or the wrong size"),
41
+ "stale-read": PITFALLS("Values never update, or the chart jumps"),
42
+ "static-chart": PITFALLS("Values never update, or the chart jumps"),
43
+ "style-string": PITFALLS("Values never update, or the chart jumps"),
44
+ "block-transition": PITFALLS("Values never update, or the chart jumps"),
45
+ "bare-boolean": PITFALLS("A click, hover, tap or key does nothing, or the wrong thing"),
46
+ "handler-at-render": PITFALLS("A click, hover, tap or key does nothing, or the wrong thing"),
47
+ "flicker": 'interaction.md, "2. Readout on hover, focus and tap"',
48
+ "selection-reset": 'interaction.md, "2. Readout on hover, focus and tap"',
49
+ "selection-hover-stays": 'interaction.md, "2. Readout on hover, focus and tap"',
50
+ "selection-control-reset": 'interaction.md, "2. Readout on hover, focus and tap"',
51
+ "layout-jump": PITFALLS("The layout jumps when the reader interacts"),
52
+ "text-shift": PITFALLS("The layout jumps when the reader interacts"),
53
+ "jitter": PITFALLS("The layout jumps when the reader interacts"),
54
+ "out-of-view": 'interaction.md, "2. Readout on hover, focus and tap"',
55
+ "scrolls-away": 'interaction.md, "2. Readout on hover, focus and tap"',
56
+ "focus-invisible": 'interaction.md, "9. Keyboard"',
57
+ "page-css-ignored": PITFALLS("Styles or colors do not apply"),
58
+ "series-past-theme": PITFALLS("Styles or colors do not apply"),
59
+ "class-name": PITFALLS("Styles or colors do not apply"),
60
+ "low-contrast": 'design.md, "4. Color"',
61
+ "faint-marks": 'design.md, "4. Color"',
62
+ "colorblind": 'design.md, "4. Color"',
63
+ "rhp-warning": PITFALLS("Console warnings rhp prints"),
64
+ };
65
+
12
66
  // A finding from what measure() saw in the page
13
67
  export function fromProbe(f, context) {
14
68
 
@@ -173,7 +227,7 @@ export function fromProbe(f, context) {
173
227
  fix: "Give it visible text, an aria-label (aria-label=\"Play\" on an icon button), or a <label> around an input or a select.",
174
228
  };
175
229
  case "layout-jump": {
176
- const list = f.moved.slice(0, 3).map((m) => `${m.what} moves ${shift(m)}`).join(", ");
230
+ const list = f.moved.slice(0, 3).map((m) => (m.box ? `${m.what} ${grows(m)}` : `${m.what} moves ${shift(m)}`)).join(", ");
177
231
  const plot = f.moved.some((m) => m.plot);
178
232
  return {
179
233
  level: "warning",
@@ -181,7 +235,42 @@ export function fromProbe(f, context) {
181
235
  message: `The layout jumps when ${DOING[f.kind] ?? f.kind} ${f.target}: ${list}${f.moved.length > 3 ? ` and ${f.moved.length - 3} more` : ""}${f.more ? ` (${f.more} more interaction${f.more > 1 ? "s do" : " does"} the same)` : ""}, so what the reader goes to next is no longer where it was.`,
182
236
  fix: plot
183
237
  ? "Keep the plot still while a slat is lit: a lit name that gets wider (bolder, larger) makes room: \"auto\" measure the names again, so keep the names' weight and size and change only their color, or give the room in px (room: { start: 140 })."
184
- : "Keep what the interaction changes in a box that keeps its size: a readout or a headline on one line (white-space: nowrap; overflow: hidden; text-overflow: ellipsis) or with a min-height for its longest text, and buttons that keep their width when their label or weight changes.",
238
+ : "Keep what the interaction changes in a box that keeps its size: a readout or a headline on one line (white-space: nowrap; overflow: hidden; text-overflow: ellipsis) or with a min-height for its longest text, a readout's card or a legend's panel with a fixed width or a min-width and min-height for its longest content, and buttons that keep their width when their label or weight changes.",
239
+ };
240
+ }
241
+ case "text-shift": {
242
+ const list = f.moved.slice(0, 3).map((m) => `${m.what} moves ${shift(m)}`).join(", ");
243
+ return {
244
+ level: "warning",
245
+ code: f.code,
246
+ message: `Text that says the same thing moves when ${DOING[f.kind] ?? f.kind} ${f.target}: ${list}${f.moved.length > 3 ? ` and ${f.moved.length - 3} more` : ""}${f.more ? ` (${f.more} more interaction${f.more > 1 ? "s" : ""} or pick${f.more > 1 ? "s" : ""} move text too)` : ""}. Labels and values that slide as the numbers change make the reader lose their place.`,
247
+ fix: "Give what changes a place that keeps its size, so the text around it stays put: numbers in tabular figures (font-variant-numeric: tabular-nums) in a column of their own or with a min-width in ch, right-aligned; a label in its own column (a grid), not after a value on the same line; a readout's lines in a fixed order; and a min-height for the readout's longest text.",
248
+ };
249
+ }
250
+ case "jitter": {
251
+ const how = (m) => [m.dx && `slides up to ${Math.abs(m.dx)}px ${m.dx > 0 ? "right" : "left"}`, m.dy && `moves up to ${Math.abs(m.dy)}px ${m.dy > 0 ? "down" : "up"}`].filter(Boolean).join(" and ");
252
+ const list = f.moved.slice(0, 3).map((m) => `${m.what}${m.line ? ` (in "${m.line}")` : ""} ${how(m)}`).join(", ");
253
+ return {
254
+ level: "warning",
255
+ code: f.code,
256
+ message: `The readout jitters as the pointer moves along ${f.target}: ${list}${f.moved.length > 3 ? ` and ${f.moved.length - 3} more` : ""}. A text that keeps none of its edges in place while the values change, or moves while it says the same thing, shakes under the reader's eyes at every step along the chart.`,
257
+ fix: "Start each part of the readout where nothing before it changes width: a grid with a column per part, or each part on a line of its own, never a label after a value on the same line. Give each value a box that keeps its size: tabular figures (font-variant-numeric: tabular-nums) with a min-width in ch for the longest value it can show (and the longest date), right-aligned when it ends a column. Keep the readout on its lines whatever it says: white-space: nowrap, or a min-height for its longest text at the narrowest width.",
258
+ };
259
+ }
260
+ case "flicker":
261
+ return {
262
+ level: "warning",
263
+ code: f.code,
264
+ message: `Moving the mouse from ${f.from} to ${f.to}, ${f.readout ? "the readout goes back to what it shows at rest" : "the lit slat goes back to how it looks at rest"} for a moment between them${f.more ? ` (and between ${f.more} more pair${f.more > 1 ? "s" : ""} of neighbors)` : ""}: it flickers as the reader moves along the chart.`,
265
+ fix: "Keep the pick while the pointer is between slats: change it only when the pointer reaches another slat (on a pointer move over no slat, keep the pick), and clear it only when the pointer leaves the chart or the poster, as the readout example in SKILL.md does.",
266
+ };
267
+ case "scrolls-away": {
268
+ const parts = [f.controls.length && `its controls (${f.controls.join(", ")})`, f.readout && `its readout ("${f.readout}")`].filter(Boolean);
269
+ return {
270
+ level: "info",
271
+ code: f.code,
272
+ message: `With the middle of the chart in the middle of the ${f.phone ? "phone's screen" : "window"}, ${parts.join(" and ")} ${parts.length > 1 || f.controls.length ? "are" : "is"} scrolled out of view: a reader going through the chart has to scroll back to ${f.controls.length ? "use them" : "read it"}.`,
273
+ fix: "Consider keeping them in view as the reader scrolls (often better, not always): position: sticky; top: env(safe-area-inset-top, 0px) on the row that holds them, with a background and a z-index over the plot's 1, compact on a phone (one line, the readout under the controls), and scroll-margin-top on the slats so a picked slat is never under it. Leave them where they are when the design needs it.",
185
274
  };
186
275
  }
187
276
  case "out-of-view":
@@ -198,6 +287,13 @@ export function fromProbe(f, context) {
198
287
  message: `Tab reaches ${f.what}${f.more ? ` and ${f.more} more` : ""}, but nothing on screen shows the focus there: a keyboard reader cannot see where it is.`,
199
288
  fix: "Show focus: take off outline: none, or put a :focus-visible style that shows in its place (outline: 2px solid in the ink color, outline-offset: 2px; on a slat, in the slat type's css).",
200
289
  };
290
+ case "selection-control-reset":
291
+ return {
292
+ level: "warning",
293
+ code: f.code,
294
+ message: `The control "${f.control}" selects the same data as chart hover, but ${f.when} changes its committed value or loses its selected view.`,
295
+ fix: "Keep the choice made with this selection control separate from the temporary hover preview. The control may follow the hovered point visually, but when hover ends or focus moves, its value and selected view must return to the committed choice. Chart clicks, taps, keyboard selection and the input should update the same committed selection. Do not save and restore a shared value around individual events, reset the input on blur, or apply the initial-state reset rule to this control's chosen value. Preserve explicit user instructions for a different interaction policy.",
296
+ };
201
297
  case "selection-hover-stays":
202
298
  return {
203
299
  level: "warning",
@@ -27,8 +27,10 @@
27
27
  // findings [{ level: "error" | "warning" | "info", code, message, fix, width, widths }], errors first
28
28
  // charts [{ width, slats, blocks: { bar: 3, label: 6 }, box: { width, height } }]
29
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
30
+ // swept [{ width, breakpoint, slats, reached, jumps, scrubbed }]: the widths at which every slat was picked in
31
+ // turn (each width checked, and the page's own breakpoints between them), how many slats, how many the
32
+ // pointer could reach, how many moved the layout, and when the pointer was moved along the chart
33
+ // instead, scrubbed: { points, moved: how many of its steps moved something }
32
34
  // screenshots [{ width, dark, hover, path }]
33
35
  // text the report as plain text (what the CLI prints)
34
36
  import fs from "fs";
@@ -39,7 +41,7 @@ import { pathToFileURL, fileURLToPath } from "url";
39
41
  import { detectFormat, bundle, guardedStandalone, htmlProblems, packages, contentType } from "./build.js";
40
42
  import { launch, NO_BROWSER_FIX } from "./browser.js";
41
43
  import { install } from "./inpage.js";
42
- import { fromProbe, fixForError, placeOf } from "./findings.js";
44
+ import { fromProbe, fixForError, placeOf, SEE } from "./findings.js";
43
45
  import { textContrast, faintMarks, colorBlind } from "./colors.js";
44
46
  import { decode } from "./png.js";
45
47
  import { interact as interactionPass } from "./interact.js";
@@ -94,17 +96,28 @@ function source({ file, code, format }) {
94
96
  // 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
97
  // the mouse elsewhere), on a page at rest with reduced motion asked for, where a pick shows at once; and lists what moved
96
98
  // 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.
99
+ // throws is tagged with it in events, as the interaction pass does. When the main chart draws a line or an area, a
100
+ // layer over the plot takes the pointer (the slats cannot be pointed at) or the Plot has one or two slats (a line per
101
+ // series), the reader moves along the chart, and the sweep scrubs it first (scrub()). { slats, reached: the slats it
102
+ // could point at, jumps, scrub }
98
103
  async function sweep(page, touch, events) {
99
104
 
100
105
  const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets());
101
106
  const jumps = [];
102
- if (!touch) await page.mouse.move(0, 0);
103
- await page.evaluate(() => window.__rhpProbe.place());
107
+ await page.mouse.move(0, 0);
108
+ await page.evaluate(() => {
109
+ window.__rhpProbe.place();
110
+ window.__rhpProbe.placeLines();
111
+ window.__rhpProbe.watch(true);
112
+ });
113
+ const first = slats.length ? await page.evaluate((n) => window.__rhpProbe.pointAt(n), slats[0].target).catch(() => null) : null;
114
+ const scrubbed = slats.length ? await scrub(page, events, slats.length <= 2 || !!first?.covered) : null;
115
+ let reached = 0;
104
116
  for (const s of slats) {
105
117
  // (a slat that something covers has no point: pointing there would pick what covers it)
106
118
  const point = await page.evaluate((n) => window.__rhpProbe.pointAt(n), s.target).catch(() => null);
107
119
  if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
120
+ reached++;
108
121
  const name = `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
109
122
  const before = events.length;
110
123
  try {
@@ -120,8 +133,92 @@ async function sweep(page, touch, events) {
120
133
  const moved = await page.evaluate((n) => window.__rhpProbe.moved(n, true), s.target).catch(() => []);
121
134
  if (moved.length) jumps.push({ target: name, moved });
122
135
  }
136
+ await page.evaluate(() => window.__rhpProbe.watch(false)).catch(() => {});
123
137
  if (!touch) await page.mouse.move(0, 0);
124
- return { slats: slats.length, jumps };
138
+ return { slats: slats.length, reached, jumps, scrub: scrubbed };
139
+ }
140
+
141
+ // Moves the mouse along the main chart in small steps (scrubPath() in inpage.js, which says when), as a reader reading
142
+ // a chart that follows the pointer across its plot (a line read day by day), at every width: a finger dragging along a phone's plot
143
+ // shows the same readout. Each step is measured against the page at rest (place() and placeLines() before it): the
144
+ // layout, as for a pick (moved()), and the lines of text around the charts (slides()), which jitter when a label
145
+ // slides as the value before it changes width, or a readout takes another line on some days.
146
+ // { points, steps: [what moved at each step that moved anything] }
147
+ async function scrub(page, events, force) {
148
+
149
+ const path = await page.evaluate((f) => window.__rhpProbe.scrubPath(f), force).catch(() => null);
150
+ if (!path?.points.length) return null;
151
+ const start = events.length;
152
+ const steps = [];
153
+ let said = null;
154
+ let changes = 0;
155
+ try {
156
+ for (const p of path.points) {
157
+ await page.mouse.move(p.x, p.y);
158
+ await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
159
+ const [moved, text] = await page.evaluate((n) => [[...window.__rhpProbe.moved(n, true).filter((m) => !m.text), ...window.__rhpProbe.slides()], window.__rhpProbe.state().text], path.target).catch(() => [[], said]);
160
+ if (said !== null && text !== said) changes++;
161
+ said = text;
162
+ if (moved.length) steps.push(moved);
163
+ }
164
+ } finally {
165
+ await page.mouse.move(0, 0).catch(() => {});
166
+ for (const e of events.slice(start)) {
167
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= "moving the pointer along the chart";
168
+ }
169
+ }
170
+ // (a readout that changes at three steps in four or more follows the pointer along the chart: a sentence that slides
171
+ // there jitters; one that changes less often changes once per mark, and only what a pick counts counts)
172
+ const continuous = changes >= 0.75 * (path.points.length - 1);
173
+ const kept = steps.map((moved) => (continuous ? moved : moved.filter((m) => !m.strict))).filter((moved) => moved.length);
174
+ return { points: path.points.length, continuous, steps: kept };
175
+ }
176
+
177
+ // Moves the mouse from a slat to its neighbor in small steps (three pairs of the main Plot: the first, the middle and
178
+ // the last), on a page at rest with reduced motion asked for, and notes each pair where the page, between two slats
179
+ // that each show something, goes back for a moment to what it shows with the pointer away: the readout, or the lit
180
+ // slat, flickers in the gap.
181
+ async function glide(page, events) {
182
+
183
+ const start = events.length;
184
+ const pairs = await page.evaluate(() => window.__rhpProbe.neighbors()).catch(() => []);
185
+ if (!pairs.length) return [];
186
+ const frame = () => page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
187
+ const state = () => page.evaluate(() => window.__rhpProbe.state());
188
+ const name = (s) => `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
189
+ const out = [];
190
+ try {
191
+ await page.mouse.move(0, 0);
192
+ await frame();
193
+ const rest = await state();
194
+ for (const [a, b] of pairs) {
195
+ // (from the middle of one slat's largest mark to the middle of the next one's: what each slat shows of its own)
196
+ const p = await page.evaluate((n) => window.__rhpProbe.markCenter(n), a.target).catch(() => null);
197
+ const q = await page.evaluate((n) => window.__rhpProbe.markCenter(n, false), b.target).catch(() => null);
198
+ if (![p?.x, p?.y, q?.x, q?.y].every(Number.isFinite)) continue;
199
+ await page.mouse.move(p.x, p.y);
200
+ await frame();
201
+ const from = await state();
202
+ const seen = [];
203
+ for (let k = 1; k <= 12; k++) {
204
+ await page.mouse.move(p.x + ((q.x - p.x) * k) / 12, p.y + ((q.y - p.y) * k) / 12);
205
+ await frame();
206
+ seen.push(await state());
207
+ }
208
+ const to = seen.at(-1);
209
+ // The text, or the look of the slats, blinks when both slats show something other than the rest in it, and a
210
+ // step between them shows the rest (a slat the page lights at rest shows the rest of its own, and does not blink)
211
+ const blinks = (key) => from[key] !== rest[key] && to[key] !== rest[key] && seen.slice(0, -1).some((s) => s[key] === rest[key]);
212
+ const readout = blinks("text");
213
+ if (readout || blinks("look")) out.push({ from: name(a), to: name(b), readout });
214
+ }
215
+ } finally {
216
+ await page.mouse.move(0, 0).catch(() => {});
217
+ for (const e of events.slice(start)) {
218
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= "moving the mouse from a slat to its neighbor";
219
+ }
220
+ }
221
+ return out;
125
222
  }
126
223
 
127
224
  export async function check(options = {}) {
@@ -374,8 +471,17 @@ export async function check(options = {}) {
374
471
  // Every slat picked in turn, here and at the page's own breakpoints between this width and the next narrower
375
472
  // one checked (a page of its own at each), where a layout that only fits at the widths checked jumps
376
473
  const swept = [];
474
+ let flicker = [];
475
+ let away = null;
377
476
  if (wantInteract) {
378
477
  swept.push({ width, ...(await sweep(page, phone, events)) });
478
+ // what a reader scrolling through a tall chart loses sight of, and (with a mouse) a readout that flickers between slats
479
+ const scrolled = events.length;
480
+ away = await page.evaluate((h) => window.__rhpProbe.scrollsAway(h), phone ? PHONE_WINDOW : 900).catch(() => null);
481
+ for (const e of events.slice(scrolled)) {
482
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= "scrolling through the chart";
483
+ }
484
+ if (!phone && i === 0) flicker = await glide(page, events);
379
485
  const next = [...widths].sort((a, b) => b - a).find((w) => w < width);
380
486
  const between = next == null ? [] : (await page.evaluate(() => window.__rhpProbe.breakpoints())).filter((b) => b < width && b > next && !widths.includes(b));
381
487
  for (const b of between.slice(0, MAX_BREAKPOINTS)) {
@@ -427,7 +533,7 @@ export async function check(options = {}) {
427
533
  guarded.push(...(await fresh.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
428
534
  }
429
535
 
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) });
536
+ results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, selection, swept, flicker, away, ms: Math.round(performance.now() - w0) });
431
537
  } catch (error) {
432
538
  if (error.code !== "page-unsettled") throw error;
433
539
  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 });
@@ -493,23 +599,58 @@ export async function check(options = {}) {
493
599
  findings.push({ ...f, width: r.width });
494
600
  }
495
601
 
496
- // the layout jumping when an interaction runs: the same things moving for several interactions is one finding
602
+ // the layout jumping when an interaction runs: the same things moving for several interactions is one finding.
603
+ // Texts that moved with what jumped (by the same amount) are that jump; the others are one text-shift finding for
604
+ // the width, with each text at the most it moved, or, as the pointer moves along the chart, one jitter finding.
497
605
  const jumps = new Map();
606
+ const texted = { "text-shift": { found: null, by: new Map() }, jitter: { found: null, by: new Map() } };
607
+ const add = (kind, target, moved, code = "text-shift") => {
608
+ const other = moved.filter((m) => !m.text);
609
+ const texts = moved.filter((m) => m.text && !other.some((o) => Math.abs(o.dx - m.dx) <= 1 && Math.abs(o.dy - m.dy) <= 1));
610
+ if (other.length) {
611
+ const key = other.map((m) => m.what).join("\n");
612
+ if (jumps.has(key)) jumps.get(key).more++;
613
+ else jumps.set(key, { code: "layout-jump", kind, target, moved: other, more: 0 });
614
+ }
615
+ if (!texts.length) return;
616
+ const t = texted[code];
617
+ if (t.found) t.found.more++;
618
+ else t.found = { code, kind, target, more: 0 };
619
+ for (const m of texts) {
620
+ const was = t.by.get(m.what);
621
+ if (!was || Math.abs(m.dx) + Math.abs(m.dy) > Math.abs(was.dx) + Math.abs(was.dy)) t.by.set(m.what, m);
622
+ }
623
+ };
498
624
  for (const x of r.interactions.filter((x) => x.moved?.length)) {
499
- const key = x.moved.map((m) => m.what).join("\n");
500
- if (jumps.has(key)) jumps.get(key).more++;
501
- else jumps.set(key, { code: "layout-jump", kind: x.kind, target: x.target, moved: x.moved, more: 0 });
625
+ add(x.kind, x.target, x.moved);
502
626
  }
503
627
  for (const sw of r.swept) {
628
+ const beside = sw.breakpoint ? ` at ${sw.width}px, beside one of the page's own breakpoints` : "";
629
+ for (const moved of sw.scrub?.steps ?? []) {
630
+ add("scrub", `the chart${beside}`, moved, "jitter");
631
+ }
504
632
  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 });
633
+ add("pick", `${j.target}${beside}`, j.moved);
508
634
  }
509
635
  }
510
636
  for (const j of jumps.values()) {
511
637
  findings.push({ ...fromProbe(j, {}), width: r.width });
512
638
  }
639
+ // (a text the scrub found jittering is that finding: a pick that moved it is one of the steps along the chart)
640
+ for (const what of texted.jitter.by.keys()) {
641
+ texted["text-shift"].by.delete(what);
642
+ }
643
+ for (const { found, by } of Object.values(texted)) {
644
+ if (!found || !by.size) continue;
645
+ const moved = [...by.values()].sort((a, b) => Math.abs(b.dx) + Math.abs(b.dy) - Math.abs(a.dx) - Math.abs(a.dy));
646
+ findings.push({ ...fromProbe({ ...found, moved }, {}), width: r.width });
647
+ }
648
+
649
+ // a readout or a lit slat that goes back to how it is at rest between two slats, as the mouse goes from one to the next
650
+ if (r.flicker.length) findings.push({ ...fromProbe({ code: "flicker", ...r.flicker[0], more: r.flicker.length - 1 }, {}), width: r.width });
651
+
652
+ // the controls or the readout out of view while the reader is in the middle of a tall chart
653
+ if (r.away) findings.push({ ...fromProbe({ code: "scrolls-away", ...r.away, phone: r.width <= PHONE }, {}), width: r.width });
513
654
 
514
655
  // taps whose every change of text lies outside the window: one finding, on the last of them (the furthest slat)
515
656
  const unseen = r.interactions.filter((x) => x.unseen);
@@ -526,7 +667,7 @@ export async function check(options = {}) {
526
667
  const charts = results.flatMap((r) => r.seen.charts.map((c) => ({ width: r.width, ...c })));
527
668
  const selectionAudits = results.flatMap((r) => r.selection.tested ? [{ width: r.width, ...r.selection.tested }] : []);
528
669
  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);
670
+ const swept = results.flatMap((r) => r.swept.map(({ width, breakpoint, slats, reached, jumps, scrub }) => ({ width, breakpoint: !!breakpoint, slats, reached, jumps: jumps.length, scrubbed: scrub ? { points: scrub.points, moved: scrub.steps.length } : null }))).sort((a, b) => b.width - a.width);
530
671
  timings.widths = Object.fromEntries(results.map((r) => [r.width, r.ms]));
531
672
 
532
673
  return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, selectionAudits, swept, screenshots, timings, started });
@@ -544,7 +685,7 @@ function finish({ findings, started, timings, ...rest }) {
544
685
  if (f.width != null && !had.widths.includes(f.width)) had.widths.push(f.width);
545
686
  continue;
546
687
  }
547
- merged.set(key, { level: f.level, code: f.code, message: f.message, fix: f.fix, width: f.width ?? null, widths: f.width != null ? [f.width] : [] });
688
+ merged.set(key, { level: f.level, code: f.code, message: f.message, fix: f.fix, see: SEE[f.code] ?? null, width: f.width ?? null, widths: f.width != null ? [f.width] : [] });
548
689
  }
549
690
 
550
691
  const list = [...merged.values()].sort((a, b) => LEVELS[a.level] - LEVELS[b.level]);