@bezda/rhp-mcp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,176 @@
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
+ Before that, the **sweep** (`sweep()` in index.js): every slat of the main Plot (up to 32, spread evenly) is picked in turn, by a tap at a phone's width and the mouse elsewhere, and what moved outside the plot is noted (as below).
19
+ It runs at each width checked, and on a page of its own at each of the page's own breakpoints between that width and the next narrower one (both sides of each `min-width` or `max-width` of its media queries, up to six per width), where a layout made only for the widths checked jumps: a readout that takes another line for one slat, just past the width where the poster's padding changes.
20
+ With reduced motion a pick shows at once, so two frames after each are enough: the sweep takes a few seconds per width.
21
+ Widths between those (a jump only from 322 to 338px) are not swept; a readout that keeps its height by design (one line, or the same number of lines whatever it says) has none.
22
+ 6. **Interact** (interact.js), on a fresh page with motion on, as a reader gets it.
23
+ At the first width: point at a mark of the first, middle and last slat of the largest chart (in its first Plot that takes the pointer), and of the slat whose edge Label is widest when it is wider than theirs (lit, a name that gets bolder can make room "auto" wider); click each button and toggle, change each range and select, press an arrow key on a slider (`role=slider` first, else a range), and press Tab and the arrow keys when slats take focus.
24
+ Then Tab through the page (up to 8 stops), and compare a screenshot of each stop's box (with 12px around it for an outline) focused and blurred: under 20 pixels that change (by more than 24 in all three channels) is focus that shows nothing.
25
+ 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.
26
+ A point on a mark is one that reaches its slat: on its marks, inside an SVG shape in it (`isPointInFill`, for a donut's wedge that fills a small part of its box), or at a Label's center. When none does and the slat takes the pointer, something covers it, and the step says "could not reach slat N" and what covers it instead of pointing at a corner of a box.
27
+ After each step the guard reads again what the slats read once (stale values, static charts), and the pass notes what moved on the page (the layout jumps).
28
+ Controls go by their accessible name, slats by their Labels' text as the reader sees it (not hidden text, nor screen-reader text).
29
+ 7. **Report** (findings.js turns probe results into findings, report.js writes the text): errors first, then warnings, then notes, what passed, the interactions (with one line for the sweep: how many slats, and every width it picked them at) and the screenshots.
30
+ When pointing at slats (and tapping them) changes nothing but buttons or a slider change the chart, the report says so in one line.
31
+ When nothing changes and the page has no controls, it says "no interaction found (fine when none was asked for)" in one line, instead of a line per slat (slats that others cover, in an overlap Plot with no interaction, are left out of it, as long as one slat was reached).
32
+
33
+ ### Screenshots, and the page they move
34
+
35
+ A full-page screenshot of a page taller than the window makes Chromium lay the page out at another size for a moment (1 by 1 px, after most captures), so the page's resize, ResizeObserver and matchMedia handlers run: a chart whose orientation follows the width turns and turns back.
36
+ The checker waits up to ten seconds for fonts, finite entry animations and two unchanged DOM frames, and checks the result of that wait.
37
+ `settleTimeout` overrides this budget in `check()` and the MCP tool.
38
+ The CLI exposes the same setting as `--settle-timeout <ms>`.
39
+ The CLI and MCP accept 100 to 30,000ms; the MCP's overall 50-second deadline still applies.
40
+ An unfinished wait produces `page-unsettled` instead of layout or color conclusions from a moving page.
41
+ That holds for the page that is measured, with reduced motion asked for, where a live feed or a race changes in steps and comes to rest between them.
42
+ The interaction pass runs with motion on, as a reader gets the page, and there a page that moves on its own never comes to rest: it waits up to three seconds for fonts and entry animations, half a second after each screenshot, and is tried as it moves (`clean-moving.html`).
43
+ Geometry and text colors are read after the initial screenshots have settled, so responsive capture handlers cannot leave stale coordinates in the color probes.
44
+ So every screenshot goes through `capture()` (index.js), which lets the page come to rest again when the capture made it change (`calm()` in inpage.js), and `hide()` and `show()` put back only the style properties they set, never a whole `style` attribute saved before (that undid what the page did meanwhile and left such a chart drawn wrong).
45
+
46
+ ## Levels
47
+
48
+ - **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.
49
+ - **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.
50
+ - **info**: a note that changes nothing by itself (a font that did not load, a remote file that could not be fetched).
51
+
52
+ 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.
53
+ `ok` in the result is false when there is an error.
54
+
55
+ ## Codes
56
+
57
+ 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.
58
+
59
+ ### Loading the chart
60
+
61
+ | Code | Level | What triggers it | Fix it gives |
62
+ |---|---|---|---|
63
+ | `no-file` | error | The path doesn't exist, or no code was given. | Pass the path of the chart's file. |
64
+ | `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. |
65
+ | `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. |
66
+ | `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. |
67
+ | `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. |
68
+ | `cdn-version` | error | The page loads another major version of rhp from a CDN. | Load `@bezda/rhp@2` in the import map. |
69
+ | `no-browser` | error | No Chromium, Chrome or Edge to render in. | `npx @bezda/rhp-mcp install-browser`, or `RHP_CHECK_BROWSER`. |
70
+
71
+ ### The code (source.js, read before it runs)
72
+
73
+ | Code | Level | What triggers it | Fix it gives |
74
+ |---|---|---|---|
75
+ | `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. |
76
+ | `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. |
77
+ | `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}`. |
78
+ | `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) => ...`. |
79
+ | `style-string` | error | `style="..."` (or a string) on a Chart or a Plot, which take only a style object. | `style=${{ "pointer-events": "none" }}`. |
80
+ | `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`. |
81
+
82
+ ### What the page throws or prints
83
+
84
+ | Code | Level | What triggers it | Fix it gives |
85
+ |---|---|---|---|
86
+ | `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...). |
87
+ | `console-error` | error | The page logged an error. | As for `runtime-error`. |
88
+ | `interaction-error` | error | A step of the interaction pass threw or logged an error. | As for `runtime-error`. |
89
+ | `rhp-warning` | error | rhp printed one of its `rhp:` warnings. | Do what the warning says. |
90
+ | `request-failed` | error | A file next to the chart (or in the bundle) failed to load. | Fix the path, or add the file. |
91
+ | `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. |
92
+
93
+ ### How rhp is used (the guard)
94
+
95
+ | Code | Level | What triggers it | Fix it gives |
96
+ |---|---|---|---|
97
+ | `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. |
98
+ | `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. |
99
+ | `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()`. |
100
+ | `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"`. |
101
+ | `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. |
102
+ | `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. |
103
+ | `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. |
104
+ | `no-component` | error | A Solid, React or module file exports several components and no default one. | Export the chart as the default. |
105
+ | `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`. |
106
+ | `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`. |
107
+ | `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. |
108
+
109
+ ### Layout
110
+
111
+ | Code | Level | What triggers it | Fix it gives |
112
+ |---|---|---|---|
113
+ | `no-chart` | error | No `.rhp-chart` in the page (not reported when the code can't load, since that reason is reported instead). | Render into an element the page has, or fix the errors above. |
114
+ | `empty-plot` | error | A Plot with no slats shown (an empty overlap Plot is fine: a readout drawn only while pointing). | Give the Plot its data. |
115
+ | `page-unsettled` | error | Fonts, finite animations or DOM changes did not settle within the measurement budget. | Finish entry animations, honor reduced motion for continuous updates, or increase `settleTimeout` for a slow machine. |
116
+ | `small-plot` | error | A plot under 40px along its value axis, or slats under 2px thick (3px slats are fine: a histogram's many bins, a line chart's slats for pointing). | A height or an aspect, a thickness, or fewer rows. |
117
+ | `collapsed-chart` | error | A chart whose plot is under 2px wide: it sits in a flex row, an inline-block or a float that shrinks it to its room. | `flex: 1; min-width: 0`, or a box with a width. |
118
+ | `plot-outside-chart` | error | A `.rhp-plot` with no `.rhp-chart` around it: no scale and none of rhp's CSS. | Put the Plot in a Chart. |
119
+ | `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. |
120
+ | `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. |
121
+ | `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). |
122
+ | `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. |
123
+ | `text-outside-poster` | error | Text more than 1px outside its `.poster`. | Let it wrap, or make it smaller at narrow widths. |
124
+ | `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). |
125
+ | `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. |
126
+ | `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. |
127
+ | `page-scrolls-sideways` | error | The page is wider than the window. | `max-width: 100%`, and no fixed width wider than a phone. |
128
+ | `tiny-text` | error | Text under 9px as drawn (transforms counted). | 9px at least, data labels 11px or more. |
129
+ | `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. |
130
+
131
+ ### rhp's CSS
132
+
133
+ | Code | Level | What triggers it | Fix it gives |
134
+ |---|---|---|---|
135
+ | `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. |
136
+ | `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. |
137
+ | `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. |
138
+
139
+ ### Controls and interactions
140
+
141
+ | Code | Level | What triggers it | Fix it gives |
142
+ |---|---|---|---|
143
+ | `unnamed-control` | warning | A button, input, select or control with a role (slider, switch, tab...) that has no accessible name: no text, aria-label, aria-labelledby or label, so a screen reader says only what kind of control it is. | Visible text, an aria-label on an icon button, or a `<label>` around an input or a select. |
144
+ | `layout-jump` | warning | An interaction of the pass, or a pick of the sweep, moves a control other than the one it used, or a chart's top, by more than 2px (a readout that takes a second line, a button whose label changes its width); or, while it lights a slat (pointing, a tap, the keyboard), it moves an edge of the plot (a lit name that gets bolder makes room "auto" wider, and every bar slides) or the end of the chart's section (its outermost box in the page: a readout after the chart that takes a second line), by more than 2px. A control in a slat that moves with its slat (rows sorted or removed) is left out, and so are fixed and sticky elements. | A box that keeps its size: a readout on one line with an ellipsis, or a min-height for its longest text; buttons that keep their width; names that keep their weight and size when lit, or room in px. |
145
+ | `out-of-view` | warning | At a phone's width, in a window 664px tall, a tap on a slat that is in the window changes text the reader can see, and all of it lies outside the window (above, below or beside it), even when the tap lights the slat in view: a readout above a tall chart, when the last slat is tapped. | A readout next to the slat (in the slat, or in a Label of it), or one kept in view (`position: sticky`, with a z-index over the plot's 1). |
146
+ | `focus-invisible` | warning | Tab reaches a slat or a control, and a screenshot of its box (with 12px around it) focused and blurred differ in under 20 pixels: `outline: none` with nothing in its place. | Keep the focus ring, or a `:focus-visible` style that shows in its place (on a slat, in the slat type's css). |
147
+
148
+ ### Colors (read from screenshots)
149
+
150
+ | Code | Level | What triggers it | Fix it gives |
151
+ |---|---|---|---|
152
+ | `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. |
153
+ | `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. |
154
+ | `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. |
155
+
156
+ ## Adding a probe
157
+
158
+ 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.
159
+ 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.
160
+ 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`).
161
+ 4. Run `npm test`: the broken chart must report exactly its code, and the gallery and the SKILL.md example must stay clean.
162
+
163
+ ## Tests
164
+
165
+ `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.
166
+ 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.
167
+
168
+ - **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.
169
+ 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).
170
+ 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.
171
+ 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.
172
+ - **gallery**: the site's examples (from `RHP_DOCS`, the documentation checkout), each horizontal and in its best orientation, with the interaction pass.
173
+ 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).
174
+ - **recipes**: the skill's recipes, listed with their findings; they don't fail the test, since the recipes' authors fix them.
175
+ - **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).
176
+ - **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.";