@bezda/rhp-mcp 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/skill/SKILL.md +113 -22
- package/skill/recipes/area.html +129 -33
- package/skill/recipes/bar.html +129 -33
- package/skill/recipes/box-plot.html +129 -33
- package/skill/recipes/bubble.html +129 -33
- package/skill/recipes/bullet.html +129 -33
- package/skill/recipes/candlestick.html +129 -33
- package/skill/recipes/column.html +129 -33
- package/skill/recipes/dial.html +129 -33
- package/skill/recipes/diverging-bars.html +129 -33
- package/skill/recipes/donut.html +129 -33
- package/skill/recipes/dumbbell.html +129 -33
- package/skill/recipes/gantt.html +129 -33
- package/skill/recipes/grouped-bars.html +129 -33
- package/skill/recipes/heatmap.html +129 -33
- package/skill/recipes/histogram.html +129 -33
- package/skill/recipes/line.html +129 -33
- package/skill/recipes/live.html +129 -33
- package/skill/recipes/log-line.html +129 -33
- package/skill/recipes/lollipop.html +129 -33
- package/skill/recipes/multi-line.html +129 -33
- package/skill/recipes/pyramid.html +129 -33
- package/skill/recipes/race.html +129 -33
- package/skill/recipes/radial-bars.html +129 -33
- package/skill/recipes/scatter.html +129 -33
- package/skill/recipes/silhouettes.html +129 -33
- package/skill/recipes/slope.html +129 -33
- package/skill/recipes/sparklines.html +129 -33
- package/skill/recipes/stacked-100.html +129 -33
- package/skill/recipes/stacked-bars.html +129 -33
- package/skill/recipes/strip.html +129 -33
- package/skill/recipes/tape.html +578 -0
- package/skill/recipes/unit-stack.html +129 -33
- package/skill/recipes/violin.html +129 -33
- package/skill/recipes/waffle.html +129 -33
- package/skill/recipes/waterfall.html +129 -33
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/download.md +135 -35
- package/skill/references/forms.md +3 -2
- package/skill/references/interaction.md +8 -4
- package/skill/references/pitfalls.md +3 -2
- package/skill/scripts/add-download.mjs +33 -0
- package/src/check/README.md +29 -5
- package/src/check/build.js +4 -3
- package/src/check/colors.js +4 -2
- package/src/check/findings.js +99 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +292 -5
- package/src/check/interact.js +22 -4
- package/src/check/png.js +6 -1
- package/src/check/report.js +9 -2
- package/src/check/selection-controls.js +105 -0
- package/src/check/selection.js +54 -12
- package/src/check/source.js +21 -0
- package/src/server.js +4 -4
package/src/check/selection.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Conditional audits of existing selection: a control selecting chart data, or click selection with temporary hover.
|
|
2
|
+
// The observed behavior establishes which interaction policy applies; unrelated controls do not change it.
|
|
3
3
|
// Run on a disposable page: clicks must not change the starting state of the normal interaction pass.
|
|
4
4
|
import { seenPixels } from "./png.js";
|
|
5
|
+
import { controlSelection } from "./selection-controls.js";
|
|
5
6
|
|
|
6
7
|
const SAME = 20;
|
|
7
8
|
const same = (a, b) => seenPixels(a, b) < SAME;
|
|
@@ -40,7 +41,22 @@ export async function selectionBehavior(page, { events }) {
|
|
|
40
41
|
};
|
|
41
42
|
const look = async () => {
|
|
42
43
|
await settle();
|
|
43
|
-
|
|
44
|
+
// Decorative canvas pixels have their own clock, unrelated to chart selection.
|
|
45
|
+
// Hide only inert backgrounds behind the content, never a chart or its marks.
|
|
46
|
+
const backgrounds = page.locator('canvas[aria-hidden="true"]');
|
|
47
|
+
await backgrounds.evaluateAll((canvases) => {
|
|
48
|
+
for (const canvas of canvases) {
|
|
49
|
+
const css = getComputedStyle(canvas);
|
|
50
|
+
if (!canvas.closest(".rhp-chart") && Number(css.zIndex) < 0 && css.pointerEvents === "none") {
|
|
51
|
+
canvas.setAttribute("data-rhp-selection-background", "");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
return await page.screenshot({ animations: "disabled", scale: "css", style: "canvas[data-rhp-selection-background] { visibility: hidden !important; }" });
|
|
57
|
+
} finally {
|
|
58
|
+
await backgrounds.evaluateAll((canvases) => canvases.forEach((canvas) => canvas.removeAttribute("data-rhp-selection-background")));
|
|
59
|
+
}
|
|
44
60
|
};
|
|
45
61
|
const away = async () => {
|
|
46
62
|
await page.mouse.move(-1, -1);
|
|
@@ -51,10 +67,32 @@ export async function selectionBehavior(page, { events }) {
|
|
|
51
67
|
// Use reachable marks, including marks inside a slat, from the main Plot.
|
|
52
68
|
// Keep the window still throughout the sequence so scrolling and sticky readouts cannot mimic a state change.
|
|
53
69
|
const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets(12));
|
|
54
|
-
if (slats.length
|
|
70
|
+
if (!slats.length) return audit;
|
|
55
71
|
await page.evaluate((n) => window.__rhpProbe.pointAt(n), slats[0].target);
|
|
56
72
|
await away();
|
|
57
73
|
const position = await page.evaluate(() => [scrollX, scrollY]);
|
|
74
|
+
const off = await page.evaluate(beside, slats[0].target);
|
|
75
|
+
if (!off) return audit;
|
|
76
|
+
doing = "checking selection changed by a control";
|
|
77
|
+
const controlled = await controlSelection(page, { slats, off, look, away, settle });
|
|
78
|
+
findings.push(...controlled.findings);
|
|
79
|
+
if (controlled.tested.length) audit.tested = { controls: controlled.tested };
|
|
80
|
+
// The control audit leaves focus on the last control it tried. Its focus ring is not the chart's state,
|
|
81
|
+
// and a click beside the chart takes it away, so the reset would differ from the rest by that ring alone.
|
|
82
|
+
await page.evaluate(() => document.activeElement?.blur?.());
|
|
83
|
+
// The reset is the chart's state and its readout, not the controls' own look: a control keeps a focus ring, a hover
|
|
84
|
+
// or a pressed state of its own after the clicks, so the reset leaves out the controls' boxes (as the control audit
|
|
85
|
+
// does), with room for a ring around them
|
|
86
|
+
const controls = await page.evaluate((target) => {
|
|
87
|
+
const poster = window.__rhpTargets[target]?.closest(".poster, figure") ?? document.body;
|
|
88
|
+
return [...poster.querySelectorAll('button, input, select, textarea, summary, [role="button"], [role="slider"], [role="switch"], [role="tab"]')]
|
|
89
|
+
.filter((el) => !el.closest(".rhp-chart"))
|
|
90
|
+
.map((el) => el.getBoundingClientRect())
|
|
91
|
+
.filter((r) => r.width > 0 && r.height > 0)
|
|
92
|
+
.map((r) => ({ left: r.left - 8, right: r.right + 8, top: r.top - 8, bottom: r.bottom + 8 }));
|
|
93
|
+
}, slats[0].target);
|
|
94
|
+
const quiet = (a, b) => seenPixels(a, b, controls) < SAME;
|
|
95
|
+
if (slats.length < 2) return audit;
|
|
58
96
|
const candidates = [];
|
|
59
97
|
for (const s of slats) {
|
|
60
98
|
const point = await pointAt(s);
|
|
@@ -64,8 +102,6 @@ export async function selectionBehavior(page, { events }) {
|
|
|
64
102
|
if (candidates.length === 3) break;
|
|
65
103
|
}
|
|
66
104
|
if (candidates.length < 2) return audit;
|
|
67
|
-
const off = await page.evaluate(beside, candidates[0].target);
|
|
68
|
-
if (!off) return audit;
|
|
69
105
|
const rest = await look();
|
|
70
106
|
if (!same(rest, await look())) return audit;
|
|
71
107
|
// At most three candidates, so an initial meaningful default on the first mark does not hide click selection.
|
|
@@ -98,14 +134,20 @@ export async function selectionBehavior(page, { events }) {
|
|
|
98
134
|
const selected = await look();
|
|
99
135
|
const hoverBroken = !same(selected, offChart);
|
|
100
136
|
// This click is inside the poster, outside every chart and control.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
let reset = selected;
|
|
138
|
+
let resetBroken = false;
|
|
139
|
+
// A proven selection control establishes a persistent choice, rather than the page's initial default.
|
|
140
|
+
// The control audit already checks that a background click leaves that choice intact.
|
|
141
|
+
if (!controlled.tested.length) {
|
|
142
|
+
doing = `click beside the chart after selecting ${name(chosen)}`;
|
|
143
|
+
await page.mouse.click(off.x, off.y);
|
|
144
|
+
await away();
|
|
145
|
+
reset = await look();
|
|
146
|
+
resetBroken = !quiet(rest, reset);
|
|
147
|
+
}
|
|
106
148
|
const end = await page.evaluate(() => [scrollX, scrollY]);
|
|
107
149
|
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 };
|
|
150
|
+
audit.tested = { ...audit.tested, selected: name(chosen), preview: name(preview), hoverRestoresSelection: !hoverBroken, ...(!controlled.tested.length ? { clickRestoresInitial: !resetBroken } : {}) };
|
|
109
151
|
if (hoverBroken) findings.push({ code: "selection-hover-stays", target: name(preview), selected: name(chosen), what: off.what });
|
|
110
152
|
if (resetBroken) findings.push({ code: "selection-reset", target: name(chosen), what: off.what });
|
|
111
153
|
return audit;
|
package/src/check/source.js
CHANGED
|
@@ -74,6 +74,27 @@ function walk(node, visit) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Literal module dependencies, excluding strings, comments and object properties that look like imports.
|
|
78
|
+
export function moduleSpecifiers(code, file) {
|
|
79
|
+
|
|
80
|
+
let ast;
|
|
81
|
+
try {
|
|
82
|
+
ast = parseSync(code, { sourceType: "module", configFile: false, babelrc: false, filename: file });
|
|
83
|
+
} catch {
|
|
84
|
+
return []; // The caller reports syntax errors separately.
|
|
85
|
+
}
|
|
86
|
+
const imports = [];
|
|
87
|
+
walk(ast.program, (node) => {
|
|
88
|
+
const source = /^(ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration|ImportExpression)$/.test(node.type)
|
|
89
|
+
? node.source
|
|
90
|
+
: node.type === "CallExpression" && node.callee.type === "Import" ? node.arguments[0] : null;
|
|
91
|
+
const value = source?.type === "StringLiteral" ? source.value
|
|
92
|
+
: source?.type === "TemplateLiteral" && source.expressions.length === 0 ? source.quasis[0].value.cooked : null;
|
|
93
|
+
if (value != null) imports.push({ source: value, line: node.loc.start.line });
|
|
94
|
+
});
|
|
95
|
+
return imports;
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
const isSpace = (c) => c === " " || c === "\n" || c === "\t" || c === "\r" || c === "\f";
|
|
78
99
|
|
|
79
100
|
// The attributes in an html template, in order: { tag, name, value, at } with tag { component, expr, name }, value
|
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. Take each chart's technique from the closest recipe (rhp_recipe) and design the chart for its own subject
|
|
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. Choose interaction behavior and presentation for the reader's task and the intended design; familiar patterns are welcome when they fit. 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 });
|
|
@@ -185,7 +185,7 @@ 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 (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (
|
|
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 (choosing behavior and presentation for the reader's task, with implementation examples), download (the fit-to-screen and PNG download icons for standalone poster pages), 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
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"'),
|
|
@@ -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 chart page
|
|
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 demonstrating composition, state and event handling, accessibility and responsive techniques. Choose the chart\'s interaction behavior and presentation for the reader\'s task and intended design. Familiar patterns are welcome when they fit; the recipe\'s controls, feedback, layout and transitions are choices to reconsider.',
|
|
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
|
},
|
|
@@ -239,7 +239,7 @@ server.registerPrompt("chart", {
|
|
|
239
239
|
role: "user",
|
|
240
240
|
content: {
|
|
241
241
|
type: "text",
|
|
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
|
+
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. Choose interaction behavior and presentation for the reader's task and intended design, keeping familiar patterns when they fit. 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.`,
|
|
243
243
|
},
|
|
244
244
|
}],
|
|
245
245
|
}));
|