@bezda/rhp-mcp 0.1.0 → 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.
- package/README.md +4 -3
- package/bin/rhp-mcp.js +5 -1
- package/package.json +3 -3
- package/skill/SKILL.md +112 -25
- package/skill/recipes/area.html +6 -0
- package/skill/recipes/bar.html +6 -0
- package/skill/recipes/box-plot.html +6 -0
- package/skill/recipes/bubble.html +6 -0
- package/skill/recipes/bullet.html +6 -0
- package/skill/recipes/candlestick.html +6 -0
- package/skill/recipes/column.html +18 -5
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +6 -0
- package/skill/recipes/donut.html +6 -0
- package/skill/recipes/dumbbell.html +6 -0
- package/skill/recipes/gantt.html +14 -4
- package/skill/recipes/grouped-bars.html +6 -0
- package/skill/recipes/heatmap.html +6 -0
- package/skill/recipes/histogram.html +6 -0
- package/skill/recipes/line.html +7 -1
- package/skill/recipes/live.html +6 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +6 -0
- package/skill/recipes/multi-line.html +10 -5
- package/skill/recipes/pyramid.html +6 -0
- package/skill/recipes/race.html +6 -0
- package/skill/recipes/radial-bars.html +6 -0
- package/skill/recipes/scatter.html +6 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +6 -0
- package/skill/recipes/sparklines.html +7 -1
- package/skill/recipes/stacked-100.html +6 -0
- package/skill/recipes/stacked-bars.html +6 -0
- package/skill/recipes/strip.html +6 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +6 -0
- package/skill/recipes/waffle.html +6 -0
- package/skill/recipes/waterfall.html +6 -0
- package/skill/references/api.md +150 -22
- package/skill/references/design.md +303 -326
- package/skill/references/environments.md +11 -4
- package/skill/references/forms.md +41 -4
- package/skill/references/interaction.md +3 -1
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +52 -7
- package/src/check/README.md +15 -2
- package/src/check/findings.js +1 -1
- package/src/check/index.js +103 -14
- package/src/check/inpage.js +80 -16
- package/src/check/report.js +4 -1
- package/src/guard/rhp.js +4 -0
- package/src/guard/rules.js +1 -1
- package/src/server.js +9 -8
package/src/check/README.md
CHANGED
|
@@ -15,6 +15,10 @@ The CLI (`rhp-mcp check`) and the MCP tool (`rhp_check`) print the same report.
|
|
|
15
15
|
5. **Probe** (inpage.js runs in the page; colors.js reads screenshots): layout, text, controls, rhp's CSS, colors.
|
|
16
16
|
The report's screenshot is taken first, before any probe hides or restyles anything; then the screenshots the colors are read from.
|
|
17
17
|
A slat's own transition on a block is read last, with motion asked for again (with reduced motion rhp turns every block's transition off).
|
|
18
|
+
Before that, the **sweep** (`sweep()` in index.js): every slat of the main Plot (up to 32, spread evenly) is picked in turn, by a tap at a phone's width and the mouse elsewhere, and what moved outside the plot is noted (as below).
|
|
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
|
+
With reduced motion a pick shows at once, so two frames after each are enough: the sweep takes a few seconds per width.
|
|
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.
|
|
18
22
|
6. **Interact** (interact.js), on a fresh page with motion on, as a reader gets it.
|
|
19
23
|
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.
|
|
20
24
|
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.
|
|
@@ -22,13 +26,21 @@ The CLI (`rhp-mcp check`) and the MCP tool (`rhp_check`) print the same report.
|
|
|
22
26
|
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.
|
|
23
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).
|
|
24
28
|
Controls go by their accessible name, slats by their Labels' text as the reader sees it (not hidden text, nor screen-reader text).
|
|
25
|
-
7. **Report** (findings.js turns probe results into findings, report.js writes the text): errors first, then warnings, then notes, what passed, the interactions and the screenshots.
|
|
29
|
+
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.
|
|
26
30
|
When pointing at slats (and tapping them) changes nothing but buttons or a slider change the chart, the report says so in one line.
|
|
27
31
|
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).
|
|
28
32
|
|
|
29
33
|
### Screenshots, and the page they move
|
|
30
34
|
|
|
31
35
|
A full-page screenshot of a page taller than the window makes Chromium lay the page out at another size for a moment (1 by 1 px, after most captures), so the page's resize, ResizeObserver and matchMedia handlers run: a chart whose orientation follows the width turns and turns back.
|
|
36
|
+
The checker waits up to ten seconds for fonts, finite entry animations and two unchanged DOM frames, and checks the result of that wait.
|
|
37
|
+
`settleTimeout` overrides this budget in `check()` and the MCP tool.
|
|
38
|
+
The CLI exposes the same setting as `--settle-timeout <ms>`.
|
|
39
|
+
The CLI and MCP accept 100 to 30,000ms; the MCP's overall 50-second deadline still applies.
|
|
40
|
+
An unfinished wait produces `page-unsettled` instead of layout or color conclusions from a moving page.
|
|
41
|
+
That holds for the page that is measured, with reduced motion asked for, where a live feed or a race changes in steps and comes to rest between them.
|
|
42
|
+
The interaction pass runs with motion on, as a reader gets the page, and there a page that moves on its own never comes to rest: it waits up to three seconds for fonts and entry animations, half a second after each screenshot, and is tried as it moves (`clean-moving.html`).
|
|
43
|
+
Geometry and text colors are read after the initial screenshots have settled, so responsive capture handlers cannot leave stale coordinates in the color probes.
|
|
32
44
|
So every screenshot goes through `capture()` (index.js), which lets the page come to rest again when the capture made it change (`calm()` in inpage.js), and `hide()` and `show()` put back only the style properties they set, never a whole `style` attribute saved before (that undid what the page did meanwhile and left such a chart drawn wrong).
|
|
33
45
|
|
|
34
46
|
## Levels
|
|
@@ -100,6 +112,7 @@ The tests read this table: every code in it must have a chart in `test/broken/`
|
|
|
100
112
|
|---|---|---|---|
|
|
101
113
|
| `no-chart` | error | No `.rhp-chart` in the page (not reported when the code can't load, since that reason is reported instead). | Render into an element the page has, or fix the errors above. |
|
|
102
114
|
| `empty-plot` | error | A Plot with no slats shown (an empty overlap Plot is fine: a readout drawn only while pointing). | Give the Plot its data. |
|
|
115
|
+
| `page-unsettled` | error | Fonts, finite animations or DOM changes did not settle within the measurement budget. | Finish entry animations, honor reduced motion for continuous updates, or increase `settleTimeout` for a slow machine. |
|
|
103
116
|
| `small-plot` | error | A plot under 40px along its value axis, or slats under 2px thick (3px slats are fine: a histogram's many bins, a line chart's slats for pointing). | A height or an aspect, a thickness, or fewer rows. |
|
|
104
117
|
| `collapsed-chart` | error | A chart whose plot is under 2px wide: it sits in a flex row, an inline-block or a float that shrinks it to its room. | `flex: 1; min-width: 0`, or a box with a width. |
|
|
105
118
|
| `plot-outside-chart` | error | A `.rhp-plot` with no `.rhp-chart` around it: no scale and none of rhp's CSS. | Put the Plot in a Chart. |
|
|
@@ -128,7 +141,7 @@ The tests read this table: every code in it must have a chart in `test/broken/`
|
|
|
128
141
|
| Code | Level | What triggers it | Fix it gives |
|
|
129
142
|
|---|---|---|---|
|
|
130
143
|
| `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. |
|
|
131
|
-
| `layout-jump` | warning | An interaction of the pass 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
|
|
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. |
|
|
132
145
|
| `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). |
|
|
133
146
|
| `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). |
|
|
134
147
|
|
package/src/check/findings.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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" };
|
|
7
|
+
const DOING = { hover: "pointing at", tap: "tapping", click: "clicking", input: "setting", select: "setting", key: "pressing", pick: "picking" };
|
|
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 ");
|
package/src/check/index.js
CHANGED
|
@@ -16,16 +16,19 @@
|
|
|
16
16
|
// outDir where the screenshots go (a folder in the OS temp dir by default)
|
|
17
17
|
// guard check how rhp is used, in the page (true by default; false is for proving the guard draws nothing)
|
|
18
18
|
// freeze take the screenshots with CSS animations stopped (an endless one at its start), for comparing them
|
|
19
|
+
// settleTimeout readiness budget per settling wait in ms (10000 by default); an unsettled page fails explicitly
|
|
19
20
|
// offline fetch nothing from the web (fonts fall back): for tests that must not depend on the network
|
|
20
21
|
// browser a Playwright Browser to use instead of launching one (it is left open)
|
|
21
22
|
// debug also save the screenshots the color probes read (as drawn, with the text hidden, with the marks hidden)
|
|
22
23
|
//
|
|
23
|
-
// Result: { ok, file, format, rhpVersion, browser, findings, charts, interactions, interactedAt, screenshots, timings,
|
|
24
|
+
// Result: { ok, file, format, rhpVersion, browser, findings, charts, interactions, interactedAt, swept, screenshots, timings,
|
|
24
25
|
// text }
|
|
25
26
|
// ok no finding is an error
|
|
26
27
|
// findings [{ level: "error" | "warning" | "info", code, message, fix, width, widths }], errors first
|
|
27
28
|
// charts [{ width, slats, blocks: { bar: 3, label: 6 }, box: { width, height } }]
|
|
28
29
|
// interactions [{ width, kind, target, changed, how, error, moved, unseen }]
|
|
30
|
+
// swept [{ width, breakpoint, slats, jumps }]: the widths at which every slat was picked in turn (each width
|
|
31
|
+
// checked, and the page's own breakpoints between them), how many slats, and how many moved the layout
|
|
29
32
|
// screenshots [{ width, dark, hover, path }]
|
|
30
33
|
// text the report as plain text (what the CLI prints)
|
|
31
34
|
import fs from "fs";
|
|
@@ -51,6 +54,12 @@ const LEVELS = { error: 0, warning: 1, info: 2 };
|
|
|
51
54
|
const PHONE = 600;
|
|
52
55
|
// The height of what a phone shows of a page (Safari on a 390 by 844 iPhone, its bars shown): where the taps are tried
|
|
53
56
|
const PHONE_WINDOW = 664;
|
|
57
|
+
// With motion on, the longest wait for a page to come to rest after it opens, and after a screenshot, in ms: a page
|
|
58
|
+
// that moves on its own never does
|
|
59
|
+
const MOVING_WAIT = 3000;
|
|
60
|
+
const MOVING_CALM = 500;
|
|
61
|
+
// The most breakpoint widths a width sweeps besides itself
|
|
62
|
+
const MAX_BREAKPOINTS = 6;
|
|
54
63
|
|
|
55
64
|
const shortHash = (text) => crypto.createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
56
65
|
|
|
@@ -80,6 +89,39 @@ function source({ file, code, format }) {
|
|
|
80
89
|
return { file: abs, text: code };
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
// 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,
|
|
93
|
+
// the mouse elsewhere), on a page at rest with reduced motion asked for, where a pick shows at once; and lists what moved
|
|
94
|
+
// outside the plot (moved() in inpage.js): a readout that takes another line for one slat, at one width. What a pick
|
|
95
|
+
// throws is tagged with it in events, as the interaction pass does.
|
|
96
|
+
async function sweep(page, touch, events) {
|
|
97
|
+
|
|
98
|
+
const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets());
|
|
99
|
+
const jumps = [];
|
|
100
|
+
if (!touch) await page.mouse.move(0, 0);
|
|
101
|
+
await page.evaluate(() => window.__rhpProbe.place());
|
|
102
|
+
for (const s of slats) {
|
|
103
|
+
// (a slat that something covers has no point: pointing there would pick what covers it)
|
|
104
|
+
const point = await page.evaluate((n) => window.__rhpProbe.pointAt(n), s.target).catch(() => null);
|
|
105
|
+
if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
|
|
106
|
+
const name = `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
|
|
107
|
+
const before = events.length;
|
|
108
|
+
try {
|
|
109
|
+
if (touch) await page.touchscreen.tap(point.x, point.y);
|
|
110
|
+
else await page.mouse.move(point.x, point.y);
|
|
111
|
+
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (const e of events.slice(before)) {
|
|
116
|
+
if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= `${touch ? "tap" : "hover"} ${name}`;
|
|
117
|
+
}
|
|
118
|
+
const moved = await page.evaluate((n) => window.__rhpProbe.moved(n, true), s.target).catch(() => []);
|
|
119
|
+
if (moved.length) jumps.push({ target: name, moved });
|
|
120
|
+
}
|
|
121
|
+
if (!touch) await page.mouse.move(0, 0);
|
|
122
|
+
return { slats: slats.length, jumps };
|
|
123
|
+
}
|
|
124
|
+
|
|
83
125
|
export async function check(options = {}) {
|
|
84
126
|
|
|
85
127
|
const started = performance.now();
|
|
@@ -89,6 +131,10 @@ export async function check(options = {}) {
|
|
|
89
131
|
const dark = !!options.dark;
|
|
90
132
|
const guard = options.guard !== false;
|
|
91
133
|
const wantInteract = options.interact !== false;
|
|
134
|
+
const settleTimeout = options.settleTimeout ?? 10000;
|
|
135
|
+
const requireRest = (result) => {
|
|
136
|
+
if (!result.rest) throw Object.assign(new Error(`The page did not settle within ${settleTimeout}ms; layout and color measurements would be unreliable.`), { code: "page-unsettled" });
|
|
137
|
+
};
|
|
92
138
|
|
|
93
139
|
const src = source(options);
|
|
94
140
|
if (src.error) return finish({ file: options.file ?? null, format: options.format ?? null, findings: [{ level: "error", code: "no-file", message: src.error, fix: "Pass the path of the chart's file (an .html page, a .jsx component or a .js module)." }], timings, started });
|
|
@@ -190,7 +236,9 @@ export async function check(options = {}) {
|
|
|
190
236
|
const isLocal = (u) => u.startsWith("file:") || u.startsWith(ORIGIN);
|
|
191
237
|
|
|
192
238
|
// A page with the chart, the probe installed and at rest (in a window of another size when asked); what the page
|
|
193
|
-
// throws or prints goes to events
|
|
239
|
+
// throws or prints goes to events. The page that is measured, with reduced motion, must come to rest. With motion on
|
|
240
|
+
// (the interaction pass), a page that moves on its own (a live feed, a race that plays) never does: it gets a few
|
|
241
|
+
// seconds for its fonts and entry animations, and is then tried as it moves, as a reader gets it.
|
|
194
242
|
const open = async (context, events, reducedMotion, viewport = null) => {
|
|
195
243
|
const page = await context.newPage();
|
|
196
244
|
if (viewport) await page.setViewportSize(viewport);
|
|
@@ -212,21 +260,25 @@ export async function check(options = {}) {
|
|
|
212
260
|
await page.route("**/*", route);
|
|
213
261
|
await page.goto(format === "html" ? pathToFileURL(file).href : ORIGIN + "/", { waitUntil: "load", timeout: 20000 }).catch((e) => events.push({ kind: "pageerror", text: "The page did not finish loading: " + String(e.message).split("\n")[0] }));
|
|
214
262
|
await page.evaluate(install);
|
|
215
|
-
|
|
263
|
+
const measured = reducedMotion === "reduce";
|
|
264
|
+
const rest = await page.evaluate((max) => window.__rhpProbe.settle(max), measured ? settleTimeout : Math.min(settleTimeout, MOVING_WAIT));
|
|
265
|
+
if (measured) requireRest(rest);
|
|
216
266
|
return page;
|
|
217
267
|
};
|
|
218
268
|
|
|
219
269
|
// A screenshot, after which the page comes to rest again: taking one can lay the page out at another size for a
|
|
220
|
-
// moment (see calm() in inpage.js)
|
|
221
|
-
|
|
270
|
+
// moment (see calm() in inpage.js). In the interaction pass (moving: true) the page may move on its own, so it waits
|
|
271
|
+
// briefly and goes on.
|
|
272
|
+
const capture = async (page, opts, moving = false) => {
|
|
222
273
|
const since = await page.evaluate(() => window.__rhpProbe.resized());
|
|
223
274
|
const png = await page.screenshot(opts);
|
|
224
|
-
await page.evaluate((n) => window.__rhpProbe.calm(n), since
|
|
275
|
+
const rest = await page.evaluate(([n, max]) => window.__rhpProbe.calm(n, max), [since, moving ? Math.min(settleTimeout, MOVING_CALM) : settleTimeout]);
|
|
276
|
+
if (!moving) requireRest(rest);
|
|
225
277
|
return png;
|
|
226
278
|
};
|
|
227
279
|
|
|
228
280
|
// The screenshot the report gives, cropped to what the page draws
|
|
229
|
-
const save = async (page, width, suffix) => {
|
|
281
|
+
const save = async (page, width, suffix, moving = false) => {
|
|
230
282
|
const box = await page.evaluate(() => window.__rhpProbe.contentBox());
|
|
231
283
|
const size = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight }));
|
|
232
284
|
const pad = 16;
|
|
@@ -237,7 +289,7 @@ export async function check(options = {}) {
|
|
|
237
289
|
height: Math.min(size.h, Math.ceil(box.bottom + pad)) - Math.max(0, Math.floor(box.top - pad)),
|
|
238
290
|
};
|
|
239
291
|
const png = path.join(outDir, `${name}-${width}${dark ? "-dark" : ""}${suffix ? "-" + suffix : ""}.png`);
|
|
240
|
-
await capture(page, { type: "png", path: png, fullPage: true, animations: options.freeze ? "disabled" : "allow", ...(clip && clip.width > 0 && clip.height > 0 ? { clip } : {}) });
|
|
292
|
+
await capture(page, { type: "png", path: png, fullPage: true, animations: options.freeze ? "disabled" : "allow", ...(clip && clip.width > 0 && clip.height > 0 ? { clip } : {}) }, moving);
|
|
241
293
|
screenshots.push({ width, dark, hover: suffix === "hover", path: png });
|
|
242
294
|
};
|
|
243
295
|
|
|
@@ -249,12 +301,11 @@ export async function check(options = {}) {
|
|
|
249
301
|
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: phone ? 2 : 1, hasTouch: phone, colorScheme: dark ? "dark" : "light" });
|
|
250
302
|
const events = [];
|
|
251
303
|
const w0 = performance.now();
|
|
304
|
+
try {
|
|
252
305
|
|
|
253
306
|
// The page is measured and pictured with reduced motion asked for: rhp's moves end at once, and a page that moves
|
|
254
307
|
// on its own (an autoplay, a live feed) waits or changes in steps, so the screenshots show what was measured
|
|
255
308
|
const page = await open(context, events, "reduce");
|
|
256
|
-
const seen = await page.evaluate(() => window.__rhpProbe.measure());
|
|
257
|
-
const items = await page.evaluate(() => window.__rhpProbe.colorItems());
|
|
258
309
|
|
|
259
310
|
// first, before any probe hides or restyles anything
|
|
260
311
|
await save(page, width, "");
|
|
@@ -266,6 +317,10 @@ export async function check(options = {}) {
|
|
|
266
317
|
return decode(png);
|
|
267
318
|
};
|
|
268
319
|
const drawn = keep(await shot(), "drawn");
|
|
320
|
+
// Full-page captures can trigger responsive layout handlers. Read geometry
|
|
321
|
+
// and text colors only after those handlers have come to rest again.
|
|
322
|
+
const seen = await page.evaluate(() => window.__rhpProbe.measure());
|
|
323
|
+
const items = await page.evaluate(() => window.__rhpProbe.colorItems());
|
|
269
324
|
await page.evaluate(() => window.__rhpProbe.hide("text"));
|
|
270
325
|
const noText = keep(await shot(), "no-text");
|
|
271
326
|
let noMarks = null;
|
|
@@ -276,6 +331,26 @@ export async function check(options = {}) {
|
|
|
276
331
|
await page.evaluate(() => window.__rhpProbe.show());
|
|
277
332
|
const colorFindings = [...textContrast(items, noText, 1), ...(noMarks ? faintMarks(items, drawn, noMarks, 1) : []), ...colorBlind(items, noMarks, 1)];
|
|
278
333
|
|
|
334
|
+
// Every slat picked in turn, here and at the page's own breakpoints between this width and the next narrower
|
|
335
|
+
// one checked (a page of its own at each), where a layout that only fits at the widths checked jumps
|
|
336
|
+
const swept = [];
|
|
337
|
+
if (wantInteract) {
|
|
338
|
+
swept.push({ width, ...(await sweep(page, phone, events)) });
|
|
339
|
+
const next = [...widths].sort((a, b) => b - a).find((w) => w < width);
|
|
340
|
+
const between = next == null ? [] : (await page.evaluate(() => window.__rhpProbe.breakpoints())).filter((b) => b < width && b > next && !widths.includes(b));
|
|
341
|
+
for (const b of between.slice(0, MAX_BREAKPOINTS)) {
|
|
342
|
+
let at = null;
|
|
343
|
+
try {
|
|
344
|
+
at = await open(context, events, "reduce", { width: b, height: 900 });
|
|
345
|
+
swept.push({ width: b, breakpoint: true, ...(await sweep(at, phone, events)) });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error.code !== "page-unsettled") throw error;
|
|
348
|
+
} finally {
|
|
349
|
+
await at?.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
279
354
|
// with reduced motion rhp turns every block's transition off, so a slat's own is read with motion on
|
|
280
355
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
281
356
|
seen.found.push(...(await page.evaluate(() => window.__rhpProbe.transitions())));
|
|
@@ -289,7 +364,7 @@ export async function check(options = {}) {
|
|
|
289
364
|
const loaded = !events.some((e) => e.kind === "pageerror" && /^The page did not finish loading/.test(e.text));
|
|
290
365
|
if (wantInteract && loaded && (i === 0 || phone)) {
|
|
291
366
|
const fresh = await open(context, events, "no-preference", phone ? { width, height: PHONE_WINDOW } : null);
|
|
292
|
-
const pass = { events, capture: (opts) => capture(fresh, opts), shoot: (suffix) => save(fresh, width, suffix) };
|
|
367
|
+
const pass = { events, capture: (opts) => capture(fresh, opts, true), shoot: (suffix) => save(fresh, width, suffix, true) };
|
|
293
368
|
const passes = [];
|
|
294
369
|
if (i === 0) passes.push(await interactionPass(fresh, pass));
|
|
295
370
|
if (phone) passes.push(await interactionPass(fresh, { ...pass, taps: true }));
|
|
@@ -302,8 +377,13 @@ export async function check(options = {}) {
|
|
|
302
377
|
guarded.push(...(await fresh.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
|
|
303
378
|
}
|
|
304
379
|
|
|
305
|
-
results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, ms: Math.round(performance.now() - w0) });
|
|
306
|
-
|
|
380
|
+
results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, swept, ms: Math.round(performance.now() - w0) });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error.code !== "page-unsettled") throw error;
|
|
383
|
+
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 });
|
|
384
|
+
} finally {
|
|
385
|
+
await context.close();
|
|
386
|
+
}
|
|
307
387
|
}));
|
|
308
388
|
} finally {
|
|
309
389
|
if (!options.browser) await browser.close();
|
|
@@ -364,6 +444,13 @@ export async function check(options = {}) {
|
|
|
364
444
|
if (jumps.has(key)) jumps.get(key).more++;
|
|
365
445
|
else jumps.set(key, { code: "layout-jump", kind: x.kind, target: x.target, moved: x.moved, more: 0 });
|
|
366
446
|
}
|
|
447
|
+
for (const sw of r.swept) {
|
|
448
|
+
for (const j of sw.jumps) {
|
|
449
|
+
const key = j.moved.map((m) => m.what).join("\n");
|
|
450
|
+
if (jumps.has(key)) jumps.get(key).more++;
|
|
451
|
+
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 });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
367
454
|
for (const j of jumps.values()) {
|
|
368
455
|
findings.push({ ...fromProbe(j, {}), width: r.width });
|
|
369
456
|
}
|
|
@@ -378,9 +465,10 @@ export async function check(options = {}) {
|
|
|
378
465
|
|
|
379
466
|
const charts = results.flatMap((r) => r.seen.charts.map((c) => ({ width: r.width, ...c })));
|
|
380
467
|
const interactions = results.flatMap((r) => r.interactions);
|
|
468
|
+
const swept = results.flatMap((r) => r.swept.map(({ width, breakpoint, slats, jumps }) => ({ width, breakpoint: !!breakpoint, slats, jumps: jumps.length }))).sort((a, b) => b.width - a.width);
|
|
381
469
|
timings.widths = Object.fromEntries(results.map((r) => [r.width, r.ms]));
|
|
382
470
|
|
|
383
|
-
return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, screenshots, timings, started });
|
|
471
|
+
return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, swept, screenshots, timings, started });
|
|
384
472
|
}
|
|
385
473
|
|
|
386
474
|
// Merges the same finding at several widths, sorts errors first, and writes the report
|
|
@@ -410,6 +498,7 @@ function finish({ findings, started, timings, ...rest }) {
|
|
|
410
498
|
charts: rest.charts ?? [],
|
|
411
499
|
interactions: rest.interactions ?? [],
|
|
412
500
|
interactedAt: rest.interactedAt ?? null,
|
|
501
|
+
swept: rest.swept ?? [],
|
|
413
502
|
screenshots: rest.screenshots ?? [],
|
|
414
503
|
timings,
|
|
415
504
|
};
|
package/src/check/inpage.js
CHANGED
|
@@ -22,12 +22,13 @@ export function install() {
|
|
|
22
22
|
// Waits for fonts, two frames, the animations that end (CSS transitions, a slat's own animations) and a DOM that
|
|
23
23
|
// stays still for two frames (the JS version moves numbers on its own clock), up to max ms. Says whether the page
|
|
24
24
|
// came to rest.
|
|
25
|
-
async function settle(max =
|
|
25
|
+
async function settle(max = 10000) {
|
|
26
26
|
|
|
27
27
|
const start = performance.now();
|
|
28
28
|
const left = () => max - (performance.now() - start);
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
let timer;
|
|
31
|
+
await Promise.race([document.fonts?.ready, new Promise((r) => { timer = setTimeout(r, Math.max(0, left())); })]).finally(() => clearTimeout(timer));
|
|
31
32
|
await frame();
|
|
32
33
|
await frame();
|
|
33
34
|
|
|
@@ -39,7 +40,7 @@ export function install() {
|
|
|
39
40
|
still = !moving && mutations === before ? still + 1 : 0;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
return { ms: Math.round(performance.now() - start), rest: still >= 2 };
|
|
43
|
+
return { ms: Math.round(performance.now() - start), rest: still >= 2 && document.fonts?.status !== "loading" };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
// A full-page screenshot lays the page out at another size for a moment (Chromium does, to draw what is past the
|
|
@@ -48,11 +49,12 @@ export function install() {
|
|
|
48
49
|
// since.
|
|
49
50
|
let resizes = 0;
|
|
50
51
|
addEventListener("resize", () => resizes++);
|
|
51
|
-
async function calm([r, m]) {
|
|
52
|
+
async function calm([r, m], max = 10000) {
|
|
52
53
|
|
|
53
54
|
await frame();
|
|
54
55
|
const moving = document.getAnimations().some((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime ?? Infinity));
|
|
55
|
-
if (resizes !== r
|
|
56
|
+
if (resizes !== r || mutations !== m || moving || document.fonts?.status === "loading") return settle(max);
|
|
57
|
+
return { rest: true };
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const visible = (el) => !!el && el.checkVisibility?.({ opacityProperty: true, visibilityProperty: true }) !== false;
|
|
@@ -958,6 +960,62 @@ export function install() {
|
|
|
958
960
|
return { left: left + scrollX, top: top + scrollY, right: right + scrollX, bottom: bottom + scrollY };
|
|
959
961
|
}
|
|
960
962
|
|
|
963
|
+
// The slats of the main Plot, in screen order: the largest chart's (a size key drawn as a chart of its own comes before
|
|
964
|
+
// the chart it explains) first Plot with slats that takes the pointer (an overlay of guides or a readout takes none)
|
|
965
|
+
function mainSlats() {
|
|
966
|
+
|
|
967
|
+
const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
|
|
968
|
+
const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
|
|
969
|
+
const plots = chart ? [...chart.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale") && [...p.children].some((s) => !s.hidden)) : [];
|
|
970
|
+
const plot = plots.find((p) => getComputedStyle(p).pointerEvents !== "none") ?? plots[0];
|
|
971
|
+
if (!plot) return [];
|
|
972
|
+
const vertical = plot.dataset.rhpO === "v";
|
|
973
|
+
const slats = [...plot.children].filter((s) => !s.hidden && visible(s));
|
|
974
|
+
slats.sort((a, b) => (vertical ? a.getBoundingClientRect().left - b.getBoundingClientRect().left : a.getBoundingClientRect().top - b.getBoundingClientRect().top));
|
|
975
|
+
return slats;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// The slats the sweep picks in turn (see sweep() in index.js): every slat of the main Plot, or max of them spread
|
|
979
|
+
// evenly from the first to the last
|
|
980
|
+
function sweepTargets(max = 32) {
|
|
981
|
+
|
|
982
|
+
const slats = mainSlats();
|
|
983
|
+
const list = (window.__rhpTargets ??= []);
|
|
984
|
+
const picks = slats.length <= max ? slats.map((_, i) => i) : [...new Set(Array.from({ length: max }, (_, k) => Math.round((k * (slats.length - 1)) / (max - 1))))];
|
|
985
|
+
return picks.map((i) => ({ index: i + 1, of: slats.length, name: snippet([...slats[i].querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? ""), target: list.push(slats[i]) - 1 }));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// The window widths at which the page's own media queries switch: the last width of one layout and the first of the
|
|
989
|
+
// next (for max-width: N, N and N + 1; for min-width: N, N - 1 and N; the range forms likewise). An em counts 16px.
|
|
990
|
+
function breakpoints() {
|
|
991
|
+
|
|
992
|
+
const out = new Set();
|
|
993
|
+
const px = (v, unit) => (unit === "px" ? +v : +v * 16);
|
|
994
|
+
// the first width of the wider layout, from a bound and whether the narrow layout includes it
|
|
995
|
+
const add = (n, narrowHasIt) => {
|
|
996
|
+
const first = narrowHasIt ? Math.floor(n) + 1 : Math.ceil(n);
|
|
997
|
+
out.add(first - 1);
|
|
998
|
+
out.add(first);
|
|
999
|
+
};
|
|
1000
|
+
const walk = (rules) => {
|
|
1001
|
+
for (const r of rules ?? []) {
|
|
1002
|
+
const text = r.media?.mediaText ?? "";
|
|
1003
|
+
for (const m of text.matchAll(/(min|max)-width\s*:\s*([\d.]+)(px|r?em)/g)) add(px(m[2], m[3]), m[1] === "max");
|
|
1004
|
+
for (const m of text.matchAll(/width\s*(<=|<|>=|>)\s*([\d.]+)(px|r?em)/g)) add(px(m[2], m[3]), m[1] === "<=" || m[1] === ">");
|
|
1005
|
+
for (const m of text.matchAll(/([\d.]+)(px|r?em)\s*(<=|<|>=|>)\s*width/g)) add(px(m[1], m[2]), m[3] === ">=" || m[3] === "<");
|
|
1006
|
+
if (r.cssRules) walk(r.cssRules);
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
for (const sheet of [...document.styleSheets, ...(document.adoptedStyleSheets ?? [])]) {
|
|
1010
|
+
try {
|
|
1011
|
+
walk(sheet.cssRules);
|
|
1012
|
+
} catch {
|
|
1013
|
+
// a style sheet from another origin (a font's CSS) cannot be read, and holds no layout
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return [...out].filter((w) => w >= 200).sort((a, b) => b - a);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
961
1019
|
// What the interaction pass works on: slats to point at (the first, the middle and the last of the largest chart, and
|
|
962
1020
|
// the one whose edge Label is widest: lit, a name that gets bolder can make room: "auto" measure again), buttons and
|
|
963
1021
|
// toggles, ranges, selects, a slider for the arrow keys, Plots with keyboard. Controls go by their accessible name.
|
|
@@ -968,16 +1026,8 @@ export function install() {
|
|
|
968
1026
|
const out = { slats: [], widest: null, buttons: [], ranges: [], selects: [], slider: null, keyboard: null };
|
|
969
1027
|
const label = (el) => (nameOf(el) ? `"${snippet(nameOf(el))}"` : describe(el));
|
|
970
1028
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
|
|
974
|
-
// its first Plot with slats that takes the pointer (an overlay of guides or a readout takes none)
|
|
975
|
-
const plots = chart ? [...chart.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale") && [...p.children].some((s) => !s.hidden)) : [];
|
|
976
|
-
const plot = plots.find((p) => getComputedStyle(p).pointerEvents !== "none") ?? plots[0];
|
|
977
|
-
if (plot) {
|
|
978
|
-
const vertical = plot.dataset.rhpO === "v";
|
|
979
|
-
const slats = [...plot.children].filter((s) => !s.hidden && visible(s));
|
|
980
|
-
slats.sort((a, b) => (vertical ? a.getBoundingClientRect().left - b.getBoundingClientRect().left : a.getBoundingClientRect().top - b.getBoundingClientRect().top));
|
|
1029
|
+
const slats = mainSlats();
|
|
1030
|
+
if (slats.length) {
|
|
981
1031
|
const range = document.createRange();
|
|
982
1032
|
const nameWidth = (s) => Math.max(0, ...[...s.querySelectorAll(".rhp-label[data-rhp-edge]")].map((l) => {
|
|
983
1033
|
range.selectNodeContents(l);
|
|
@@ -1090,6 +1140,7 @@ export function install() {
|
|
|
1090
1140
|
// moves with it (rows sorted or removed), so only what it moves apart from its slat counts. What is fixed or sticky
|
|
1091
1141
|
// moves with scrolling, so it is left out.
|
|
1092
1142
|
let placed = [];
|
|
1143
|
+
let sections = [];
|
|
1093
1144
|
const at = (el) => {
|
|
1094
1145
|
const r = el.getBoundingClientRect();
|
|
1095
1146
|
return { el, x: r.left + scrollX, y: r.top + scrollY };
|
|
@@ -1118,6 +1169,13 @@ export function install() {
|
|
|
1118
1169
|
const slat = chart ? null : el.closest(".rhp-plot > *");
|
|
1119
1170
|
return { ...at(el), chart, slat: slat && at(slat), gutters: chart ? gutters(el) : null };
|
|
1120
1171
|
});
|
|
1172
|
+
// the end of each chart's section (its outermost box inside the body): a readout after the chart that takes
|
|
1173
|
+
// another line makes it longer, even on a page shorter than the window
|
|
1174
|
+
sections = [...new Set(charts.map((c) => {
|
|
1175
|
+
let a = c;
|
|
1176
|
+
while (a.parentElement && a.parentElement !== document.body && a.parentElement !== document.documentElement) a = a.parentElement;
|
|
1177
|
+
return a;
|
|
1178
|
+
}))].map((el) => ({ el, end: el.getBoundingClientRect().bottom + scrollY }));
|
|
1121
1179
|
}
|
|
1122
1180
|
|
|
1123
1181
|
function moved(n, lit = false) {
|
|
@@ -1150,6 +1208,12 @@ export function install() {
|
|
|
1150
1208
|
if (now.o === "v" && far(start)) out.push({ what: "the plot's bottom edge", dx: 0, dy: -start, plot: true });
|
|
1151
1209
|
if (now.o === "v" && far(end)) out.push({ what: "the plot's top edge", dx: 0, dy: end, plot: true });
|
|
1152
1210
|
}
|
|
1211
|
+
// lighting a slat makes no section longer or shorter: a readout after the chart that takes another line moves
|
|
1212
|
+
// whatever follows it, which no control may show
|
|
1213
|
+
for (const { el, end } of lit ? sections : []) {
|
|
1214
|
+
const grew = Math.round(el.getBoundingClientRect().bottom + scrollY - end);
|
|
1215
|
+
if (el.isConnected && far(grew)) out.push({ what: "the end of the chart's section", dx: 0, dy: grew });
|
|
1216
|
+
}
|
|
1153
1217
|
|
|
1154
1218
|
return out;
|
|
1155
1219
|
}
|
|
@@ -1264,5 +1328,5 @@ export function install() {
|
|
|
1264
1328
|
return { what: focusName(el), clip: box };
|
|
1265
1329
|
}
|
|
1266
1330
|
|
|
1267
|
-
window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, pointAt, place, moved, record, stop, unseen, focused, focusStop };
|
|
1331
|
+
window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, sweepTargets, breakpoints, pointAt, place, moved, record, stop, unseen, focused, focusStop };
|
|
1268
1332
|
}
|
package/src/check/report.js
CHANGED
|
@@ -52,7 +52,7 @@ export function report(r) {
|
|
|
52
52
|
section("info", "Notes");
|
|
53
53
|
|
|
54
54
|
const codes = new Set(r.findings.map((f) => f.code));
|
|
55
|
-
if (r.charts?.length || r.screenshots?.length) {
|
|
55
|
+
if (!codes.has("page-unsettled") && (r.charts?.length || r.screenshots?.length)) {
|
|
56
56
|
const passed = [];
|
|
57
57
|
for (const [fail, text] of PASSES) {
|
|
58
58
|
if (fail.some((c) => codes.has(c))) continue;
|
|
@@ -88,6 +88,9 @@ export function report(r) {
|
|
|
88
88
|
if (quiet.includes(x.kind)) continue;
|
|
89
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
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"}`);
|
|
91
94
|
} else if (r.charts?.length && r.interactedAt) {
|
|
92
95
|
lines.push("", "Interactions: nothing to interact with (no slats to point at, no buttons, ranges or selects).");
|
|
93
96
|
}
|
package/src/guard/rhp.js
CHANGED
|
@@ -168,6 +168,10 @@ function inspectChart(props) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
checkTheme("Chart's theme", read(props, "theme"));
|
|
171
|
+
for (const prop of ["grid", "crossGrid"]) {
|
|
172
|
+
const value = read(props, prop);
|
|
173
|
+
if (value != null && typeof value !== "boolean") note("error", "bad-prop", `Chart's ${prop} is ${show(value)}.`, "Use false to hide grid lines while retaining axis numbers, or true to show them.");
|
|
174
|
+
}
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
// The keys of the objects in rows, over all of them (a row may leave one out)
|
package/src/guard/rules.js
CHANGED
|
@@ -25,7 +25,7 @@ export const SETTING_VALUE = {
|
|
|
25
25
|
children: (v) => typeof v === "function",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
export const CHART_PROPS = ["scale", "orientation", "height", "aspect", "ticks", "format", "animate", "theme", "static", "cross", "crossTicks", "crossFormat", "label", "id", "class", "role", "style", "ref", "children"];
|
|
28
|
+
export const CHART_PROPS = ["scale", "orientation", "height", "aspect", "ticks", "grid", "format", "animate", "theme", "static", "cross", "crossTicks", "crossGrid", "crossFormat", "label", "id", "class", "role", "style", "ref", "children"];
|
|
29
29
|
|
|
30
30
|
export const THEME_KEYS = ["series", "positive", "negative", "ink", "muted", "grid", "surface", "low", "high", "font"];
|
|
31
31
|
|
package/src/server.js
CHANGED
|
@@ -21,7 +21,7 @@ const LIMIT = 50;
|
|
|
21
21
|
const MAX_WIDTH = 1280;
|
|
22
22
|
const MAX_HEIGHT = 2000;
|
|
23
23
|
|
|
24
|
-
const INSTRUCTIONS = `rhp 2 (@bezda/rhp) makes charts out of HTML elements and CSS with SolidJS. It came out in September 2026, after your training data, so what you remember about rhp is wrong or belongs to the unrelated rhp 1. Before you write or change any rhp code, call rhp_guide and follow its workflow.
|
|
24
|
+
const INSTRUCTIONS = `rhp 2 (@bezda/rhp) makes charts out of HTML elements and CSS with SolidJS. It came out in September 2026, after your training data, so what you remember about rhp is wrong or belongs to the unrelated rhp 1. Before you write or change any rhp code, call rhp_guide and follow its workflow. Take each chart's technique from the closest recipe (rhp_recipe) and design the chart for its own subject, and look up every prop you are unsure of with rhp_reference instead of guessing. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`;
|
|
25
25
|
|
|
26
26
|
const text = (body) => ({ content: [{ type: "text", text: body }] });
|
|
27
27
|
const failure = (body) => ({ content: [{ type: "text", text: body }], isError: true });
|
|
@@ -112,7 +112,7 @@ async function images(browser, shots) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
// Checks a chart: the report, then each screenshot as an image after a line saying what it shows
|
|
115
|
-
async function checkTool({ file, code, format, widths, dark, interact }) {
|
|
115
|
+
async function checkTool({ file, code, format, widths, dark, interact, settleTimeout }) {
|
|
116
116
|
|
|
117
117
|
if (file && code) return failure("Give file or code, not both.");
|
|
118
118
|
if (!file && !code) return failure("Give the chart to check: file (the absolute path of its file) or code (its code).");
|
|
@@ -131,7 +131,7 @@ async function checkTool({ file, code, format, widths, dark, interact }) {
|
|
|
131
131
|
const { check } = await import("./check/index.js");
|
|
132
132
|
const got = await sharedBrowser();
|
|
133
133
|
const browser = got.browser;
|
|
134
|
-
const running = check({ file, code, format, widths, dark, interact, browser }).then((result) => ({ result }), (error) => ({ error }));
|
|
134
|
+
const running = check({ file, code, format, widths, dark, interact, settleTimeout, browser }).then((result) => ({ result }), (error) => ({ error }));
|
|
135
135
|
let timer;
|
|
136
136
|
const late = new Promise((resolve) => {
|
|
137
137
|
timer = setTimeout(resolve, LIMIT * 1000, { late: true });
|
|
@@ -185,9 +185,9 @@ server.registerTool("rhp_guide", {
|
|
|
185
185
|
|
|
186
186
|
server.registerTool("rhp_reference", {
|
|
187
187
|
title: "rhp reference",
|
|
188
|
-
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (the editorial poster,
|
|
188
|
+
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (which interaction for which story, with code), environments (plain HTML, Solid, React, Next.js, Vue, Svelte, Angular, Astro), forms (the subject's own form, which chart for which data and story), pitfalls (mistakes and their fixes). Look props up here instead of guessing. A long reference comes back as its list of sections: call again with section to get the one you need.",
|
|
189
189
|
inputSchema: {
|
|
190
|
-
name: z.string().describe("The reference: api, design, environments, forms, interaction or pitfalls"),
|
|
190
|
+
name: z.string().describe("The reference: api, design, environments, forms, interaction, looks or pitfalls"),
|
|
191
191
|
section: z.string().optional().describe('One "##" section of the reference, by its number or title, for example "4" or "Plot"'),
|
|
192
192
|
},
|
|
193
193
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -200,7 +200,7 @@ server.registerTool("rhp_reference", {
|
|
|
200
200
|
|
|
201
201
|
server.registerTool("rhp_recipe", {
|
|
202
202
|
title: "rhp recipe",
|
|
203
|
-
description: 'Lists rhp\'s recipes (call it with no type), or returns one recipe\'s complete HTML file (type, for example "bar"). A recipe is a tested
|
|
203
|
+
description: 'Lists rhp\'s recipes (call it with no type), or returns one recipe\'s complete HTML file (type, for example "bar"). A recipe is a tested chart page that shows the technique for one kind of chart: take its technique (how its Plots, slats and blocks are composed, its interaction, its phone rules) and leave its design, which was made for its own subject.',
|
|
204
204
|
inputSchema: {
|
|
205
205
|
type: z.string().optional().describe('The recipe, for example "bar", "line" or "donut"; leave it out for the list'),
|
|
206
206
|
},
|
|
@@ -220,9 +220,10 @@ server.registerTool("rhp_check", {
|
|
|
220
220
|
file: z.string().optional().describe("The chart file's absolute path"),
|
|
221
221
|
code: z.string().optional().describe("The chart's code, instead of a file, when you cannot write files"),
|
|
222
222
|
format: z.enum(["html", "solid", "react", "module"]).optional().describe("What the code is; found from the file's extension and the code when left out"),
|
|
223
|
-
widths: z.array(z.number().int().min(200).max(2560)).min(1).max(
|
|
223
|
+
widths: z.array(z.number().int().min(200).max(2560)).min(1).max(6).optional().describe("Page widths in px, [1280, 390] by default; the interactions are tried at the first, and slats are tapped at phone widths (600px or less)"),
|
|
224
224
|
dark: z.boolean().optional().describe("Render with a dark color scheme (prefers-color-scheme: dark)"),
|
|
225
225
|
interact: z.boolean().optional().describe("Try the chart's interactions (true by default)"),
|
|
226
|
+
settleTimeout: z.number().int().min(100).max(30000).optional().describe("Maximum milliseconds per readiness wait, 10000 by default. Increase for a slow machine; the overall check still has a 50-second limit."),
|
|
226
227
|
},
|
|
227
228
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
228
229
|
}, checkTool);
|
|
@@ -238,7 +239,7 @@ server.registerPrompt("chart", {
|
|
|
238
239
|
role: "user",
|
|
239
240
|
content: {
|
|
240
241
|
type: "text",
|
|
241
|
-
text: `Make this chart with rhp (@bezda/rhp): ${request}\n\nFirst call rhp_guide and follow its workflow: write the brief,
|
|
242
|
+
text: `Make this chart with rhp (@bezda/rhp): ${request}\n\nFirst call rhp_guide and follow its workflow: write the brief, take the technique from the closest recipe (rhp_recipe), design the chart for its own subject, and look up props with rhp_reference instead of guessing. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`,
|
|
242
243
|
},
|
|
243
244
|
}],
|
|
244
245
|
}));
|