@bezda/rhp-mcp 0.1.0 → 0.1.2

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 (59) hide show
  1. package/README.md +20 -5
  2. package/bin/rhp-mcp.js +5 -1
  3. package/package.json +3 -3
  4. package/skill/SKILL.md +116 -26
  5. package/skill/recipes/area.html +6 -0
  6. package/skill/recipes/bar.html +9 -1
  7. package/skill/recipes/box-plot.html +9 -1
  8. package/skill/recipes/bubble.html +6 -0
  9. package/skill/recipes/bullet.html +6 -0
  10. package/skill/recipes/candlestick.html +9 -1
  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 +9 -1
  15. package/skill/recipes/dumbbell.html +6 -0
  16. package/skill/recipes/gantt.html +17 -5
  17. package/skill/recipes/grouped-bars.html +6 -0
  18. package/skill/recipes/heatmap.html +9 -1
  19. package/skill/recipes/histogram.html +9 -1
  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 +9 -1
  26. package/skill/recipes/race.html +6 -0
  27. package/skill/recipes/radial-bars.html +6 -0
  28. package/skill/recipes/scatter.html +23 -4
  29. package/skill/recipes/silhouettes.html +354 -0
  30. package/skill/recipes/slope.html +23 -4
  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 +9 -1
  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 +7 -2
  44. package/skill/references/looks.md +267 -0
  45. package/skill/references/performance.md +143 -0
  46. package/skill/references/pitfalls.md +52 -7
  47. package/src/check/README.md +33 -3
  48. package/src/check/browser.js +11 -5
  49. package/src/check/findings.js +15 -1
  50. package/src/check/index.js +182 -31
  51. package/src/check/inpage.js +82 -18
  52. package/src/check/interact.js +1 -19
  53. package/src/check/network.js +168 -0
  54. package/src/check/png.js +18 -0
  55. package/src/check/report.js +4 -1
  56. package/src/check/selection.js +123 -0
  57. package/src/guard/rhp.js +4 -0
  58. package/src/guard/rules.js +1 -1
  59. package/src/server.js +9 -8
@@ -0,0 +1,168 @@
1
+ // What a checked page may reach on the network. The page runs code the checker did not write, so its requests to the
2
+ // web are made here, in Node, under three rules:
3
+ // - only public addresses: never loopback, private, link-local, shared (CGNAT), multicast or reserved ones, checked
4
+ // on the address actually connected to (so neither a redirect nor a DNS answer that changes can reach one)
5
+ // - only requests that read: GET, HEAD and OPTIONS (a chart reads fonts, scripts, styles, images and data while it
6
+ // renders; it has no reason to send anything)
7
+ // - the same 5 seconds and 5 redirects as before
8
+ import dns from "dns";
9
+ import net from "net";
10
+ import http from "http";
11
+ import https from "https";
12
+ import zlib from "zlib";
13
+
14
+ const MAX_BODY = 64 * 1024 * 1024;
15
+
16
+ const BLOCKED = new net.BlockList();
17
+ for (const [address, prefix] of [
18
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
19
+ ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15],
20
+ ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
21
+ ]) BLOCKED.addSubnet(address, prefix, "ipv4");
22
+ for (const [address, prefix] of [
23
+ ["::", 128], ["::1", 128], ["64:ff9b::", 96], ["100::", 64], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
24
+ ]) BLOCKED.addSubnet(address, prefix, "ipv6");
25
+
26
+ // Whether an IP address is one a page may not reach (an IPv4 address written as IPv6 is judged as IPv4)
27
+ export function isPrivate(ip) {
28
+
29
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
30
+ if (mapped) return BLOCKED.check(mapped[1], "ipv4");
31
+ // URL canonicalizes IPv6 spellings, including dotted and expanded IPv4-mapped addresses.
32
+ if (net.isIP(ip) === 6) {
33
+ const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
34
+ const hex = /^::ffff:([\da-f]+):([\da-f]+)$/i.exec(canonical);
35
+ if (hex) {
36
+ const a = parseInt(hex[1], 16), b = parseInt(hex[2], 16);
37
+ return BLOCKED.check(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`, "ipv4");
38
+ }
39
+ }
40
+ const family = net.isIP(ip);
41
+ if (!family) return true;
42
+
43
+ return BLOCKED.check(ip, family === 4 ? "ipv4" : "ipv6");
44
+ }
45
+
46
+ const blocked = (why) => Object.assign(new Error(why), { code: "EBLOCKED" });
47
+
48
+ // dns.lookup that refuses names answering with an address a page may not reach. Node calls it for every connection,
49
+ // so the address checked is the one connected to.
50
+ function publicLookup(hostname, options, callback) {
51
+
52
+ dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
53
+ if (err) return callback(err);
54
+ const bad = addresses.find((a) => isPrivate(a.address));
55
+ if (bad) return callback(blocked(`${hostname} is at ${bad.address}, which is not a public address`));
56
+ if (options.all) return callback(null, addresses);
57
+ return callback(null, addresses[0].address, addresses[0].family);
58
+ });
59
+ }
60
+
61
+ export const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
62
+
63
+ // Request headers that belong to one connection, or that would have the server compress (the body is handed to the
64
+ // browser as it is, so it is asked for plain)
65
+ const DROP_REQUEST = new Set(["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "accept-encoding", "content-length"]);
66
+ const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length"]);
67
+
68
+ function once(url, method, headers, deadline) {
69
+
70
+ return new Promise((resolve) => {
71
+ const u = new URL(url);
72
+ const host = u.hostname.replace(/^\[|\]$/g, "");
73
+ if (net.isIP(host) && isPrivate(host)) return resolve({ error: `${host} is not a public address`, blocked: true });
74
+ const left = deadline - Date.now();
75
+ if (left <= 0) return resolve({ error: "Timeout" });
76
+ const sendHeaders = { "accept-encoding": "identity" };
77
+ for (const [k, v] of Object.entries(headers)) if (!DROP_REQUEST.has(k.toLowerCase())) sendHeaders[k] = v;
78
+ let timer;
79
+ let done = false;
80
+ const finish = (result) => {
81
+ if (done) return;
82
+ done = true;
83
+ clearTimeout(timer);
84
+ resolve(result);
85
+ };
86
+ const req = (u.protocol === "https:" ? https : http).request(u, { method, headers: sendHeaders, lookup: publicLookup }, (res) => {
87
+ const chunks = [];
88
+ let size = 0;
89
+ res.on("data", (c) => {
90
+ size += c.length;
91
+ if (size > MAX_BODY) req.destroy(new Error("Response exceeds 64 MB"));
92
+ else chunks.push(c);
93
+ });
94
+ res.on("end", () => {
95
+ if (done) return;
96
+ try {
97
+ let body = Buffer.concat(chunks);
98
+ // Some servers compress even when identity was requested.
99
+ const encoding = String(res.headers["content-encoding"] ?? "identity").toLowerCase();
100
+ const decode = { gzip: zlib.gunzipSync, deflate: zlib.inflateSync, br: zlib.brotliDecompressSync }[encoding];
101
+ if (decode) body = decode(body, { maxOutputLength: MAX_BODY });
102
+ else if (encoding !== "identity") throw new Error(`Unsupported content encoding: ${encoding}`);
103
+ finish({ status: res.statusCode, headers: res.headers, body });
104
+ } catch (e) {
105
+ finish({ error: e.message });
106
+ }
107
+ });
108
+ res.on("error", (e) => finish({ error: e.message }));
109
+ });
110
+ // One wall-clock deadline covers DNS, headers, the body, and every redirect.
111
+ timer = setTimeout(() => req.destroy(new Error("Timeout while fetching chart asset")), left);
112
+ req.on("error", (e) => finish({ error: e.message, blocked: e.code === "EBLOCKED" }));
113
+ req.end();
114
+ });
115
+ }
116
+
117
+ // Fetches a URL for the page: { status, headers, body }, or { error, blocked } when it is not allowed or fails
118
+ export async function fetchPublic(url, { method = "GET", headers = {}, timeout = 5000, maxRedirects = 5 } = {}) {
119
+
120
+ method = method.toUpperCase();
121
+ if (!READ_METHODS.has(method)) return { error: `${method} requests are not sent: the check only reads from the web`, blocked: true };
122
+
123
+ const deadline = Date.now() + timeout;
124
+ let current = url;
125
+
126
+ for (let hop = 0; ; hop++) {
127
+ if (!/^https?:$/.test(new URL(current).protocol)) return { error: `${new URL(current).protocol} is not fetched`, blocked: true };
128
+ const got = await once(current, method, headers, deadline);
129
+ if (got.error) return got;
130
+ const location = got.headers.location;
131
+ if (got.status >= 300 && got.status < 400 && location && got.status !== 304) {
132
+ if (hop >= maxRedirects) return { error: `More than ${maxRedirects} redirects` };
133
+ const next = new URL(location, current);
134
+ if (next.origin !== new URL(current).origin) {
135
+ headers = Object.fromEntries(Object.entries(headers).filter(([k]) => !["authorization", "cookie", "proxy-authorization"].includes(k.toLowerCase())));
136
+ }
137
+ current = next.href;
138
+ continue;
139
+ }
140
+ const out = {};
141
+ for (const [k, v] of Object.entries(got.headers)) if (!DROP_RESPONSE.has(k)) out[k] = Array.isArray(v) ? v.join(", ") : String(v);
142
+ return { status: got.status, headers: out, body: got.body };
143
+ }
144
+ }
145
+
146
+ // A final network fence for renderer traffic not handled by Playwright's page routes (for example worker sockets).
147
+ // Known HTTP asset requests are fulfilled by the Node fetcher before reaching this proxy.
148
+ // Nothing accepted here is forwarded, and Chromium must proxy loopback traffic too.
149
+ export async function denyBrowserNetwork(onBlocked) {
150
+ const deny = (request, socket) => {
151
+ onBlocked(request.url);
152
+ socket.destroy();
153
+ };
154
+ const server = http.createServer((request, response) => {
155
+ onBlocked(request.url);
156
+ response.writeHead(403).end();
157
+ });
158
+ server.on("connect", deny);
159
+ server.on("upgrade", deny);
160
+ await new Promise((resolve, reject) => {
161
+ server.once("error", reject);
162
+ server.listen(0, "127.0.0.1", resolve);
163
+ });
164
+ return {
165
+ proxy: { server: `http://127.0.0.1:${server.address().port}`, bypass: "<-loopback>" },
166
+ close: () => new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); }),
167
+ };
168
+ }
package/src/check/png.js CHANGED
@@ -96,3 +96,21 @@ export function pixelsIn(image, { left, top, right, bottom }) {
96
96
 
97
97
  return out;
98
98
  }
99
+
100
+ // How many pixels of two screenshots of one box differ enough to see (their channels by more than 24 in all: a
101
+ // tint of 6% of the ink over the paper shows, drawing one page twice changes nothing)
102
+ export function seenPixels(a, b) {
103
+
104
+ if (a.equals(b)) return 0;
105
+
106
+ const x = decode(a);
107
+ const y = decode(b);
108
+ if (x.width !== y.width || x.height !== y.height) return Infinity;
109
+
110
+ let n = 0;
111
+ for (let i = 0; i < x.data.length; i += 4) {
112
+ if (Math.abs(x.data[i] - y.data[i]) + Math.abs(x.data[i + 1] - y.data[i + 1]) + Math.abs(x.data[i + 2] - y.data[i + 2]) > 24) n++;
113
+ }
114
+
115
+ return n;
116
+ }
@@ -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
  }
@@ -0,0 +1,123 @@
1
+ // A conditional audit of an existing click selection with a temporary hover override.
2
+ // No click selection or no temporary hover means there is no interaction policy to repair here.
3
+ // Run on a disposable page: clicks must not change the starting state of the normal interaction pass.
4
+ import { seenPixels } from "./png.js";
5
+
6
+ const SAME = 20;
7
+ const same = (a, b) => seenPixels(a, b) < SAME;
8
+ const name = (s) => `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
9
+
10
+ // Safe space inside this chart's poster, outside every chart and every control.
11
+ // Pointing at text is safe; clicking a link, a legend button or another chart is not a reset gesture.
12
+ function beside(target) {
13
+ const slat = window.__rhpTargets[target];
14
+ const poster = slat?.closest(".poster, figure");
15
+ if (!poster) return null;
16
+ const busy = "a, button, input, select, textarea, label, summary, [role], [tabindex], [contenteditable]";
17
+ const box = poster.getBoundingClientRect();
18
+ let margin = null;
19
+ for (let y = Math.max(12, box.top + 6); y < Math.min(innerHeight - 12, box.bottom - 6); y += 16) {
20
+ for (let x = Math.max(12, box.left + 6); x < Math.min(innerWidth - 12, box.right - 6); x += 16) {
21
+ const el = document.elementFromPoint(x, y);
22
+ if (!el || !poster.contains(el) || el.closest(".rhp-chart") || el.closest(busy)) continue;
23
+ const text = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
24
+ const point = { x, y, what: text ? `"${el.textContent.trim().replace(/\s+/g, " ").slice(0, 40)}"` : "the poster's margin" };
25
+ if (text) return point;
26
+ margin ??= point;
27
+ }
28
+ }
29
+ return margin;
30
+ }
31
+
32
+ export async function selectionBehavior(page, { events }) {
33
+ const findings = [];
34
+ const audit = { findings, tested: null };
35
+ const start = events.length;
36
+ let doing = "checking existing selection and hover";
37
+ const settle = async () => {
38
+ const state = await page.evaluate(() => window.__rhpProbe.settle(1000));
39
+ if (!state.rest) throw new Error("The page is moving");
40
+ };
41
+ const look = async () => {
42
+ await settle();
43
+ return page.screenshot({ animations: "disabled", scale: "css" });
44
+ };
45
+ const away = async () => {
46
+ await page.mouse.move(-1, -1);
47
+ await settle();
48
+ };
49
+ const pointAt = (s) => page.evaluate((n) => window.__rhpProbe.pointAt(n, 0.5, false), s.target);
50
+ try {
51
+ // Use reachable marks, including marks inside a slat, from the main Plot.
52
+ // Keep the window still throughout the sequence so scrolling and sticky readouts cannot mimic a state change.
53
+ const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets(12));
54
+ if (slats.length < 2) return audit;
55
+ await page.evaluate((n) => window.__rhpProbe.pointAt(n), slats[0].target);
56
+ await away();
57
+ const position = await page.evaluate(() => [scrollX, scrollY]);
58
+ const candidates = [];
59
+ for (const s of slats) {
60
+ const point = await pointAt(s);
61
+ if (point.covered) continue;
62
+ const safe = await page.evaluate(({ x, y }) => !document.elementFromPoint(x, y)?.closest("a, button, input, select, textarea, summary, [role=button], [role=slider], [contenteditable]"), point);
63
+ if (safe) candidates.push(s);
64
+ if (candidates.length === 3) break;
65
+ }
66
+ if (candidates.length < 2) return audit;
67
+ const off = await page.evaluate(beside, candidates[0].target);
68
+ if (!off) return audit;
69
+ const rest = await look();
70
+ if (!same(rest, await look())) return audit;
71
+ // At most three candidates, so an initial meaningful default on the first mark does not hide click selection.
72
+ for (const chosen of candidates) {
73
+ doing = `click ${name(chosen)}`;
74
+ const at = await pointAt(chosen);
75
+ if (at.covered) continue;
76
+ await page.mouse.click(at.x, at.y);
77
+ await away();
78
+ const pinned = await look();
79
+ if (same(rest, pinned)) continue;
80
+ for (const preview of candidates.filter((s) => s !== chosen)) {
81
+ doing = `hover ${name(preview)} after clicking ${name(chosen)}`;
82
+ const over = await pointAt(preview);
83
+ if (over.covered) continue;
84
+ await page.mouse.move(over.x, over.y);
85
+ const hovered = await look();
86
+ if (same(pinned, hovered)) continue;
87
+ await page.mouse.move(off.x, off.y);
88
+ const offChart = await look();
89
+ await away();
90
+ const restored = await look();
91
+ // Prove that click pins a state and hover temporarily overrides it.
92
+ // A hover-only chart, a click-only chart, or a scrubber that retains its last point does not qualify.
93
+ if (!same(pinned, restored)) continue;
94
+ const here = await page.evaluate(() => [scrollX, scrollY]);
95
+ if (position.some((v, i) => v !== here[i]) || !same(restored, await look())) return audit;
96
+ // Compare with the selected state at the same outside-chart pointer position (including CSS :hover).
97
+ await page.mouse.move(off.x, off.y);
98
+ const selected = await look();
99
+ const hoverBroken = !same(selected, offChart);
100
+ // This click is inside the poster, outside every chart and control.
101
+ doing = `click beside the chart after selecting ${name(chosen)}`;
102
+ await page.mouse.click(off.x, off.y);
103
+ await away();
104
+ const reset = await look();
105
+ const resetBroken = !same(rest, reset);
106
+ const end = await page.evaluate(() => [scrollX, scrollY]);
107
+ if (position.some((v, i) => v !== end[i]) || !same(reset, await look())) return audit;
108
+ audit.tested = { selected: name(chosen), preview: name(preview), hoverRestoresSelection: !hoverBroken, clickRestoresInitial: !resetBroken };
109
+ if (hoverBroken) findings.push({ code: "selection-hover-stays", target: name(preview), selected: name(chosen), what: off.what });
110
+ if (resetBroken) findings.push({ code: "selection-reset", target: name(chosen), what: off.what });
111
+ return audit;
112
+ }
113
+ }
114
+ } catch {
115
+ // Moving pages, covered marks and navigation cannot establish this interaction contract.
116
+ // The normal interaction pass reports failures to reach marks and the page's exceptions.
117
+ } finally {
118
+ for (const e of events.slice(start)) {
119
+ if ((e.kind === "pageerror" || e.kind === "console-error") && !e.during) e.during = doing;
120
+ }
121
+ }
122
+ return audit;
123
+ }
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
  }));