@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.
Files changed (53) hide show
  1. package/README.md +4 -3
  2. package/bin/rhp-mcp.js +5 -1
  3. package/package.json +3 -3
  4. package/skill/SKILL.md +112 -25
  5. package/skill/recipes/area.html +6 -0
  6. package/skill/recipes/bar.html +6 -0
  7. package/skill/recipes/box-plot.html +6 -0
  8. package/skill/recipes/bubble.html +6 -0
  9. package/skill/recipes/bullet.html +6 -0
  10. package/skill/recipes/candlestick.html +6 -0
  11. package/skill/recipes/column.html +18 -5
  12. package/skill/recipes/dial.html +349 -0
  13. package/skill/recipes/diverging-bars.html +6 -0
  14. package/skill/recipes/donut.html +6 -0
  15. package/skill/recipes/dumbbell.html +6 -0
  16. package/skill/recipes/gantt.html +14 -4
  17. package/skill/recipes/grouped-bars.html +6 -0
  18. package/skill/recipes/heatmap.html +6 -0
  19. package/skill/recipes/histogram.html +6 -0
  20. package/skill/recipes/line.html +7 -1
  21. package/skill/recipes/live.html +6 -0
  22. package/skill/recipes/log-line.html +493 -0
  23. package/skill/recipes/lollipop.html +6 -0
  24. package/skill/recipes/multi-line.html +10 -5
  25. package/skill/recipes/pyramid.html +6 -0
  26. package/skill/recipes/race.html +6 -0
  27. package/skill/recipes/radial-bars.html +6 -0
  28. package/skill/recipes/scatter.html +6 -0
  29. package/skill/recipes/silhouettes.html +352 -0
  30. package/skill/recipes/slope.html +6 -0
  31. package/skill/recipes/sparklines.html +7 -1
  32. package/skill/recipes/stacked-100.html +6 -0
  33. package/skill/recipes/stacked-bars.html +6 -0
  34. package/skill/recipes/strip.html +6 -0
  35. package/skill/recipes/unit-stack.html +276 -0
  36. package/skill/recipes/violin.html +6 -0
  37. package/skill/recipes/waffle.html +6 -0
  38. package/skill/recipes/waterfall.html +6 -0
  39. package/skill/references/api.md +150 -22
  40. package/skill/references/design.md +303 -326
  41. package/skill/references/environments.md +11 -4
  42. package/skill/references/forms.md +41 -4
  43. package/skill/references/interaction.md +3 -1
  44. package/skill/references/looks.md +267 -0
  45. package/skill/references/pitfalls.md +52 -7
  46. package/src/check/README.md +15 -2
  47. package/src/check/findings.js +1 -1
  48. package/src/check/index.js +103 -14
  49. package/src/check/inpage.js +80 -16
  50. package/src/check/report.js +4 -1
  51. package/src/guard/rhp.js +4 -0
  52. package/src/guard/rules.js +1 -1
  53. package/src/server.js +9 -8
@@ -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 by more than 2px (a lit name that gets bolder makes room "auto" wider, and every bar slides). 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. |
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
 
@@ -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 ");
@@ -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
- await page.evaluate(() => window.__rhpProbe.settle(2000));
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
- const capture = async (page, opts) => {
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).catch(() => {});
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
- await context.close();
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
  };
@@ -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 = 2000) {
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
- await Promise.race([document.fonts?.ready, new Promise((r) => setTimeout(r, Math.max(0, left())))]);
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 && (mutations !== m || moving)) await settle(500);
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
- // the largest chart (a size key drawn as a chart of its own comes before the chart it explains)
972
- const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
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
  }
@@ -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)
@@ -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. Start each chart from the closest recipe (rhp_recipe), 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.`;
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, looks, color, type, layout), interaction (which interaction for which story, with code), environments (plain HTML, Solid, React, Next.js, Vue, Svelte, Angular, Astro), forms (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.",
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, designed chart page, a worked example of one way to build a chart: start every chart from the closest recipe, keep its structure and techniques, and replace its data, text and look.',
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(4).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)"),
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, start from the closest recipe (rhp_recipe), 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
+ 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
  }));