@bezda/rhp-mcp 0.1.6 → 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.
- package/package.json +1 -1
- package/skill/SKILL.md +108 -18
- package/skill/recipes/tape.html +578 -0
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/forms.md +3 -2
- package/skill/references/interaction.md +8 -4
- package/skill/references/pitfalls.md +3 -2
- package/skill/scripts/add-download.mjs +33 -0
- package/src/check/README.md +18 -2
- package/src/check/findings.js +92 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +276 -1
- package/src/check/interact.js +12 -0
- package/src/check/report.js +9 -2
- package/src/check/selection.js +16 -1
- package/src/server.js +4 -4
|
@@ -30,7 +30,7 @@ rhp draws all of these; the recipe named has the technique, not the design.
|
|
|
30
30
|
| a route or a sequence in space (stops, stages, a course, a river) | one line with its stations in order | `stacked-100` (its key is a metro line), `gantt`, `slope` |
|
|
31
31
|
| a document the reader knows (a receipt, a ledger, a timetable, a scoreboard, a league table, a menu) | the chart laid out as that document, its lines the slats | `lollipop`, `waterfall`, `sparklines`, `live` |
|
|
32
32
|
| a place (states, districts, seats in a hall, a pitch, a shelf) | a grid of tiles that echoes the place | `heatmap`'s composition (a Plot of rows, each a Plot of Cells) |
|
|
33
|
-
| an instrument that measures it (a thermometer, a gauge, a ruler, a clock, a scale) | the instrument's own scale and pointer | a `Scale` drawn your own way, a Tick as the pointer, `radial-bars` |
|
|
33
|
+
| an instrument that measures it (a thermometer, a gauge, a ruler, a clock, a scale) | the instrument's own scale and pointer | a `Scale` drawn your own way, a Tick as the pointer, `radial-bars`, `tape` (a ruler's rules and numerals along a whole cut into lengths) |
|
|
34
34
|
|
|
35
35
|
- The native form is the hero, and the numbers still have to be read: print each value on or beside its mark, since a ring, an outline or a tile is read less exactly than a bar on a shared baseline.
|
|
36
36
|
- When an exact comparison is the whole point, keep bars from one baseline and let the subject show in how the marks are drawn (design.md section 2).
|
|
@@ -76,7 +76,7 @@ The headline will be its answer.
|
|
|
76
76
|
| see a value over two categories | a grid of values (day by hour) | heatmap | `heatmap` |
|
|
77
77
|
| see two groups across ordered bands | two values per band | pyramid | `pyramid` |
|
|
78
78
|
| see things known by their shape at their size | one value per item, each item with an outline | silhouettes to scale | `silhouettes` |
|
|
79
|
-
| see how a day or another cycle splits | parts that add up to the cycle | a dial | `dial` |
|
|
79
|
+
| see how a day or another cycle splits | parts that add up to the cycle | a dial, or a tape cut into lengths | `dial`, `tape` |
|
|
80
80
|
| follow one part of a total over periods, and count it | parts per period that add up | unit stacks with the part at the foot | `unit-stack` |
|
|
81
81
|
| see a schedule and its progress | tasks with start, end, progress | gantt | `gantt` |
|
|
82
82
|
| follow prices with their range | open, high, low, close per day | candlestick | `candlestick` |
|
|
@@ -150,6 +150,7 @@ The interaction listed is the recipe's; interaction.md has the others.
|
|
|
150
150
|
| `pyramid` | two sides of a population by age | 2 | an age picked lights everyone that age or older |
|
|
151
151
|
| `silhouettes` | ten towers to scale, each in its own outline, on one street | 1, vertical, `shape()` per row | readout of the tower picked, its level carried to the tallest |
|
|
152
152
|
| `dial` | the parts of a day around a 24-hour dial | 10 + 9 | a hand that turns to the part picked, a readout in the hub |
|
|
153
|
+
| `tape` | the parts of a day laid end to end along a tape measure cut into three 8-hour lengths | 2 (an `overlap` Plot of Bars from each part's start to its end), a Chart per length + 9 | readout of the part picked, its key lines picking too |
|
|
153
154
|
| `log-line` | ten years of a price on a log scale with a line at each doubling, its falls shaded | 5 (the falls as one filled Line) + 8 + 9, log scale | a crosshair readout by pointer, tap, drag or keys |
|
|
154
155
|
| `unit-stack` | monthly spending as piles of coins, one part at the foot of every pile | 2 (the picked part and the rest), units by a mask | a picker of parts |
|
|
155
156
|
| `gantt` | tasks over weeks, progress, today | 2 + 8 + 9 | readout of a task and its dependencies |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Interaction
|
|
2
2
|
|
|
3
|
-
How to
|
|
3
|
+
How to choose interaction behavior and presentation for the reader's task, and how to build an interaction a user asks for.
|
|
4
4
|
rhp keeps no interaction state: the app holds it in a signal, passes it to the Plot as data, and the slat shows it.
|
|
5
5
|
|
|
6
6
|
Contents:
|
|
@@ -71,10 +71,14 @@ The page's CSS styles what sits around the chart (the poster, the readout, butto
|
|
|
71
71
|
|
|
72
72
|
## 1. Pick the interaction from the story
|
|
73
73
|
|
|
74
|
-
When the user names an interaction,
|
|
75
|
-
When they name none,
|
|
74
|
+
When the user names an interaction, preserve the behavior they requested and use the sections below for the techniques it needs.
|
|
75
|
+
When they name none, decide what the reader should be able to inspect, compare or change, and what visible response helps them do it.
|
|
76
|
+
The table offers working examples, not fixed pairings between stories, chart types and controls.
|
|
77
|
+
Reuse reliable state and event handling, while choosing the controls, feedback, placement and transitions for this chart.
|
|
78
|
+
The same selection state can drive an inline annotation, a comparison or a detail reveal; a separate readout band is only one way to present it.
|
|
79
|
+
Keep a familiar pattern when it fits the task and design; difference alone is not a reason to change it.
|
|
76
80
|
|
|
77
|
-
| The
|
|
81
|
+
| The reader's task | Example interaction | Section | Recipes demonstrating the technique |
|
|
78
82
|
|---|---|---|---|
|
|
79
83
|
| compare items: which is bigger, by how much | readout of the slat under the pointer, finger or focus, with the fields the bars do not show | 2 | bar, column, pyramid, donut |
|
|
80
84
|
| a ranking whose order depends on the measure | sort toggle | 3 | grouped-bars, bullet, dumbbell |
|
|
@@ -95,7 +95,7 @@ Start from a recipe, never from what another library does.
|
|
|
95
95
|
6. **Numbers show long decimals while they move** [26]: in the JS version (`animate`) values are fractional mid-way; round what you print.
|
|
96
96
|
7. **Bars vanish, or a slat jumps to the first place, after the data changes** [4]: a `style` given as a string that changed (or turned to nothing) replaced the variables rhp keeps in the element's style.
|
|
97
97
|
Give a slat root's changing style as an object: `style=${() => ({ "--n": d.n })}`.
|
|
98
|
-
Blocks need the same workaround on
|
|
98
|
+
Blocks need the same workaround only on rhp 2.0.2; 2.0.3 fixed Block style replacements.
|
|
99
99
|
8. **`animate` is on, but a value still jumps**: the JS version never moves a group given as a function of the row (`v=${(d) => ...}`). Give the changing numbers as a list (`v=${() => values()}`), a single value or a field of `rows`.
|
|
100
100
|
9. **A race stops at each step, or a live feed's marks stop between readings**: to play back data you have, never step through it with a timer; move a playhead on every frame, give the Plot the values interpolated between the steps around it, and keep the JS version's default 150 ms, so the marks glide and Pause leaves them between steps (interaction.md section 10). For a live feed, whose next value is not known yet, ease linear into each new reading over a little more than one interval (`ease: "linear"`, 1.5 intervals); with ease-out or a shorter duration the marks rest between readings.
|
|
101
101
|
10. **Marks trail behind data that changes rapidly or continuously** (a live feed, a slider or drag that drives the data, a play button, a bar chart race): in the default CSS version every new value restarts a CSS transition from wherever the mark is, so under steady change the marks always lag, and Safari can stall transitions that input events retarget. Give the Plot `animate` for the data groups that change (`animate=${["value"]}`, or `animate=${true}`), or the Chart when the scale moves with the data: rhp's JS clock carries each move into the next value. Measured with a slider changing the data every frame: the CSS version trailed the data by 10 points of 100, the JS version by 6.
|
|
@@ -116,12 +116,13 @@ Start from a recipe, never from what another library does.
|
|
|
116
116
|
|
|
117
117
|
## The layout jumps when the reader interacts
|
|
118
118
|
|
|
119
|
-
The checker warns (`layout-jump`) when a control or the chart moves after one of its interactions; these are the usual causes. It tries only some controls, so try every one yourself and compare.
|
|
119
|
+
The checker warns (`layout-jump`) when a control or the chart moves after one of its interactions, (`text-shift`) when a text that still says the same thing moves, and (`jitter`) when a text moves as the pointer goes along a chart that follows it (a line read day by day); these are the usual causes. It tries only some controls, so try every one yourself and compare.
|
|
120
120
|
|
|
121
121
|
1. **Text that changes with a switch moves the controls below it** (a headline or a dek per question): stack every version in one grid cell (a wrapper with `display: grid`, each version at `grid-area: 1 / 1`, `visibility: hidden` except the current one), so the cell is always as tall as the longest version.
|
|
122
122
|
Align the versions to the cell's end (`align-self: end`) when the chart is below them, so a short version leaves no empty line above the chart.
|
|
123
123
|
2. **A readout that wraps on a phone pushes the chart down**: give it a `min-height` for its longest text at 320px (`min-height: 2lh`, or `3lh`), counting the longest numbers and dates that gain a year ("Jan 4, 2027").
|
|
124
124
|
3. **Numbers that change width shift what follows them**: use `font-variant-numeric: tabular-nums`, and give a column of numbers a fixed room; in an "auto" end room, align them across slats with `justify-self: stretch` and `justify-content: flex-end` on the label.
|
|
125
|
+
In a readout, tabular figures alone are not enough when the number of digits changes ("$654" to "$84,176") or a date's month names differ in width: start each part where nothing before it changes width (a grid with a column per part, or each part on its own line), or give the value a box with a `min-width` in `ch` for the longest value it can show, right-aligned. A label or a unit after a value on the same line ("8,101 tons in 2026") slides at every step of a pointer moving along the chart.
|
|
125
126
|
4. **Every bar slides when a slat is lit**: the lit name turns bold in a `room.start` of "auto", which measures the widest name again and moves the plot.
|
|
126
127
|
Change only the lit name's color, or keep its bold width at rest: room in px, or the bold text drawn hidden under the name (`.name::after { content: attr(data-name); display: block; height: 0; overflow: hidden; visibility: hidden; font-weight: 700; }`, with `data-name=${() => d.name}` on the Label; measured: the bars held still at 1280px, where a plain bold name moved them 10.8px).
|
|
127
128
|
5. **A button whose text changes (Play, Pause) moves its neighbors**: stack both texts in one grid cell, the hidden one `visibility: hidden`, so the button is always as wide as the longer one.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Adds the poster's fit-to-screen and PNG download block to a standalone HTML page, before </body>.
|
|
2
|
+
// The block is the same in every recipe; this copies it from one, so the agent never reads it.
|
|
3
|
+
// Usage: node scripts/add-download.mjs <page.html>
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const page = process.argv[2];
|
|
10
|
+
if (!page || !fs.existsSync(page)) {
|
|
11
|
+
console.error("Usage: node scripts/add-download.mjs <page.html> (the page must exist)");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const html = fs.readFileSync(page, "utf8");
|
|
16
|
+
if (html.includes('id="poster-download-script"')) {
|
|
17
|
+
console.log(`${page} already has the download block.`);
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
if (!/<\/body>/i.test(html)) {
|
|
21
|
+
console.error(`${page} has no </body>: add the block by hand, from <style id="poster-download-styles"> to </body> of any recipe.`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const recipe = fs.readFileSync(path.join(here, "../recipes/bar.html"), "utf8");
|
|
26
|
+
const start = recipe.indexOf('<style id="poster-download-styles">');
|
|
27
|
+
const end = recipe.lastIndexOf("</body>");
|
|
28
|
+
const block = recipe.slice(start, end);
|
|
29
|
+
|
|
30
|
+
// We insert before the last </body>, so a </body> inside a script or a comment is left alone.
|
|
31
|
+
const at = html.toLowerCase().lastIndexOf("</body>");
|
|
32
|
+
fs.writeFileSync(page, html.slice(0, at) + block + html.slice(at));
|
|
33
|
+
console.log(`Added the fit-to-screen and download block to ${page} (${block.length} characters). The poster needs the class "poster" for the block to find it.`);
|
package/src/check/README.md
CHANGED
|
@@ -19,14 +19,26 @@ The CLI (`rhp-mcp check`) and the MCP tool (`rhp_check`) print the same report.
|
|
|
19
19
|
It runs at each width checked, and on a page of its own at each of the page's own breakpoints between that width and the next narrower one (both sides of each `min-width` or `max-width` of its media queries, up to six per width), where a layout made only for the widths checked jumps: a readout that takes another line for one slat, just past the width where the poster's padding changes.
|
|
20
20
|
With reduced motion a pick shows at once, so two frames after each are enough: the sweep takes a few seconds per width.
|
|
21
21
|
Widths between those (a jump only from 322 to 338px) are not swept; a readout that keeps its height by design (one line, or the same number of lines whatever it says) has none.
|
|
22
|
+
While the sweep picks, the texts around the charts that a pick rewrites are noted: that is the readout.
|
|
23
|
+
When the main chart draws a Line or an Area, a layer over the plot takes the pointer (the main Plot's first slat cannot be pointed at) or the main Plot has one or two slats (a line per series), the reader moves along the chart, and the sweep first **scrubs** it (`scrub()` in index.js): the mouse goes through 41 points across the plot's middle, along the scale of the plot that draws the line or the area (or of a Plot of one or two slats) and across the main Plot's slats otherwise, at every width (a finger dragging along a phone's plot shows the same readout).
|
|
24
|
+
After each point the layout is measured as for a pick, and so is every line of text around the charts, each text node on its own (its first line box, in the box it floats in), against the page with the pointer away: a text that still says the same thing and moved jitters, and so does one whose words changed and that kept none of its edges (its start, its end or, on one line, its middle) where it was.
|
|
25
|
+
When the page's text changes at three steps in four or more, the readout follows the pointer along the chart (a line read day by day), and a whole sentence that slides counts, though a pick lets one slide. When it changes less often, it changes once per mark crossed (bubbles that a layer covers), and a slide along the line counts as for a pick: only for a text that still says the same thing, and not for a sentence. A text that moves up or down (a readout's name centered beside a description that takes another line) counts either way.
|
|
26
|
+
The report says when no slat could be picked, rather than that nothing moved.
|
|
27
|
+
Then, when the main chart is taller than three quarters of the window (664px at a phone's width), the page is scrolled so that the chart's middle is in the middle of the window, and the controls of the chart's section (not its fit and download icons) and the readout are noted when all of them are out of view.
|
|
28
|
+
At the first width, with a mouse, the **glide** (`glide()` in index.js) then moves the pointer from a slat to its neighbor in twelve steps, for the first two slats, two in the middle and the last two, and reads after each step the page's visible text and how the slats are drawn (two hashes).
|
|
29
|
+
When both slats show something of their own and a step between them shows the text, or the slats, as they are with the pointer away, the readout or the lit slat flickers in the gap.
|
|
22
30
|
6. **Interact** (interact.js), on a fresh page with motion on, as a reader gets it.
|
|
23
31
|
At the first width: point at a mark of the first, middle and last slat of the largest chart (in its first Plot that takes the pointer), and of the slat whose edge Label is widest when it is wider than theirs (lit, a name that gets bolder can make room "auto" wider); click each button and toggle, change each range and select, press an arrow key on a slider (`role=slider` first, else a range), and press Tab and the arrow keys when slats take focus.
|
|
24
32
|
Then Tab through the page (up to 8 stops), and compare a screenshot of each stop's box (with 12px around it for an outline) focused and blurred: under 20 pixels that change (by more than 24 in all three channels) is focus that shows nothing.
|
|
25
33
|
At a phone's width, in a window 664px tall (what Safari shows of a page on a 390 by 844 iPhone): tap a mark of the first and the last slat, and note when the tap changed text the reader can see (wrote it, or showed it) but all of that text lies outside the window while the tapped slat is in it; a style the tap changed in view (the tapped bar lit) does not tell the reader what the text says.
|
|
26
34
|
A point on a mark is one that reaches its slat: on its marks, inside an SVG shape in it (`isPointInFill`, for a donut's wedge that fills a small part of its box), or at a Label's center. When none does and the slat takes the pointer, something covers it, and the step says "could not reach slat N" and what covers it instead of pointing at a corner of a box.
|
|
27
|
-
After each step the guard reads again what the slats read once (stale values, static charts), and the pass notes what moved on the page (the layout jumps).
|
|
35
|
+
After each step the guard reads again what the slats read once (stale values, static charts), and the pass notes what moved on the page (the layout jumps, and the texts that moved).
|
|
36
|
+
A step that is not a pick (a button, a range, a select, a key on a slider) is also read five times as it runs, every 200ms, so a text that jitters while Play runs the years counts even when it is back in place at the end.
|
|
37
|
+
A text is measured in the box it floats in when it floats (fixed, sticky or absolute: a readout pinned to the window, a tooltip that follows the pointer), else on the page, and only when its own text (its text nodes, not its children's) is the same as before: a value that changed may change its size, but the label beside it stays put.
|
|
38
|
+
A text that moves by as much as something that jumped is reported with that jump, not again.
|
|
28
39
|
Controls go by their accessible name, slats by their Labels' text as the reader sees it (not hidden text, nor screen-reader text).
|
|
29
40
|
7. **Report** (findings.js turns probe results into findings, report.js writes the text): errors first, then warnings, then notes, what passed, the interactions (with one line for the sweep: how many slats, and every width it picked them at) and the screenshots.
|
|
41
|
+
A finding whose fix the skill's references explain names that section on a line of its own (`See: pitfalls.md, "The layout jumps when the reader interacts"`, from `SEE` in findings.js), so an agent whose fix did not hold reads that section, not the whole file; the headings it names must stay as they are.
|
|
30
42
|
When pointing at slats (and tapping them) changes nothing but buttons or a slider change the chart, the report says so in one line.
|
|
31
43
|
When nothing changes and the page has no controls, it says "no interaction found (fine when none was asked for)" in one line, instead of a line per slat (slats that others cover, in an overlap Plot with no interaction, are left out of it, as long as one slat was reached).
|
|
32
44
|
|
|
@@ -141,8 +153,12 @@ The tests read this table: every code in it must have a chart in `test/broken/`
|
|
|
141
153
|
| Code | Level | What triggers it | Fix it gives |
|
|
142
154
|
|---|---|---|---|
|
|
143
155
|
| `unnamed-control` | warning | A button, input, select or control with a role (slider, switch, tab...) that has no accessible name: no text, aria-label, aria-labelledby or label, so a screen reader says only what kind of control it is. | Visible text, an aria-label on an icon button, or a `<label>` around an input or a select. |
|
|
144
|
-
| `layout-jump` | warning | An interaction of the pass, or a pick of the sweep, moves a control other than the one it used, or a chart's top, by more than 2px (a readout that takes a second line, a button whose label changes its width); or, while it lights a slat (pointing, a tap, the keyboard), it moves an edge of the plot (a lit name that gets bolder makes room "auto" wider, and every bar slides) or the end of the chart's section (its outermost box in the page: a readout after the chart that takes a second line), by more than 2px. A control in a slat that moves with its slat (rows sorted or removed) is left out, and so are fixed and sticky elements. | A box that keeps its size: a readout on one line with an ellipsis, or a min-height for its longest text; buttons that keep their width; names that keep their weight and size when lit, or room in px. |
|
|
156
|
+
| `layout-jump` | warning | An interaction of the pass, or a pick of the sweep, moves a control other than the one it used, or a chart's top, by more than 2px (a readout that takes a second line, a button whose label changes its width); or, while it lights a slat (pointing, a tap, the keyboard), it moves an edge of the plot (a lit name that gets bolder makes room "auto" wider, and every bar slides) or the end of the chart's section (its outermost box in the page: a readout after the chart that takes a second line), or it changes the size of a box around the charts that has a background, a border or a shadow and holds no chart (a readout's card that shrinks to its text, a legend's panel), by more than 2px. A control in a slat that moves with its slat (rows sorted or removed) is left out, and so are fixed and sticky elements; a box that floats (fixed or absolute: a tooltip) is left out. | A box that keeps its size: a readout on one line with an ellipsis, or a min-height for its longest text; a card or a panel with a fixed width, or a min-width and min-height for its longest content; buttons that keep their width; names that keep their weight and size when lit, or room in px. |
|
|
157
|
+
| `text-shift` | warning | An interaction of the pass, a pick of the sweep, or a moment while a control runs (Play), moves a text around the charts whose own text did not change, by more than 2px, inside the box it floats in or on the page: a label after a value that got wider, a unit after a number in proportional figures, a readout's lines that change order, a legend that reflows, a column that resizes to its widest value. One finding per width, each text at the most it moved; a text that moved with a layout jump is that jump. | Numbers in tabular figures in a column of their own or with a min-width in ch, right-aligned; labels in their own column (a grid), not after a value on one line; lines in a fixed order; a min-height for the readout's longest text. |
|
|
158
|
+
| `jitter` | warning | While the scrub moves the pointer along the chart, a text around the charts moves by more than 2px against the page at rest, inside the box it floats in or on the page: a label or a unit after a value that changed width ("8,101 tons in 2026", where " in 2026" slides), a phrase after a date whose month names differ in width, a sentence whose values change, a line that wraps on some days and not others. One finding per width, each text at the most it moved, named with the line it is part of; a text that moved with a layout jump is that jump, and a text found here is left out of the width's text-shift. | 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. Values in tabular figures with a min-width in ch for the longest value they can show, right-aligned when they end a column; the readout on its lines whatever it says (nowrap, or a min-height). |
|
|
159
|
+
| `flicker` | warning | With a mouse, between two neighboring slats that each show something of their own, a step of the glide shows the page's text, or the slats, as they are with the pointer away: the pick clears in the gap between two bars and comes back, so the readout or the lit slat blinks as the reader moves along the chart. | Keep the pick while the pointer is between slats: change it only on another slat, clear it only when the pointer leaves the chart or the poster. |
|
|
145
160
|
| `out-of-view` | warning | At a phone's width, in a window 664px tall, a tap on a slat that is in the window changes text the reader can see, and all of it lies outside the window (above, below or beside it), even when the tap lights the slat in view: a readout above a tall chart, when the last slat is tapped. | A readout next to the slat (in the slat, or in a Label of it), or one kept in view (`position: sticky`, with a z-index over the plot's 1). |
|
|
161
|
+
| `scrolls-away` | info | The main chart is taller than three quarters of the window (664px at a phone's width), and with its middle in the middle of the window, all of its section's controls (not its fit and download icons), or all of its readout (the texts a pick rewrites), are out of view: the reader has to scroll back to change the year or to read the values. A note: keeping them in view is usually better, not always. | Consider `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, and `scroll-margin-top` on the slats so a picked slat is never under it. |
|
|
146
162
|
| `focus-invisible` | warning | Tab reaches a slat or a control, and a screenshot of its box (with 12px around it) focused and blurred differ in under 20 pixels: `outline: none` with nothing in its place. | Keep the focus ring, or a `:focus-visible` style that shows in its place (on a slat, in the slat type's css). |
|
|
147
163
|
|
|
148
164
|
### Conditional selection repair
|
package/src/check/findings.js
CHANGED
|
@@ -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":
|
package/src/check/index.js
CHANGED
|
@@ -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
|
|
31
|
-
// checked, and the page's own breakpoints between them), how many slats,
|
|
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
|
-
|
|
103
|
-
await page.evaluate(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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]);
|