@bezda/rhp-mcp 0.0.0-stage → 0.1.0

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/LICENSE +21 -0
  2. package/README.md +221 -2
  3. package/bin/rhp-mcp.js +125 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +363 -0
  6. package/skill/recipes/area.html +150 -0
  7. package/skill/recipes/bar.html +139 -0
  8. package/skill/recipes/box-plot.html +158 -0
  9. package/skill/recipes/bubble.html +182 -0
  10. package/skill/recipes/bullet.html +132 -0
  11. package/skill/recipes/candlestick.html +173 -0
  12. package/skill/recipes/column.html +125 -0
  13. package/skill/recipes/diverging-bars.html +144 -0
  14. package/skill/recipes/donut.html +225 -0
  15. package/skill/recipes/dumbbell.html +131 -0
  16. package/skill/recipes/gantt.html +186 -0
  17. package/skill/recipes/grouped-bars.html +136 -0
  18. package/skill/recipes/heatmap.html +177 -0
  19. package/skill/recipes/histogram.html +193 -0
  20. package/skill/recipes/line.html +164 -0
  21. package/skill/recipes/live.html +249 -0
  22. package/skill/recipes/lollipop.html +146 -0
  23. package/skill/recipes/multi-line.html +223 -0
  24. package/skill/recipes/pyramid.html +156 -0
  25. package/skill/recipes/race.html +272 -0
  26. package/skill/recipes/radial-bars.html +151 -0
  27. package/skill/recipes/scatter.html +193 -0
  28. package/skill/recipes/slope.html +193 -0
  29. package/skill/recipes/sparklines.html +169 -0
  30. package/skill/recipes/stacked-100.html +152 -0
  31. package/skill/recipes/stacked-bars.html +144 -0
  32. package/skill/recipes/strip.html +215 -0
  33. package/skill/recipes/violin.html +163 -0
  34. package/skill/recipes/waffle.html +159 -0
  35. package/skill/recipes/waterfall.html +121 -0
  36. package/skill/references/api.md +1556 -0
  37. package/skill/references/design.md +763 -0
  38. package/skill/references/environments.md +1570 -0
  39. package/skill/references/forms.md +160 -0
  40. package/skill/references/interaction.md +792 -0
  41. package/skill/references/pitfalls.md +157 -0
  42. package/src/check/README.md +163 -0
  43. package/src/check/browser.js +92 -0
  44. package/src/check/build.js +439 -0
  45. package/src/check/colors.js +324 -0
  46. package/src/check/findings.js +247 -0
  47. package/src/check/index.js +419 -0
  48. package/src/check/inpage.js +1268 -0
  49. package/src/check/interact.js +266 -0
  50. package/src/check/png.js +98 -0
  51. package/src/check/report.js +103 -0
  52. package/src/check/source.js +387 -0
  53. package/src/check/sourcemap.js +75 -0
  54. package/src/guard/note.js +30 -0
  55. package/src/guard/render.js +17 -0
  56. package/src/guard/rhp.js +608 -0
  57. package/src/guard/rules.js +125 -0
  58. package/src/server.js +265 -0
  59. package/src/skill.js +140 -0
@@ -0,0 +1,157 @@
1
+ # rhp pitfalls: symptoms, causes and fixes
2
+
3
+ Use this file when a chart does not look or behave as it should, or when the checker reports a finding you do not understand.
4
+ Find the symptom, then try its causes in order.
5
+ Every cause here was reproduced with rhp 2.0.1; the numbers in brackets point to the "Gotchas" list at the end of [api.md](api.md).
6
+
7
+ ## Contents
8
+
9
+ - Mistakes that come from other libraries
10
+ - Nothing draws, or the chart is empty
11
+ - Bars, dots or labels in the wrong place
12
+ - Text sticks out, is cut off or overlaps
13
+ - The chart is tiny, squashed or the wrong size
14
+ - Values never update, or the chart jumps
15
+ - A click, hover, tap or key does nothing, or the wrong thing
16
+ - The layout jumps when the reader interacts
17
+ - Styles or colors do not apply
18
+ - Console warnings rhp prints
19
+ - Mistakes in the work itself
20
+
21
+ ## Mistakes that come from other libraries
22
+
23
+ rhp is not configured with an options object, and none of these exist: `<BarChart data=...>`, `<Chart type="bar">`, `series: [{ data }]`, `new Chart(ctx, config)`, `xAxis`, `yAxis`, `tooltip: { ... }`, `legend: { ... }`.
24
+ A chart is a `Chart` holding `Plot`s, each Plot draws one slat per row of data, and the slat is a function you write from blocks.
25
+ Start from a recipe, never from what another library does.
26
+
27
+ - **rhp 1 code** (`react-html-plots`, Legend-State observables, `@bezda/rhp-core`, `BarPlot`, `useObservable`): a different, retired library. Use rhp 2 only, from `@bezda/rhp@2`.
28
+ - **React JSX around rhp components** (`<Bar to={d.sold} />` inside a React component): React does not render Solid components. In React, write the chart with the `html` template from `@bezda/rhp/standalone` and mount it into an element (environments.md, React).
29
+ - **`solid-js` imported in a React, Vue or plain page** next to `@bezda/rhp/standalone`: two copies of Solid, and reactivity silently stops between them. Import everything from the standalone module.
30
+ - **`useState` or `useEffect` inside an rhp chart component**: use `createSignal`, `createMemo`, `createEffect` and `onCleanup` from the same module as rhp.
31
+ - **A tooltip library or an SVG overlay**: rhp charts are HTML. Show a readout with a signal and `Show` inside the slat, or in the poster's dek (interaction.md).
32
+
33
+ ## Nothing draws, or the chart is empty
34
+
35
+ 1. **The module failed to load.** An error like "does not provide an export named createSelector": the standalone module has no `Dynamic`, `Portal`, `createResource` or `ErrorBoundary`, and rhp 2.0.1 also lacks `createSelector`, `createComputed`, `on`, `mergeProps`, `splitProps`, `Switch` and `Match`, which 2.0.2 adds [28]. Return the template from a function for `Dynamic`, `render()` into the other element for `Portal`, fetch in `onMount` for `createResource`, and compare in a function (`on=${(d) => on() === d.index}`) on any version.
36
+ 2. **The import map is missing or below the module script.** It must come before any `<script type="module">` and map `@bezda/rhp/standalone` to `https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js`.
37
+ 3. **`render` got the result instead of a function.** Write ``render(() => html`<${MyChart} />`, element)``.
38
+ 4. **The slat returns two elements, text, or nothing** [30]. Return one root element (`<div>`) holding the blocks.
39
+ 5. **The Plot is outside a Chart** [29], or the Plot got the bare function instead of what `slat()` returned (the slat then has no CSS or layout).
40
+ 6. **Data groups are empty** or the Plot has none of the names the slat reads: every slat reads `undefined` and draws at the start of the scale [16]. Check the names: `d.sold` needs a Plot prop `sold`; a list of objects goes in `rows`, not `data` [7].
41
+
42
+ ## Bars, dots or labels in the wrong place
43
+
44
+ 1. **The scale does not hold the data** [13]: bars are cut at the end, dots and ticks land outside the plot.
45
+ Build it from the data: `const s = nice(Math.min(0, ...values), Math.max(0, ...values))`, then `scale=${[s.min, s.max]} ticks=${s.ticks}`.
46
+ 2. **Negative values on a scale that starts at 0**: a value below the scale's min draws nothing; the scale above takes its min below 0.
47
+ A Bar whose `to` is below its `from` runs backward.
48
+ 3. **Area or Line points past the scale squeeze the whole outline** [14]: keep every x inside the scale; give `density()` a `domain` inside it.
49
+ 4. **A reversed or equal scale** (`[100, 0]`, `[5, 5]`) draws nothing [15]. For bars that run the other way, use negative values and print them with `format=${(v) => Math.abs(v)}`.
50
+ 5. **NaN, undefined or null** draw at the scale's start and print "NaN" [16]: clean or filter the data first.
51
+ 6. **Every slat got one item of a list meant for all of them**: an array given to a Plot is a column, item i for row i. To give every slat the same list (the series still shown, a set of picked names), pass a function of the row: `shown=${(d) => shown()}`.
52
+ 7. **Lists of different lengths** wrap around without an error [8]: the third slat of a 3-item list shown beside a 5-item list repeats the first values. Give every data group the same length.
53
+ 8. **A Dot is an oval**: its `size` was a number [17]. Give a length, `size="12px"`.
54
+ 9. **A vertical Plot over a horizontal chart draws 0px-tall columns**: in rhp 2.0.1, a second top-level Plot with `orientation="vertical"` in a horizontal Chart whose slat type has `room: "auto"` gets columns as wide as the chart and no height. Give the slats room in px, or put the Plot in a chart with `cross` (as the line recipe does).
55
+ 10. **A scatter or line chart puts everything on one line**: the Chart needs `cross=${[min, max]}`, the Plot needs `overlap=${true}`, and each Dot needs `cross=${() => d.y}`.
56
+
57
+ ## Text sticks out, is cut off or overlaps
58
+
59
+ 1. **Value labels past the bars stick out at the end**: `room.end` is too small, or a `room` you gave dropped the defaults [10]. Give every side you use, in px: `room: { start: 120, end: 56 }`.
60
+ 2. **Names are cut off or stick out at the start**: give `room.start` in px, or `"auto"` with the edge Labels as direct children of the slat's root [11]; cap long names with `max-width` and an ellipsis in the slat CSS, or let them wrap.
61
+ 3. **Names under a vertical chart end in "..."** [25]: they are as wide as their columns. Shorten them, let them wrap (`.name:vertical { white-space: normal; }`), or make the chart horizontal on phones.
62
+ 4. **Axis numbers stick out** [12]: the axis has fixed room, so keep `format` short ("1.2M", not "1,200,000 people").
63
+ 5. **Labels of neighboring slats overlap**: the slats are too thin for their text. Raise `thickness`, shorten the labels, show fewer of them (only the highlighted slat, or every other one), or turn the chart.
64
+ 6. **A label overlaps its own bar's end**: give the slat CSS `--rhp-label-gap` (the space between a Label and its value).
65
+ 7. **The value label of the longest bar runs into the next column or out of the chart**: leave headroom on the scale (build it from about 1.1 times the largest value with `nice()`), or give `room.end` its width.
66
+ 8. **Wide numbers on a cross axis stick out on the left**: the cross axis gets 42px for its numbers; numbers such as 30,000 need the slat type's `room.start` at about 48px.
67
+ 9. **A value Label over the last column of a vertical chart sticks out**: give `room.after` half the label's width.
68
+ 10. **Text overlaps on a phone only**: test at 390px. Use a media query or a container query on the page for the poster, and `:vertical` / `:horizontal` rules for the slat.
69
+ 11. **Two words run together in a readout** ("All regions12") [35]: Solid's html template drops a space that stands alone between two tags. Write it as `${" "}`.
70
+
71
+ ## The chart is tiny, squashed or the wrong size
72
+
73
+ 1. **It is 148px wide with an empty plot** [24]: it sits in a flex row, an `inline-block`, a float or a `fit-content` box. Give its wrapper `flex: 1; min-width: 0`, or a width.
74
+ 2. **A vertical chart is a flat strip**: it is 240px tall by default; set `height`.
75
+ 3. **A horizontal chart's slats are 32px thick and the chart is too tall or too short**: set `thickness` on the slat type, or `height` on the Chart to make slats share it.
76
+ 4. **The chart keeps a shape on every screen**: `aspect` on the Chart keeps width over height, and ignores `height` (rhp warns).
77
+
78
+ ## Values never update, or the chart jumps
79
+
80
+ 1. **`${d.sold}` was read once** [3]: wrap it, `${() => d.sold}`; never destructure `d`.
81
+ 2. **The Plot got a signal's value once**: pass the accessor (`sold=${sold}`) or a function (`sold=${() => data().sold}`), not `sold=${sold()}` at the top of a component that runs once.
82
+ 3. **The Chart is `static`** [33]: a static chart ignores signals other than its data. Remove `static` from interactive charts.
83
+ 4. **Slats swap data when rows are removed**: give the Plot a `key` (`key="city"`).
84
+ 5. **Blocks jump to new values instead of moving** [22]: a `transition` on a block replaces rhp's own. Put your transition on an element inside the block.
85
+ 6. **Numbers show long decimals while they move** [26]: in the JS version (`animate`) values are fractional mid-way; round what you print.
86
+ 7. **`animate` is on, but a value still jumps**: the JS version never moves a group given as a function of the row (`v=${(d) => ...}`). Give the changing numbers as a list (`v=${() => values()}`), a single value or a field of `rows`.
87
+ 8. **A race stops at each step, or a live feed's marks stop between readings**: to play back data you have, never step through it with a timer; move a playhead on every frame, give the Plot the values interpolated between the steps around it, and keep the JS version's default 150 ms, so the marks glide and Pause leaves them between steps (interaction.md section 10). For a live feed, whose next value is not known yet, ease linear into each new reading over a little more than one interval (`ease: "linear"`, 1.5 intervals); with ease-out or a shorter duration the marks rest between readings.
88
+ 9. **Marks trail behind data that changes rapidly or continuously** (a live feed, a slider or drag that drives the data, a play button, a bar chart race): in the default CSS version every new value restarts a CSS transition from wherever the mark is, so under steady change the marks always lag, and Safari can stall transitions that input events retarget. Give the Plot `animate` for the data groups that change (`animate=${["value"]}`, or `animate=${true}`), or the Chart when the scale moves with the data: rhp's JS clock carries each move into the next value. Measured with a slider changing the data every frame: the CSS version trailed the data by 10 points of 100, the JS version by 6.
89
+
90
+ ## A click, hover, tap or key does nothing, or the wrong thing
91
+
92
+ 1. **A handler on a component has no parameter** [2]: `onClick=${() => pick()}` runs while drawing and is never attached. Write `onClick=${(e) => pick()}`. This holds for blocks and the Poster; plain elements are fine, and the Chart takes no handler at all (next item).
93
+ 2. **The handler is on the Chart** [6]: the Chart drops handlers, `data-*` and `title`. Put them on the Poster or a wrapper element.
94
+ 3. **A bare boolean** [1]: `<${Plot} keyboard>` passes an empty string, which is false. Write `keyboard=${true}` (also `overlap`, `static`, `animate`, `mirror`, `fill`, `smooth`).
95
+ 4. **An overlay Plot covers the slats under it**: give it `style=${{ "pointer-events": "none" }}` (an object: a string is dropped on Chart and Plot [4]).
96
+ 5. **The row is lost**: put `data-row=${() => d.index}` on the slat's root and find it with `e.target.closest("[data-row]")` from one handler on the poster.
97
+ 6. **A scroll on a phone changes the pick, or the readout flickers between slats**: the pick runs on `pointerdown` or on a finger's `pointermove` (every scroll starts with both), or it clears when the pointer is on no slat.
98
+ Pick on `pointermove` only for a mouse or a pen (`e.pointerType !== "touch"`), on `click` for a tap or a click, and on `focusin` for the keyboard; leave the pick as it is on no slat, and clear it on `pointerleave` and `focusout` (interaction.md, section 2).
99
+ 7. **The keyboard cannot reach it**: add `keyboard=${true}` to the Plot whose slats are interactive, and handle `focusin` like `pointermove`.
100
+ 8. **A focus handler throws on some elements**: `e.target.closest("[data-row]")` can return null; check it before reading `dataset`.
101
+ 9. **The chart vanished for screen readers**: `role="slider"` makes everything inside it presentational, so it must never wrap the chart; put it on its own element.
102
+ 10. **A slider has no name for screen readers**: an `<output>` inside the `<label>` takes the label from the input, because an output is labelable too. Show the value in a `<span>` or `<b>` with `aria-hidden="true"` and give the input `aria-valuetext`.
103
+
104
+ ## The layout jumps when the reader interacts
105
+
106
+ The checker warns (`layout-jump`) when a control or the chart moves after one of its interactions; these are the usual causes. It tries only some controls, so try every one yourself and compare.
107
+
108
+ 1. **Text that changes with a switch moves the controls below it** (a headline or a dek per question): stack every version in one grid cell (a wrapper with `display: grid`, each version at `grid-area: 1 / 1`, `visibility: hidden` except the current one), so the cell is always as tall as the longest version.
109
+ 2. **A readout that wraps on a phone pushes the chart down**: give it a `min-height` for its longest text (`min-height: 2lh`).
110
+ 3. **Numbers that change width shift what follows them**: use `font-variant-numeric: tabular-nums`, and give a column of numbers a fixed room; in an "auto" end room, align them across slats with `justify-self: stretch` and `justify-content: flex-end` on the label.
111
+
112
+ ## Styles or colors do not apply
113
+
114
+ 1. **Page CSS aimed at bars, labels or the axis** [21]: it cannot change their color, background, font, border, radius, shadow, padding or transition, even with `!important`. Move those rules into the slat type's `css`.
115
+ 2. **`className`** [5]: on a block it replaces rhp's class and the block vanishes. Use `class`.
116
+ 3. **The chart ignores the page's font or colors** [20]: give them in the Chart's `theme` (`font`, `ink`, `muted`, `grid`, `series`, ...).
117
+ 4. **`var(--brand)` as a color warns** [20]: put the brand colors in the theme instead.
118
+ 5. **Rows 5 and 6 take the first series color** [18]: `series()` counts six colors; with a shorter theme list call `series(n)`.
119
+ 6. **A value label does not take its bar's color** [19]: set `--rhp-color` on the slat's root (and leave `color` off the Bar), or color the label itself.
120
+ 7. **The poster looks unstyled** [31]: the Poster brings no CSS. Style `.poster`, `figcaption`, `.kicker`, `.headline`, `.dek` and `.note` in the page.
121
+ 8. **Focus rings on slats do not show your style** [22]: rhp's core CSS resets `outline` on slat roots, so a page `:focus-visible` rule never reaches them. Put `.slat:focus-visible { ... }` in the slat type's `css`.
122
+ 9. **The poster's styles do not apply inside a framework component**: scoped component styles (Vue `scoped`, Svelte, Astro, Angular's emulated encapsulation) never reach elements rhp makes. Use a plain CSS file; environments.md section 14 has every framework's verified caveats.
123
+ 10. **Padding on the chart's element does nothing**: rhp's room owns it. Pad a wrapper instead.
124
+ 11. **A white label is unreadable on some segments**: a label needs 4.5:1 on each segment's color. Darkening a segment can break the color-blind check against its neighbor, so switch that label to the ink color instead.
125
+ 12. **A transition on the slat's root does nothing** [22]: rhp keeps the root's own transition (its slide). Transition an element inside a block instead.
126
+ 13. **A rule in the slat's CSS matches nothing**: the class is on a different element than you think. Classes you put on blocks go on the block's own element (`.rhp-bar.bar`); `:horizontal` and `:vertical` go on the slat or a block, not on an element inside a block.
127
+ 14. **A pinned readout or a menu over the chart is drawn under the bars** [36]: give it `z-index: 2` or more.
128
+
129
+ ## Console warnings rhp prints
130
+
131
+ Each is worth fixing [34]:
132
+
133
+ | Warning starts with | Fix |
134
+ |---|---|
135
+ | "a Bar is a slat's root here" (or Dot, Label...) | wrap the block in an element: ``(d) => html`<div><${Bar} .../></div>` `` |
136
+ | "an edge Label is inside another element" | make the edge Label a direct child of the slat's root, or give room in px |
137
+ | "aspect takes a number above 0" | `aspect=${16 / 9}` |
138
+ | "a Chart with an aspect takes its height from its width" | remove `height` |
139
+ | "a flat Chart with an aspect has rows with a thickness" | remove `thickness` from the slat type |
140
+ | "... reads a page variable; use a theme key" | put the color in the Chart's `theme` and use its key |
141
+ | "--rhp-radius takes one length" | one length, or `--rhp-start-radius` and `--rhp-end-radius` |
142
+ | "unknown ease" | a CSS name, four cubic-bezier numbers, or a function |
143
+ | "linkedCss() was called, but this page doesn't link" | link `@bezda/rhp/rhp.css`, or remove `linkedCss()` |
144
+
145
+ ## Mistakes in the work itself
146
+
147
+ These are not rhp errors, but they decide whether the user gets what they asked for.
148
+
149
+ - **Dropping or changing a request**: every explicit request in the brief is in the result, exactly (their colors, their title, their sort order, "no poster"). A warning is fixed without removing a requested feature.
150
+ - **Invented numbers presented as fact**: data you supplied names its source and year, or the note says "Illustrative data".
151
+ - **A series the user did not ask for**: what is left when their parts do not reach 100% is a quiet, unlabeled remainder explained in the note, not a new series with its own color and key entry; the brief lists it under Asked as a deviation.
152
+ - **A headline that is not true for the data**: recompute every number in the headline, the dek and the note from the final data ("four in ten" was false for 52%).
153
+ - **A real name on invented numbers**: never put a real country, city, company or person on illustrative data; use real figures with their source, or plain invented names ("Riverton", "a monsoon city", "Team A") and say "invented" in the note.
154
+ - **A magazine poster dropped into an app**: inside an existing app, use the app's fonts and colors (design.md).
155
+ - **Hover-only information**: everything a hover shows also shows on focus and on tap.
156
+ - **Not looking at the phone screenshot**: most layout problems appear only at 390px.
157
+ - **Random data in a deliverable**: use fixed numbers unless the user asked for live or generated data.
@@ -0,0 +1,163 @@
1
+ # The checker, for maintainers
2
+
3
+ `check()` (index.js) renders a chart in headless Chromium and reports what is wrong with it, as findings: `{ level, code, message, fix, widths }`.
4
+ The CLI (`rhp-mcp check`) and the MCP tool (`rhp_check`) print the same report.
5
+
6
+ ## How a check runs
7
+
8
+ 1. **Load and build** (build.js): an html page is read as it is, and its module scripts are parsed; a Solid, React or module file is bundled with esbuild.
9
+ A `.jsx` or `.tsx` file is React or Solid as the nearest package.json that names `react`, `solid-js` or `vite-plugin-solid` says (react without solid-js is React; solid-js or its Vite plugin without react is Solid), then as its own imports say: a React file with the automatic JSX runtime imports nothing from react.
10
+ 2. **Read the code** (source.js): the module code is parsed with Babel, and its html templates and imports are checked for what fails with no error.
11
+ 3. **Guard** (../guard/): `@bezda/rhp` and `@bezda/rhp/standalone` resolve to a wrapper that checks how each component is used, and records what slats read on `d`.
12
+ It never throws and never changes what is drawn (the `guard` test compares screenshots with and without it, pixel for pixel).
13
+ 4. **Render** at each width (1280 and 390 by default; 600px or less is a phone, drawn as a touch screen) with reduced motion asked for, and wait for fonts and for the page to come to rest.
14
+ With reduced motion rhp's moves end at once, and a page that moves on its own waits (an autoplay that honors the setting) or changes in steps (a live feed), so the screenshots show what was measured.
15
+ 5. **Probe** (inpage.js runs in the page; colors.js reads screenshots): layout, text, controls, rhp's CSS, colors.
16
+ The report's screenshot is taken first, before any probe hides or restyles anything; then the screenshots the colors are read from.
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
+ 6. **Interact** (interact.js), on a fresh page with motion on, as a reader gets it.
19
+ 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
+ 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.
21
+ At a phone's width, in a window 664px tall (what Safari shows of a page on a 390 by 844 iPhone): tap a mark of the first and the last slat, and note when the tap changed text the reader can see (wrote it, or showed it) but all of that text lies outside the window while the tapped slat is in it; a style the tap changed in view (the tapped bar lit) does not tell the reader what the text says.
22
+ 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
+ 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
+ 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.
26
+ 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
+ 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
+
29
+ ### Screenshots, and the page they move
30
+
31
+ 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.
32
+ 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
+
34
+ ## Levels
35
+
36
+ - **error**: the chart is broken, wrong or unreadable for someone: it throws, draws nothing, shows a wrong value, hides or overlaps text, or uses rhp in a way that does nothing.
37
+ - **warning**: the chart works, but worse than it should: under WCAG AA, a name that ends in an ellipsis, a block that jumps instead of moving, CSS that has no effect.
38
+ - **info**: a note that changes nothing by itself (a font that did not load, a remote file that could not be fetched).
39
+
40
+ Zero false positives is a hard rule: every recipe and every gallery example must check clean, except for problems someone has looked at in the screenshot and found real.
41
+ `ok` in the result is false when there is an error.
42
+
43
+ ## Codes
44
+
45
+ The tests read this table: every code in it must have a chart in `test/broken/` that reports it (except `no-browser`), and every code a check reports must be in it.
46
+
47
+ ### Loading the chart
48
+
49
+ | Code | Level | What triggers it | Fix it gives |
50
+ |---|---|---|---|
51
+ | `no-file` | error | The path doesn't exist, or no code was given. | Pass the path of the chart's file. |
52
+ | `syntax-error` | error | A module script or a file that doesn't parse (esbuild), or an import map that is not JSON. | Fix the code at the line given. |
53
+ | `missing-import` | error | In a bundled file, an import that doesn't resolve from the file's folder or from the checker's packages. | Install the package, or fix the path. |
54
+ | `missing-import-map` | error | A page's module script imports a bare name that no import map maps. | Add the import map with the standalone URL. |
55
+ | `plain-script-tag` | error | rhp loaded with a plain `<script src>`, although it is an ES module. | An import map and a `type="module"` script. |
56
+ | `cdn-version` | error | The page loads another major version of rhp from a CDN. | Load `@bezda/rhp@2` in the import map. |
57
+ | `no-browser` | error | No Chromium, Chrome or Edge to render in. | `npx @bezda/rhp-mcp install-browser`, or `RHP_CHECK_BROWSER`. |
58
+
59
+ ### The code (source.js, read before it runs)
60
+
61
+ | Code | Level | What triggers it | Fix it gives |
62
+ |---|---|---|---|
63
+ | `missing-export` | error | A name imported from `@bezda/rhp/standalone` that it doesn't export, so the module fails to load. | What to use instead (a function returning the template for `Dynamic`, `render()` into the other element for `Portal`...), or the closest export. |
64
+ | `two-solids` | error | `@bezda/rhp/standalone` and `solid-js` imported in the same code: two copies of Solid, and the signals of one are not tracked by the other. | Import everything from the standalone module. |
65
+ | `bare-boolean` | error | `overlap`, `static`, `keyboard`, `animate`, `mirror`, `smooth`, `fill` or `keyed` written bare on a component in an html template: it passes `""`, which is false. | `name=${true}`. |
66
+ | `handler-at-render` | error | An `on...` prop on a component in an html template given a function with no parameter (inline, or a function named in the file): it is read as a value, runs at render and is never attached. | Give the handler a parameter: `(e) => ...`. |
67
+ | `style-string` | error | `style="..."` (or a string) on a Chart or a Plot, which take only a style object. | `style=${{ "pointer-events": "none" }}`. |
68
+ | `class-name` | error | `className` in an html template: on a block it replaces rhp's class and the block is not drawn, elsewhere it sets nothing. | `class`. |
69
+
70
+ ### What the page throws or prints
71
+
72
+ | Code | Level | What triggers it | Fix it gives |
73
+ |---|---|---|---|
74
+ | `runtime-error` | error | The page threw while loading (with the place in the user's file, through the bundle's source map). | A fix for common messages (a name not defined or not imported, a property of undefined read...). |
75
+ | `console-error` | error | The page logged an error. | As for `runtime-error`. |
76
+ | `interaction-error` | error | A step of the interaction pass threw or logged an error. | As for `runtime-error`. |
77
+ | `rhp-warning` | error | rhp printed one of its `rhp:` warnings. | Do what the warning says. |
78
+ | `request-failed` | error | A file next to the chart (or in the bundle) failed to load. | Fix the path, or add the file. |
79
+ | `remote-failed` | info | A remote file (fonts, a CDN) could not be fetched; the check goes on without it. | Check the URL; offline, fonts fall back. |
80
+
81
+ ### How rhp is used (the guard)
82
+
83
+ | Code | Level | What triggers it | Fix it gives |
84
+ |---|---|---|---|
85
+ | `unknown-prop` | error | A prop a component doesn't have (HTML attributes, `data-*`, `aria-*`, handlers, `class`, `style` and `ref` pass on blocks), or a theme key or slat setting rhp doesn't have. | The prop it means (`Bar`'s value is `to`), the closest name, or the list. |
86
+ | `bad-prop` | error | A value of the wrong kind: a block's number that is a function or a string, a color that is not a color, an orientation, reorder, side or edge rhp doesn't have, rows that are not objects, a key that is no data group. A number given as a string is a warning. | The kind of value it takes, with an example. |
87
+ | `bad-scale` | error | A Chart's `scale` or `cross` that is not two finite numbers, or whose first number is not the smaller (no Bar length, no axis). | The two numbers in order, or a scale built with `nice()`. |
88
+ | `dot-size` | error | A Dot's `size` given as a number: a share of the slat's width and of its height, so an oval. | A length: `size="12px"`. |
89
+ | `missing-group` | error | A slat reads `d.x` and the Plot has no data group `x` (nor rows with it); or `sortBy` by a name that is no data group. | The closest data group, `rows=` when a group holds objects, or passing the group. |
90
+ | `plot-typo` | warning | A Plot prop that is a typo of a Plot setting, with a value of that setting's kind (`overlpa=${true}`): rhp takes it as a data group. | The setting it means. |
91
+ | `group-lengths` | warning | A Plot's data groups (lists of 3 or more, not palettes) have different lengths, and rhp repeats the shorter ones. | One item per row in every group. |
92
+ | `no-component` | error | A Solid, React or module file exports several components and no default one. | Export the chart as the default. |
93
+ | `nan-value` | error | A block's number is NaN; a block's `at` or `to` is given but null or undefined (a row with no value, drawn at the start of the scale); a `--rhp-*` value of a block or the Chart came out as NaN, undefined or Infinity; or a text in a chart prints NaN, undefined or Infinity. | Check the data the value is worked out from; leave rows with no value out, or draw the block in a `Show`. |
94
+ | `stale-read` | error | A slat read `d.x` once, while it was drawn and outside any function (`${d.x}` in an html template), and the interaction pass changed it: the slat still shows the old value. | `${() => d.x}`, and never destructure `d`. |
95
+ | `static-chart` | error | A static Chart or Plot whose per-row function (`on=${(d) => on() === d.index}`) changed during the interaction pass: static draws each row once, so the interaction changes nothing. | Take `static` off. |
96
+
97
+ ### Layout
98
+
99
+ | Code | Level | What triggers it | Fix it gives |
100
+ |---|---|---|---|
101
+ | `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
+ | `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. |
103
+ | `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
+ | `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
+ | `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. |
106
+ | `cramped` | warning | A horizontal chart whose slats are under 18px thick while most of them hold text, or a vertical chart at the default 240px whose labels in one slat overlap (lines of one label don't count). | A thickness of 24px or more; a height or an aspect. |
107
+ | `value-past-scale` | error | A value of a Bar, Dot, Tick, Place or Label (placed with `at`) outside the Chart's scale (or cross scale), whatever the block's size: a Bar is cut at the end of the scale (to nothing when all of it is past one end, so it draws nothing), a point is drawn outside the plot, a Label is kept at one end of the scale or drawn outside the plot. Left out: a block with no box (display: none), and one with a size that clipping hides (a slat that shows a window of its marks). A reversed or equal scale is `bad-scale`'s, so nothing is compared with it. | A scale that covers the data, built with `nice()`; for a Label, the value its mark shows. |
108
+ | `points-past-scale` | error | An Area's or a Line's points outside the scale: rhp squeezes the whole outline into it, so every point is drawn in the wrong place. | A scale that covers the points, or points cut to the scale (`density()` and `bins()` take a domain). |
109
+ | `sticks-out` | error | An element, or the glyphs of a text (axis numbers included), visible more than 1px outside its chart's box. | By what sticks out: room for names (`room: { start }` or `"auto"`), room at the end for values, thickness, shorter axis numbers. |
110
+ | `text-outside-poster` | error | Text more than 1px outside its `.poster`. | Let it wrap, or make it smaller at narrow widths. |
111
+ | `text-cut-off` | warning or error | Text partly hidden by an ancestor's overflow (not by a box that scrolls that way; text with no part shown is hidden on purpose, such as screen-reader text). With `text-overflow: ellipsis` a warning (the reader loses the rest of the name); clipped with no sign of it, an error. | Room for names, wrapping, a shorter label, or a horizontal chart on narrow screens (a vertical chart's names are as wide as their columns). |
112
+ | `text-overlap` | error | The glyphs of two texts in different blocks overlap by more than 1px both ways (neither inside the other; invisible text ignored). Texts that share their nearest block-level ancestor are lines of one heading, paragraph or label: a tight line-height (a headline set at .9) brings their glyph boxes together without the letters touching, so they are not reported. | Room or thickness, a smaller font, shorter text. |
113
+ | `ambiguous-label` | warning | In an overlap Plot on a cross axis (a scatter, a bubble chart), a Label a reader may take for another slat's name: its glyphs go more than 1px into another slat's Dot, or another slat's Dot is nearer the middle line of the text than the Label's own Dot is to the text. A Dot on the line of a text is read as what it names; one past a corner of the text, or above or below it, is a neighbor. | Each name beside its own mark, on the side away from its neighbors (a side or an offset per row, another on narrow screens), or a crowded name left to the readout. |
114
+ | `page-scrolls-sideways` | error | The page is wider than the window. | `max-width: 100%`, and no fixed width wider than a phone. |
115
+ | `tiny-text` | error | Text under 9px as drawn (transforms counted). | 9px at least, data labels 11px or more. |
116
+ | `font-not-loaded` | info | A web font the text asks for none of whose faces loaded, or a font named in a stack that the machine doesn't have. (A family split by unicode-range, as Google Fonts serves CJK ones, loads only the faces its text needs, so one face loaded is enough; `document.fonts.check()` says false there.) | Load it with a Google Fonts link, or drop it from the stack. |
117
+
118
+ ### rhp's CSS
119
+
120
+ | Code | Level | What triggers it | Fix it gives |
121
+ |---|---|---|---|
122
+ | `series-past-theme` | error | A block's color is `series-n` with no `--rhp-series-n` in its chart, so it falls back to `series-1`. | `series(n)` for a theme of n colors, or more theme colors. |
123
+ | `block-transition` | warning | A slat's CSS sets a transition on a block (or on an Area's or a Line's path) that drops rhp's own (left and width, bottom and height, a Cell's background-color, a path's d), so the block jumps to new values. Not in the JS version, not with `transition: none`, and not when the slat's CSS places the block itself (a radial layout). Read with motion on. | A transition on an element inside the block, or rhp's properties named too. |
124
+ | `page-css-ignored` | warning | A page rule aimed at a chart (it names an `.rhp-` class, or its subject has a class, an id or an attribute and it reaches only elements in charts) sets properties that rhp's own rules set on every element it reaches. rhp's are `!important` in its layers, so the page's change nothing. | The slat type's `css`, the block's `color`, the Chart's theme. |
125
+
126
+ ### Controls and interactions
127
+
128
+ | Code | Level | What triggers it | Fix it gives |
129
+ |---|---|---|---|
130
+ | `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. |
132
+ | `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
+ | `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
+
135
+ ### Colors (read from screenshots)
136
+
137
+ | Code | Level | What triggers it | Fix it gives |
138
+ |---|---|---|---|
139
+ | `low-contrast` | error or warning | Text against what is behind it (the median of a screenshot with the glyphs hidden). Under 3:1 (2:1 for large text: 24px, or 18.66px bold) an error; from there up to WCAG AA (4.5:1, 3:1 for large text) a warning. | The nearest color to the text, and to what is behind it, that reaches AA. |
140
+ | `faint-marks` | warning | A mark whose drawn pixels reach under 1.25:1 against the same pixels without the marks (a tenth of its pixels is enough, so an outline or a two-tone mark is seen), unless it, or a mark it lies in, has a border, an outline or a box-shadow that reaches 3:1 against the background. Tracks (a Bar across the whole scale) are left out. | A color that reaches 3:1, or an edge that does. |
141
+ | `colorblind` | warning | Two of 2 to 8 series colors, not told apart by labels in their slats (as the reader sees them: screen-reader text names nothing), under a CIEDE2000 difference of 10 where their marks touch (5 anywhere), for normal vision or simulated protanopia, deuteranopia or tritanopia. A see-through color (rgba, `color-mix` with `transparent`, a mark's opacity) is the color it makes over what is behind the mark, read from the screenshot with the marks hidden. | Colors that differ in lightness too, or names next to the marks. |
142
+
143
+ ## Adding a probe
144
+
145
+ 1. Find the problem in the page (inpage.js `measure()`, or interact.js for what an interaction does), in the screenshots (colors.js), in the code (source.js) or in the guard, and give it a code.
146
+ 2. Write its message and fix in findings.js (or where it is found): the message says what is wrong in the reader's terms, and the fix names the rhp knob that fixes it.
147
+ 3. Add a row above, and a chart in `test/broken/` broken in that one way, whose first lines say `expect: <code>` (or `code:level`).
148
+ 4. Run `npm test`: the broken chart must report exactly its code, and the gallery and the SKILL.md example must stay clean.
149
+
150
+ ## Tests
151
+
152
+ `node test/run.js` runs the checker's tests (`npm test` runs them after the MCP server's); `node test/run.js broken` (or `gallery`, `recipes`, `guard`, `skill`) runs one part.
153
+ GitHub Actions runs them on every pull request and push to master (the `checker` job in `.github/workflows/test.yml`), with rhp built from the commit; the gallery part needs the documentation checkout, so it is skipped there.
154
+
155
+ - **broken**: each chart in `test/broken/` reports exactly the codes it expects, and nothing else; a `clean*` chart (and `expect: none`) reports nothing at all, notes included.
156
+ A chart whose first lines also say `says: <text>` must have that line in its report (`clean.html` pins the one line of a chart with no interaction).
157
+ A folder there is a small project (its own package.json), and the file in it whose first lines say what it expects is the chart: `react-project/` is a React app's chart checked as React because of its package.json.
158
+ When pointing at slats changes nothing, the report's screenshot must match the one taken while pointing (each channel within 32 of 255: two drawings of a page differ a little at anti-aliased edges), so a probe that leaves the chart drawn wrong fails the test.
159
+ - **gallery**: the site's examples (from `RHP_DOCS`, the documentation checkout), each horizontal and in its best orientation, with the interaction pass.
160
+ An error fails the test unless it is in `KNOWN` in run.js: a problem looked at in the screenshot, found real, and reported to the owner (the gallery is theirs).
161
+ - **recipes**: the skill's recipes, listed with their findings; they don't fail the test, since the recipes' authors fix them.
162
+ - **guard**: the gallery examples and the recipes drawn with and without the guard, compared pixel for pixel at both widths (a chart that changes on a timer is listed, not failed: its two drawings may fall on either side of a change).
163
+ - **skill**: the html example in `skills/rhp/SKILL.md` checks with no findings.
@@ -0,0 +1,92 @@
1
+ // The browser a check runs in, found in this order: the path in RHP_CHECK_BROWSER, Playwright's own Chromium (its
2
+ // headless shell first, this playwright-core's revision first), the system's Chrome, then Edge.
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { createRequire } from "module";
7
+ import { chromium } from "playwright-core";
8
+
9
+ const require = createRequire(import.meta.url);
10
+
11
+ // Where Playwright keeps its browsers
12
+ function playwrightCache() {
13
+
14
+ if (process.env.PLAYWRIGHT_BROWSERS_PATH && process.env.PLAYWRIGHT_BROWSERS_PATH !== "0") return process.env.PLAYWRIGHT_BROWSERS_PATH;
15
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library/Caches/ms-playwright");
16
+ if (process.platform === "win32") return path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData/Local"), "ms-playwright");
17
+
18
+ return path.join(process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache"), "ms-playwright");
19
+ }
20
+
21
+ // The executables inside one of Playwright's browser folders
22
+ const EXECUTABLES = [
23
+ "chrome-headless-shell", "chrome-headless-shell.exe", "chrome", "chrome.exe",
24
+ "Chromium.app/Contents/MacOS/Chromium", "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
25
+ ];
26
+
27
+ function executableIn(folder) {
28
+
29
+ if (!fs.existsSync(folder)) return null;
30
+
31
+ for (const sub of fs.readdirSync(folder)) {
32
+ for (const name of EXECUTABLES) {
33
+ const file = path.join(folder, sub, name);
34
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
35
+ }
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ // Playwright's Chromium builds on this machine, best first
42
+ function playwrightChromiums() {
43
+
44
+ const cache = playwrightCache();
45
+ if (!fs.existsSync(cache)) return [];
46
+
47
+ let revision = null;
48
+ try {
49
+ revision = JSON.parse(fs.readFileSync(path.join(path.dirname(require.resolve("playwright-core/package.json")), "browsers.json"), "utf8")).browsers.find((b) => b.name === "chromium").revision;
50
+ } catch {
51
+ // an unknown layout: any revision will do
52
+ }
53
+
54
+ const folders = fs.readdirSync(cache).filter((f) => /^chromium(_headless_shell)?-\d+$/.test(f));
55
+ const rank = (f) => [f.endsWith("-" + revision) ? 0 : 1, f.startsWith("chromium_headless_shell") ? 0 : 1, -Number(f.split("-").pop())];
56
+ folders.sort((a, b) => {
57
+ const [x, y] = [rank(a), rank(b)];
58
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
59
+ });
60
+
61
+ return folders.map((f) => ({ folder: f, file: executableIn(path.join(cache, f)) })).filter((c) => c.file);
62
+ }
63
+
64
+ // Launches a headless browser, or returns { error } saying how to get one
65
+ export async function launch() {
66
+
67
+ const tries = [];
68
+
69
+ if (process.env.RHP_CHECK_BROWSER) tries.push({ name: `RHP_CHECK_BROWSER (${process.env.RHP_CHECK_BROWSER})`, options: { executablePath: process.env.RHP_CHECK_BROWSER } });
70
+
71
+ for (const c of playwrightChromiums()) {
72
+ tries.push({ name: `Playwright's ${c.folder.startsWith("chromium_headless_shell") ? "Chromium headless shell" : "Chromium"} (${c.folder})`, options: { executablePath: c.file } });
73
+ }
74
+
75
+ tries.push({ name: "Google Chrome", options: { channel: "chrome" } });
76
+ tries.push({ name: "Microsoft Edge", options: { channel: "msedge" } });
77
+
78
+ const failures = [];
79
+
80
+ for (const t of tries) {
81
+ try {
82
+ const browser = await chromium.launch({ headless: true, ...t.options, args: ["--hide-scrollbars", "--disable-lcd-text"] });
83
+ return { browser, name: `${t.name} ${browser.version()}` };
84
+ } catch (e) {
85
+ failures.push(`${t.name}: ${String(e.message ?? e).split("\n")[0]}`);
86
+ }
87
+ }
88
+
89
+ return { error: failures };
90
+ }
91
+
92
+ export const NO_BROWSER_FIX = "Install a browser for checking: run `npx -y @bezda/rhp-mcp install-browser` (it downloads Playwright's Chromium), or install Google Chrome, or set RHP_CHECK_BROWSER to the path of a Chrome, Chromium or Edge executable.";