@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,1577 @@
1
+ # Environments
2
+
3
+ ## Contents
4
+
5
+ 1. [Find the environment](#1-find-the-environment): the detection table
6
+ 2. [The example chart](#2-the-example-chart): its data, its CSS, its fonts
7
+ 3. [Plain HTML page](#3-plain-html-page)
8
+ 4. [Solid](#4-solid): Vite, TypeScript, SolidStart
9
+ 5. [Solid JSX and the html template](#5-solid-jsx-and-the-html-template): the conversion rules
10
+ 6. [The chart module and the mount helper](#6-the-chart-module-and-the-mount-helper): for every app that is not a Solid app
11
+ 7. [React](#7-react): the wrapper, Vite, TypeScript
12
+ 8. [Next.js](#8-nextjs): App Router, Pages Router
13
+ 9. [Vue and Nuxt](#9-vue-and-nuxt)
14
+ 10. [Svelte and SvelteKit](#10-svelte-and-sveltekit)
15
+ 11. [Angular](#11-angular)
16
+ 12. [Astro](#12-astro): a custom element, or a Solid island
17
+ 13. [Check a chart inside an app](#13-check-a-chart-inside-an-app)
18
+ 14. [Caveats for every app](#14-caveats-for-every-app)
19
+ 15. [What was verified, and the versions](#15-what-was-verified-and-the-versions)
20
+
21
+ Where an rhp chart goes in a plain HTML page and in Solid, React, Next.js, Vue, Nuxt, Svelte, SvelteKit, Angular and Astro apps.
22
+ Every section builds the same small poster chart, with its data passed in by the app, and every one was built and checked in a browser on 2026-10-03 with `@bezda/rhp` 2.0.1 (section 15).
23
+
24
+ ## 1. Find the environment
25
+
26
+ Read package.json (`dependencies` and `devDependencies`) and the config files at the project's root.
27
+ Go down the table and take the first row that matches: Next.js apps also have `react`, Nuxt apps `vue`, SvelteKit apps `svelte`.
28
+
29
+ | Files | package.json has | Environment | Write the chart as | Section |
30
+ |---|---|---|---|---|
31
+ | no package.json; a chatbot answer; "an HTML file" | | plain page | one `.html` file, rhp from jsDelivr | 3 |
32
+ | `astro.config.*` | `astro` and `@astrojs/solid-js` | Astro with Solid | a Solid island (JSX) | 12 |
33
+ | `astro.config.*` | `astro` | Astro | a custom element drawing an html-template chart | 12 |
34
+ | `next.config.*`, `app/` or `src/app/` | `next` | Next.js App Router | a React component the server never renders | 8 |
35
+ | `next.config.*`, `pages/` or `src/pages/` | `next` | Next.js Pages Router | a React component loaded with `ssr: false` | 8 |
36
+ | `nuxt.config.*` | `nuxt` | Nuxt | a Vue component | 9 |
37
+ | `src/routes/`, `sveltekit()` in `vite.config.*` or a `svelte.config.*` | `@sveltejs/kit` | SvelteKit | a Svelte component | 10 |
38
+ | `src/entry-server.*`, `src/routes/` | `@solidjs/start` | SolidStart | a JSX component | 4 |
39
+ | `angular.json` | `@angular/core` | Angular | a standalone component | 11 |
40
+ | `vite.config.*` with `solid()` | `solid-js`, `vite-plugin-solid` | Solid | a JSX component | 4 |
41
+ | `vite.config.*` with `react()` | `react`, `react-dom` | React | a React component around an html-template chart | 7 |
42
+ | `vite.config.*` with `vue()` | `vue` | Vue | a Vue component | 9 |
43
+ | `vite.config.*` with `svelte()` | `svelte` | Svelte | a Svelte component | 10 |
44
+ | a bundler and none of the above | | modules | the chart module and `mountChart`, from the app's own script | 6 |
45
+
46
+ - When a Next.js app has both `app/` and `pages/`, the folder of the page that shows the chart decides.
47
+ - A `tsconfig.json` and `.ts` or `.tsx` sources mean TypeScript: each section has the typed files.
48
+ - `solid-js` in `node_modules` or in the lock file does not make a Solid app: npm installs it with `@bezda/rhp` in every app, as a peer dependency, and it is not added to package.json.
49
+ Only `solid-js` in package.json, together with `vite-plugin-solid`, `@solidjs/start` or `@astrojs/solid-js`, makes a Solid app.
50
+ - Install in every app with the project's package manager: `npm install @bezda/rhp`.
51
+ Only npm was tested; pnpm, yarn and bun were not.
52
+ - Not tested: React apps that render on a server outside Next.js (React Router in framework mode, Remix, Gatsby), Preact, Qwik, Lit, Ember, Nuxt 3, SvelteKit 2.
53
+ For those, use section 6: draw the chart in the browser in the component's mount hook, dispose it in its unmount hook, and check the page in a browser.
54
+
55
+ ## 2. The example chart
56
+
57
+ Every section builds the same poster: the seven tallest buildings as a ranked bar chart, with a one-line readout that names the tower the reader points at, taps, or reaches with Tab and the arrow keys.
58
+ Three parts are the same in every app.
59
+
60
+ **The data** comes from the app as `rows`, a list of `{ name, city, year, height }`.
61
+ Here it is a module of its own (`towers.js`); in a real app it comes from a fetch, a database or the page's server data, and reaches the chart the same way.
62
+
63
+ ```js
64
+ // The seven tallest finished buildings, height to the architectural top in metres (CTBUH, 2025)
65
+ export const TOWERS = [
66
+ { name: "Burj Khalifa", city: "Dubai", year: 2010, height: 828 },
67
+ { name: "Merdeka 118", city: "Kuala Lumpur", year: 2023, height: 679 },
68
+ { name: "Shanghai Tower", city: "Shanghai", year: 2015, height: 632 },
69
+ { name: "Makkah Royal Clock Tower", city: "Mecca", year: 2012, height: 601 },
70
+ { name: "Ping An Finance Center", city: "Shenzhen", year: 2017, height: 599 },
71
+ { name: "Lotte World Tower", city: "Seoul", year: 2017, height: 555 },
72
+ { name: "One World Trade Center", city: "New York", year: 2014, height: 541 },
73
+ ];
74
+ ```
75
+
76
+ **The poster's CSS** (`towers-chart.css`) is an ordinary stylesheet for the panel around the chart: the Poster's `figcaption`, `.kicker`, `.headline`, `.dek` and `.note`, and the readout.
77
+ The chart inside takes its look from the slat type's `css` and the Chart's `theme`, never from this file.
78
+
79
+ ```css
80
+ /* The towers poster: the page's own CSS for the panel around the chart (rhp styles the chart itself) */
81
+ .towers { box-sizing: border-box; width: 100%; max-width: 880px; margin: 0 auto; padding: 28px 28px 20px; border-radius: 4px; color: #17191c; font: 15px/1.45 "Archivo", system-ui, sans-serif; letter-spacing: normal; text-align: start; background: linear-gradient(rgb(23 25 28 / .05) 1px, transparent 1px) 0 0 / 22px 22px, linear-gradient(90deg, rgb(23 25 28 / .05) 1px, transparent 1px) 0 0 / 22px 22px, #f3f1ec; box-shadow: 0 1px 2px rgb(0 0 0 / .08), 0 12px 32px rgb(0 0 0 / .1); }
82
+ .towers figcaption { display: grid; gap: 10px; margin-bottom: 18px; }
83
+ .towers .kicker { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #c2381b; }
84
+ .towers .headline { font: 700 clamp(34px, 7vw, 60px)/.95 "Antonio", "Arial Narrow", sans-serif; text-transform: uppercase; text-wrap: balance; }
85
+ .towers .dek { max-width: 44ch; color: #4a4e55; }
86
+ .towers .readout { max-width: none; height: 22px; margin: 0 0 10px; font-size: 15px; line-height: 22px; white-space: nowrap; color: #4a4e55; }
87
+ .towers .readout b { color: #17191c; }
88
+ .towers .note { display: block; margin-top: 16px; font-size: 12px; color: #5d6168; }
89
+ @media (max-width: 480px) { .towers { padding: 20px 16px 16px; } .towers .readout { font-size: 13px; } }
90
+ ```
91
+
92
+ The panel sets its own font, color, `letter-spacing` and `text-align`, and the readout sets `max-width: none` and its margins, because an app's own CSS reaches the poster (section 14).
93
+
94
+ **The fonts** are Antonio (the headline) and Archivo (the text) from Google Fonts.
95
+ Each section says where these tags go:
96
+
97
+ ```html
98
+ <link rel="preconnect" href="https://fonts.googleapis.com">
99
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
100
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">
101
+ ```
102
+
103
+ | Environment | The chart's files | The poster's CSS | The fonts | On the server |
104
+ |---|---|---|---|---|
105
+ | plain page | one `.html` file | `<style>` in the head | `<link>` in the head | no server |
106
+ | Solid | `TowersChart.jsx` | imported by `TowersChart.jsx` | `index.html` | no server |
107
+ | SolidStart | `TowersChart.jsx` | imported by `TowersChart.jsx` | `src/entry-server.jsx` | drawn, then hydrated |
108
+ | React | `Towers.jsx` | imported by `Towers.jsx` | `index.html` | no server |
109
+ | Next.js App Router | `Towers.tsx`, loaded by `ClientOnlyTowers.tsx` | imported by `ClientOnlyTowers.tsx` | `next/font` in `app/layout.tsx` | not drawn; rhp not loaded |
110
+ | Next.js Pages Router | `Towers.jsx`, loaded with `ssr: false` | imported by `pages/_app.js` | `pages/_document.js` | not drawn; rhp not loaded |
111
+ | Vue | `towers-chart.js`, `Towers.vue` | imported by `Towers.vue` | `index.html` | no server |
112
+ | Nuxt | `towers-chart.js`, `Towers.vue` | imported by `Towers.vue` | `nuxt.config.ts` | an empty box |
113
+ | Svelte | `towers-chart.js`, `Towers.svelte` | imported by `Towers.svelte` | `index.html` | no server |
114
+ | SvelteKit | `towers-chart.ts`, `Towers.svelte` | imported by `Towers.svelte` | `src/app.html` | an empty box |
115
+ | Angular | `towers-chart.ts`, `towers.ts` | `styleUrl` with `ViewEncapsulation.None` | `src/index.html` | not tested |
116
+ | Astro | `towers-chart.js`, `Towers.astro` | imported in the frontmatter | the page's `<head>` | an empty element |
117
+ | Astro with Solid | `TowersChart.jsx` as an island | imported by `TowersChart.jsx` | the page's `<head>` | drawn, then hydrated |
118
+
119
+ ## 3. Plain HTML page
120
+
121
+ Use it when there is no project, in a chatbot's answer, or when the user asks for an HTML file.
122
+ rhp comes from jsDelivr through an import map, so the page needs no install and no build step.
123
+ Its module script has the sections every recipe has: data, slat types, the chart, then `render`.
124
+
125
+ ```html
126
+ <!doctype html>
127
+ <html lang="en">
128
+ <head>
129
+ <meta charset="utf-8">
130
+ <meta name="viewport" content="width=device-width, initial-scale=1">
131
+ <title>The tallest buildings</title>
132
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
133
+ <link rel="preconnect" href="https://fonts.googleapis.com">
134
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
135
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">
136
+ <style>
137
+ body { margin: 0; padding: 32px 16px; background: #d9d7d2; }
138
+ .towers { box-sizing: border-box; width: 100%; max-width: 880px; margin: 0 auto; padding: 28px 28px 20px; border-radius: 4px; color: #17191c; font: 15px/1.45 "Archivo", system-ui, sans-serif; letter-spacing: normal; text-align: start; background: linear-gradient(rgb(23 25 28 / .05) 1px, transparent 1px) 0 0 / 22px 22px, linear-gradient(90deg, rgb(23 25 28 / .05) 1px, transparent 1px) 0 0 / 22px 22px, #f3f1ec; box-shadow: 0 1px 2px rgb(0 0 0 / .08), 0 12px 32px rgb(0 0 0 / .1); }
139
+ .towers figcaption { display: grid; gap: 10px; margin-bottom: 18px; }
140
+ .towers .kicker { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #c2381b; }
141
+ .towers .headline { font: 700 clamp(34px, 7vw, 60px)/.95 "Antonio", "Arial Narrow", sans-serif; text-transform: uppercase; text-wrap: balance; }
142
+ .towers .dek { max-width: 44ch; color: #4a4e55; }
143
+ .towers .readout { max-width: none; height: 22px; margin: 0 0 10px; font-size: 15px; line-height: 22px; white-space: nowrap; color: #4a4e55; }
144
+ .towers .readout b { color: #17191c; }
145
+ .towers .note { display: block; margin-top: 16px; font-size: 12px; color: #5d6168; }
146
+ @media (max-width: 480px) { .towers { padding: 20px 16px 16px; } .towers .readout { font-size: 13px; } }
147
+ </style>
148
+ </head>
149
+ <body>
150
+ <div id="chart"></div>
151
+ <script type="module">
152
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, render, createSignal, createMemo, Show } from "@bezda/rhp/standalone";
153
+
154
+ // Data: the seven tallest finished buildings, height to the architectural top in metres (CTBUH, 2025)
155
+ const TOWERS = [
156
+ { name: "Burj Khalifa", city: "Dubai", year: 2010, height: 828 },
157
+ { name: "Merdeka 118", city: "Kuala Lumpur", year: 2023, height: 679 },
158
+ { name: "Shanghai Tower", city: "Shanghai", year: 2015, height: 632 },
159
+ { name: "Makkah Royal Clock Tower", city: "Mecca", year: 2012, height: 601 },
160
+ { name: "Ping An Finance Center", city: "Shenzhen", year: 2017, height: 599 },
161
+ { name: "Lotte World Tower", city: "Seoul", year: 2017, height: 555 },
162
+ { name: "One World Trade Center", city: "New York", year: 2014, height: 541 },
163
+ ];
164
+
165
+ // Slat types: one tower, with its name, its bar (one line per floor) and its height; the tower the reader is on turns red
166
+ const Tower = slat({
167
+ thickness: 44,
168
+ room: { start: "auto", end: 60 },
169
+ css: `
170
+ .tower { cursor: default; outline: none; }
171
+ .tower:focus-visible { background: rgb(23 25 28 / .06); }
172
+ .name { max-width: 10em; font-size: 14px; font-weight: 600; line-height: 1.15; white-space: normal; text-align: end; }
173
+ .bar { --rhp-radius: 0px; background: repeating-linear-gradient(var(--rhp-toward-end), #2a2e34 0 6px, #464b53 6px 7px); }
174
+ .on .bar { background: repeating-linear-gradient(var(--rhp-toward-end), #c2381b 0 6px, #d9614a 6px 7px); }
175
+ .height { font-size: 13px; font-weight: 700; color: var(--rhp-muted); }
176
+ .on .height { color: #c2381b; }
177
+ `,
178
+ }, (d) => html`
179
+ <div class=${() => (d.on ? "tower on" : "tower")} data-name=${() => d.name}>
180
+ <${Label} edge="start" class="name">${() => d.name}<//>
181
+ <${Bar} to=${() => d.height} class="bar" />
182
+ <${Label} at=${() => d.height} class="height">${() => d.height} m<//>
183
+ </div>`);
184
+
185
+ // The chart: a poster with a one-line readout over the towers, ranked by height.
186
+ // Its data comes in as props.rows, a list of { name, city, year, height }.
187
+ function TowersChart(props) {
188
+ const [on, setOn] = createSignal(null); // the name of the tower the reader picked
189
+ const current = createMemo(() => props.rows.find((t) => t.name === on()));
190
+ const tallest = createMemo(() => Math.max(...props.rows.map((t) => t.height)));
191
+ // The name of the tower an element is in, or null (between towers, on the text)
192
+ const nameOf = (el) => el.closest("[data-name]")?.dataset.name ?? null;
193
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no tower the pick stays
194
+ const pick = (e) => {
195
+ const name = nameOf(e.target);
196
+ if (name != null) setOn(name);
197
+ };
198
+ // Leaving the poster: back to the tower that has focus (a keyboard's or a click's pick), or none
199
+ const leave = (e) => {
200
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
201
+ setOn(e.currentTarget.contains(document.activeElement) ? nameOf(document.activeElement) : null);
202
+ };
203
+
204
+ return html`
205
+ <${Poster} class="towers"
206
+ kicker="Skyscrapers, 2025"
207
+ title=${"Burj Khalifa still towers 149\u00a0m over the rest"}
208
+ dek="The seven tallest finished buildings in the world, by height to their architectural top."
209
+ note="Source: Council on Tall Buildings and Urban Habitat (CTBUH), 2025."
210
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)}
211
+ onClick=${pick}
212
+ onFocusIn=${pick}
213
+ onPointerLeave=${leave}
214
+ onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
215
+ <p class="readout">
216
+ <${Show} when=${current} fallback="Point at a tower, tap it, or Tab to it.">
217
+ ${(t) => html`<b>${() => t().name}</b> in ${() => t().city}, finished ${() => t().year}`}
218
+ <//>
219
+ </p>
220
+ <${Chart} scale=${() => [0, tallest()]} ticks=${false} label="The seven tallest buildings, by height"
221
+ theme=${{ font: '"Archivo", system-ui, sans-serif', ink: "#17191c", muted: "#5d6168" }}>
222
+ <${Plot} rows=${() => props.rows} key="name" order=${sortBy("height", "desc")} on=${(d) => on() === d.name} keyboard=${true}>${Tower}<//>
223
+ <//>
224
+ <//>`;
225
+ }
226
+
227
+ // Render: the chart into #chart, with its data
228
+ render(() => TowersChart({ rows: TOWERS }), document.getElementById("chart"));
229
+ </script>
230
+ </body>
231
+ </html>
232
+ ```
233
+
234
+ - It opens from the disk (`file://`) in Chrome 154, Safari 26.5 and Firefox 155 (Playwright's build), and from any web server.
235
+ - Keep the module script inline.
236
+ A page that loads its own module file (`<script type="module" src="chart.js">`) does not run from the disk in Chrome (a blocked cross-origin request) or in Safari; it runs in Firefox, and in every browser when the folder is served (`npx serve`).
237
+ - `@bezda/rhp@2` in the URL takes the latest rhp 2 release.
238
+ Pin the version (`@bezda/rhp@2.0.1`) for a page that must never change.
239
+
240
+ ## 4. Solid
241
+
242
+ Detect: `solid-js` and `vite-plugin-solid` in package.json (Vite), or `@solidjs/start` (SolidStart).
243
+ Install: `npm install @bezda/rhp`; it uses the app's own `solid-js` (rhp needs 1.9 or a later 1.x).
244
+ Import rhp from `@bezda/rhp` and Solid from `solid-js`, never from `@bezda/rhp/standalone`, which carries a second copy of Solid.
245
+
246
+ ```
247
+ index.html the fonts (section 2)
248
+ src/charts/TowersChart.jsx the chart, a Solid component
249
+ src/charts/towers-chart.css the poster's CSS (section 2)
250
+ src/data/towers.js the data (section 2)
251
+ src/App.jsx the app passes its data
252
+ ```
253
+
254
+ `src/charts/TowersChart.jsx` is the JSX version of section 3's chart, line for line (section 5 has the rules):
255
+
256
+ ```jsx
257
+ // The towers poster in a Solid app: rhp with JSX
258
+ import { createMemo, createSignal, Show } from "solid-js";
259
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy } from "@bezda/rhp";
260
+ import "./towers-chart.css";
261
+
262
+ // Slat types: one tower, with its name, its bar (one line per floor) and its height; the tower the reader is on turns red
263
+ const Tower = slat({
264
+ thickness: 44,
265
+ room: { start: "auto", end: 60 },
266
+ css: `
267
+ .tower { cursor: default; outline: none; }
268
+ .tower:focus-visible { background: rgb(23 25 28 / .06); }
269
+ .name { max-width: 10em; font-size: 14px; font-weight: 600; line-height: 1.15; white-space: normal; text-align: end; }
270
+ .bar { --rhp-radius: 0px; background: repeating-linear-gradient(var(--rhp-toward-end), #2a2e34 0 6px, #464b53 6px 7px); }
271
+ .on .bar { background: repeating-linear-gradient(var(--rhp-toward-end), #c2381b 0 6px, #d9614a 6px 7px); }
272
+ .height { font-size: 13px; font-weight: 700; color: var(--rhp-muted); }
273
+ .on .height { color: #c2381b; }
274
+ `,
275
+ }, (d) => (
276
+ <div class={d.on ? "tower on" : "tower"} data-name={d.name}>
277
+ <Label edge="start" class="name">{d.name}</Label>
278
+ <Bar to={d.height} class="bar" />
279
+ <Label at={d.height} class="height">{d.height} m</Label>
280
+ </div>
281
+ ));
282
+
283
+ // The chart: a poster with a one-line readout over the towers, ranked by height.
284
+ // Its data comes in as props.rows, a list of { name, city, year, height }.
285
+ export function TowersChart(props) {
286
+ const [on, setOn] = createSignal(null); // the name of the tower the reader picked
287
+ const current = createMemo(() => props.rows.find((t) => t.name === on()));
288
+ const tallest = createMemo(() => Math.max(...props.rows.map((t) => t.height)));
289
+ // The name of the tower an element is in, or null (between towers, on the text)
290
+ const nameOf = (el) => el.closest("[data-name]")?.dataset.name ?? null;
291
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no tower the pick stays
292
+ const pick = (e) => {
293
+ const name = nameOf(e.target);
294
+ if (name != null) setOn(name);
295
+ };
296
+ // Leaving the poster: back to the tower that has focus (a keyboard's or a click's pick), or none
297
+ const leave = (e) => {
298
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
299
+ setOn(e.currentTarget.contains(document.activeElement) ? nameOf(document.activeElement) : null);
300
+ };
301
+
302
+ return (
303
+ <Poster class="towers"
304
+ kicker="Skyscrapers, 2025"
305
+ title={"Burj Khalifa still towers 149\u00a0m over the rest"}
306
+ dek="The seven tallest finished buildings in the world, by height to their architectural top."
307
+ note="Source: Council on Tall Buildings and Urban Habitat (CTBUH), 2025."
308
+ onPointerMove={(e) => e.pointerType !== "touch" && pick(e)}
309
+ onClick={pick}
310
+ onFocusIn={pick}
311
+ onPointerLeave={leave}
312
+ onFocusOut={(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
313
+ <p class="readout">
314
+ <Show when={current()} fallback="Point at a tower, tap it, or Tab to it.">
315
+ {(t) => <><b>{t().name}</b> in {t().city}, finished {t().year}</>}
316
+ </Show>
317
+ </p>
318
+ <Chart scale={[0, tallest()]} ticks={false} label="The seven tallest buildings, by height"
319
+ theme={{ font: '"Archivo", system-ui, sans-serif', ink: "#17191c", muted: "#5d6168" }}>
320
+ <Plot rows={props.rows} key="name" order={sortBy("height", "desc")} on={(d) => on() === d.name} keyboard>{Tower}</Plot>
321
+ </Chart>
322
+ </Poster>
323
+ );
324
+ }
325
+ ```
326
+
327
+ `src/App.jsx`:
328
+
329
+ ```jsx
330
+ import { TowersChart } from "./charts/TowersChart.jsx";
331
+ import { TOWERS } from "./data/towers.js";
332
+
333
+ export default function App() {
334
+ return (
335
+ <main style={{ padding: "32px 16px" }}>
336
+ <TowersChart rows={TOWERS} />
337
+ </main>
338
+ );
339
+ }
340
+ ```
341
+
342
+ - `rows` can be a signal, a store or a resource: `<TowersChart rows={rows()} />`.
343
+ A store changed in place (`setStore("rows", 2, "height", 700)`) moves that one bar (checked).
344
+ - With `key="name"` on the Plot, new data keeps each tower's slat: the element stays and its bar moves (checked).
345
+ - Solid removes the chart with its owner (a `<Show>`, a route): there is nothing to clean up (checked).
346
+ - Development: the Vite dev server updates the chart after an edit to the chart or the data without a reload (checked).
347
+
348
+ **TypeScript** (`TowersChart.tsx`): the same file with these lines changed, checked with `tsc -b` under TypeScript 6.0's defaults, which are strict:
349
+
350
+ ```tsx
351
+ export type TowerRow = { name: string; city: string; year: number; height: number };
352
+ const Tower = slat<TowerRow & { on: boolean }>({
353
+ export function TowersChart(props: { rows: TowerRow[] }) {
354
+ const [on, setOn] = createSignal<string | null>(null); // the name of the tower the reader picked
355
+ const nameOf = (el: Element) => el.closest("[data-name]")?.getAttribute("data-name") ?? null;
356
+ const pick = (e: Event) => {
357
+ const name = nameOf(e.target as Element);
358
+ const leave = (e: PointerEvent) => {
359
+ const focused = document.activeElement;
360
+ setOn(focused && (e.currentTarget as Element).contains(focused) ? nameOf(focused) : null);
361
+ onFocusOut={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setOn(null)}>
362
+ <Plot rows={props.rows} key="name" order={sortBy("height", "desc")} on={(d: TowerRow) => on() === d.name} keyboard>{Tower}</Plot>
363
+ ```
364
+
365
+ The data file imports the type: `import type { TowerRow } from "../charts/TowersChart";`.
366
+
367
+ **SolidStart**: the same `TowersChart.jsx` in `src/components/`, used by a route, with the data in `src/data/towers.js`.
368
+ `src/routes/index.jsx`:
369
+
370
+ ```jsx
371
+ import { Title } from "@solidjs/meta";
372
+ import { TowersChart } from "~/components/TowersChart";
373
+ import { TOWERS } from "~/data/towers";
374
+
375
+ export default function Home() {
376
+ return (
377
+ <main>
378
+ <Title>The tallest buildings</Title>
379
+ <TowersChart rows={TOWERS} />
380
+ </main>
381
+ );
382
+ }
383
+ ```
384
+
385
+ The fonts go in the document's head in `src/entry-server.jsx`:
386
+
387
+ ```jsx
388
+ <head>
389
+ <meta charset="utf-8" />
390
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
391
+ <link rel="icon" href="/favicon.ico" />
392
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
393
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
394
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap" />
395
+ {assets}
396
+ </head>
397
+ ```
398
+
399
+ - SolidStart draws the chart on the server and hydrates it in the browser (checked: the server's HTML holds the 7 slats, and the browser logs no hydration warning).
400
+ - The starter's `src/app.css` styles every `p` (`max-width: 14rem; margin: 2rem auto`): without its own `max-width: none` the readout was cut at 390px (checked).
401
+
402
+ ## 5. Solid JSX and the html template
403
+
404
+ Recipes, plain pages and every app that is not a Solid app write charts with the html template; a Solid app writes JSX.
405
+ Section 3's script and section 4's component are the same chart, line for line.
406
+
407
+ | | html template (`@bezda/rhp/standalone`) | Solid JSX (`@bezda/rhp` and `solid-js`) |
408
+ |---|---|---|
409
+ | imports | everything from `@bezda/rhp/standalone`: rhp, `html`, `render`, `createSignal`, `createMemo`, `Show`, `For` | rhp from `@bezda/rhp`; `createSignal`, `createMemo`, `Show`, `For`, `createSelector` from `solid-js`; `render` from `solid-js/web` |
410
+ | a component | `<${Bar} to=${...} />` | `<Bar to={...} />` |
411
+ | closing a component | `<//>` | `</Label>` |
412
+ | a value that changes | a function: `to=${() => d.height}` | the expression: `to={d.height}` |
413
+ | text with a value | `${() => d.height} m` | `{d.height} m` |
414
+ | a value that never changes | `scale=${[0, 30]}`, `rows=${TOWERS}` | `scale={[0, 30]}`, `rows={TOWERS}` |
415
+ | a signal or a memo | as it is (`when=${current}`) or in a function (`${() => current()}`) | called: `when={current()}` |
416
+ | a boolean prop | `keyboard=${true}` | `keyboard` |
417
+ | a handler on a component | takes its event: `onPointerMove=${(e) => ...}` | any function: `onClick={() => ...}` |
418
+ | a handler on a plain element | any function | any function |
419
+ | a CSS class | `class="name"`, `class=${() => ...}` | `class="name"`, `class={...}` |
420
+ | a style object | `style=${{ "pointer-events": "none" }}` | `style={{ "pointer-events": "none" }}` |
421
+ | several root elements | allowed, except in a slat | in a fragment `<>...</>`, except in a slat |
422
+
423
+ `Show` with a child function, and `For`:
424
+
425
+ ```js
426
+ // html template
427
+ <${Show} when=${current} fallback="Point at a tower, tap it, or Tab to it.">
428
+ ${(t) => html`<b>${() => t().name}</b> in ${() => t().city}`}
429
+ <//>
430
+ <${For} each=${list}>${(item) => html`<li>${item}</li>`}<//>
431
+ ```
432
+
433
+ ```jsx
434
+ // Solid JSX
435
+ <Show when={current()} fallback="Point at a tower, tap it, or Tab to it.">
436
+ {(t) => <><b>{t().name}</b> in {t().city}</>}
437
+ </Show>
438
+ <For each={list()}>{(item) => <li>{item}</li>}</For>
439
+ ```
440
+
441
+ A slat, and drawing the chart into an element:
442
+
443
+ ```js
444
+ // html template
445
+ const Tower = slat({ thickness: 44 }, (d) => html`<div>...</div>`);
446
+ render(() => TowersChart({ rows: TOWERS }), document.getElementById("chart"));
447
+ ```
448
+
449
+ ```jsx
450
+ // Solid JSX
451
+ const Tower = slat({ thickness: 44 }, (d) => <div>...</div>);
452
+ render(() => <TowersChart rows={TOWERS} />, document.getElementById("root"));
453
+ ```
454
+
455
+ Each of these was checked in both formats:
456
+
457
+ 1. `Show`'s child function runs once and gets an accessor (`t`).
458
+ In the template, read it in functions (`${() => t().name}`): `${t().name}` kept the first tower's name when the reader moved to another tower.
459
+ JSX makes `{t().name}` follow it.
460
+ 2. A bare boolean attribute is `true` in JSX and `""` (false) in the template: a bare `keyboard` gave the template's chart no tab stops.
461
+ 3. A handler with no parameter on a component is attached in JSX.
462
+ In the template it runs once while drawing, is never attached, and a click then throws `c.call is not a function`.
463
+ 4. A style object takes CSS property names: `"pointer-events"` works, and `pointerEvents` (React's spelling) is ignored, in both.
464
+ 5. `className` on a block replaces rhp's class, in both: the element is no longer drawn as a bar (it spans the whole slat).
465
+ Use `class`.
466
+ 6. `For` takes the signal as it is in the template (`each=${list}`) or a function (`each=${() => list()}`); both follow it.
467
+ 7. In TypeScript, the template's `${...}` values have no types, so give every function parameter inside it a type: `(e: PointerEvent) =>`, `(d: TowerRow) =>`, `(t: () => TowerRow) =>`.
468
+ Strict mode (TypeScript 6's default) rejects them untyped (TS7006), and so does TSX for a Plot's data props: `on={(d: TowerRow) => ...}`.
469
+ 8. The template module has no `Dynamic` or `Portal`, and before rhp 2.0.2 no `createSelector`, `Switch`, `Match`, `splitProps` or `mergeProps` (api.md, section 2): compare in a function (`on=${(d) => on() === d.name}`) and use `Show`, which work with every rhp 2.
470
+
471
+ ## 6. The chart module and the mount helper
472
+
473
+ In every app that is not a Solid app, the chart is an ES module written with the html template, and a small helper draws it into an element and hands it new data.
474
+ Vue, Nuxt, Svelte, SvelteKit, Angular and Astro use the module as it is; React and Next.js put the same code in their component's file (section 7).
475
+
476
+ The chart module, `towers-chart.js`: section 3's script without the data and the `render` call, with the chart exported.
477
+
478
+ ```js
479
+ // The towers poster, written with rhp's html template: the same file in every app that is not a Solid app
480
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, createSignal, createMemo, Show } from "@bezda/rhp/standalone";
481
+
482
+ // Slat types: one tower, with its name, its bar (one line per floor) and its height; the tower the reader is on turns red
483
+ const Tower = slat({
484
+ thickness: 44,
485
+ room: { start: "auto", end: 60 },
486
+ css: `
487
+ .tower { cursor: default; outline: none; }
488
+ .tower:focus-visible { background: rgb(23 25 28 / .06); }
489
+ .name { max-width: 10em; font-size: 14px; font-weight: 600; line-height: 1.15; white-space: normal; text-align: end; }
490
+ .bar { --rhp-radius: 0px; background: repeating-linear-gradient(var(--rhp-toward-end), #2a2e34 0 6px, #464b53 6px 7px); }
491
+ .on .bar { background: repeating-linear-gradient(var(--rhp-toward-end), #c2381b 0 6px, #d9614a 6px 7px); }
492
+ .height { font-size: 13px; font-weight: 700; color: var(--rhp-muted); }
493
+ .on .height { color: #c2381b; }
494
+ `,
495
+ }, (d) => html`
496
+ <div class=${() => (d.on ? "tower on" : "tower")} data-name=${() => d.name}>
497
+ <${Label} edge="start" class="name">${() => d.name}<//>
498
+ <${Bar} to=${() => d.height} class="bar" />
499
+ <${Label} at=${() => d.height} class="height">${() => d.height} m<//>
500
+ </div>`);
501
+
502
+ // The chart: a poster with a one-line readout over the towers, ranked by height.
503
+ // Its data comes in as props.rows, a list of { name, city, year, height }.
504
+ export function TowersChart(props) {
505
+ const [on, setOn] = createSignal(null); // the name of the tower the reader picked
506
+ const current = createMemo(() => props.rows.find((t) => t.name === on()));
507
+ const tallest = createMemo(() => Math.max(...props.rows.map((t) => t.height)));
508
+ // The name of the tower an element is in, or null (between towers, on the text)
509
+ const nameOf = (el) => el.closest("[data-name]")?.dataset.name ?? null;
510
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no tower the pick stays
511
+ const pick = (e) => {
512
+ const name = nameOf(e.target);
513
+ if (name != null) setOn(name);
514
+ };
515
+ // Leaving the poster: back to the tower that has focus (a keyboard's or a click's pick), or none
516
+ const leave = (e) => {
517
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
518
+ setOn(e.currentTarget.contains(document.activeElement) ? nameOf(document.activeElement) : null);
519
+ };
520
+
521
+ return html`
522
+ <${Poster} class="towers"
523
+ kicker="Skyscrapers, 2025"
524
+ title=${"Burj Khalifa still towers 149\u00a0m over the rest"}
525
+ dek="The seven tallest finished buildings in the world, by height to their architectural top."
526
+ note="Source: Council on Tall Buildings and Urban Habitat (CTBUH), 2025."
527
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)}
528
+ onClick=${pick}
529
+ onFocusIn=${pick}
530
+ onPointerLeave=${leave}
531
+ onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
532
+ <p class="readout">
533
+ <${Show} when=${current} fallback="Point at a tower, tap it, or Tab to it.">
534
+ ${(t) => html`<b>${() => t().name}</b> in ${() => t().city}, finished ${() => t().year}`}
535
+ <//>
536
+ </p>
537
+ <${Chart} scale=${() => [0, tallest()]} ticks=${false} label="The seven tallest buildings, by height"
538
+ theme=${{ font: '"Archivo", system-ui, sans-serif', ink: "#17191c", muted: "#5d6168" }}>
539
+ <${Plot} rows=${() => props.rows} key="name" order=${sortBy("height", "desc")} on=${(d) => on() === d.name} keyboard=${true}>${Tower}<//>
540
+ <//>
541
+ <//>`;
542
+ }
543
+ ```
544
+
545
+ - Importing it touches no browser API (`slat()` runs at import, and works in Node), so frameworks that render on a server can import it; only drawing needs the browser.
546
+
547
+ The mount helper, `rhp-mount.js`; copy it as it is:
548
+
549
+ ```js
550
+ // Draws an rhp chart into an element of an app that is not a Solid app (React, Vue, Svelte, Angular, a plain script),
551
+ // and hands it new props later. Copy this file as it is.
552
+ import { render, createStore, reconcile, unwrap } from "@bezda/rhp/standalone";
553
+
554
+ // The chart keeps its own copy of plain data (arrays and plain objects): Solid's store changes the objects it holds, and
555
+ // those must not be the app's. Anything else (a Date, a Map, a function) is passed as it is.
556
+ function copy(value) {
557
+ if (Array.isArray(value)) return value.map(copy);
558
+ if (value === null || typeof value !== "object") return value;
559
+ const proto = Object.getPrototypeOf(value);
560
+ if (proto !== Object.prototype && proto !== null) return value;
561
+ const out = {};
562
+ for (const key of Object.keys(value)) out[key] = copy(value[key]);
563
+ return out;
564
+ }
565
+
566
+ // mountChart(TowersChart, element, { rows }) draws the chart into the element and returns
567
+ // set({ rows }): new values for the props it names; only what differs reaches the chart (one new number moves one bar)
568
+ // dispose(): removes the chart; call it when the element goes away
569
+ export function mountChart(chart, element, props = {}) {
570
+ const [state, setState] = createStore(copy(props));
571
+ const dispose = render(() => chart(state), element);
572
+ return {
573
+ set: (next) => setState(reconcile({ ...unwrap(state), ...copy(next) })),
574
+ dispose,
575
+ };
576
+ }
577
+ ```
578
+
579
+ - `mountChart(chart, element, props)` draws the chart into the element and returns `set(props)` and `dispose()`.
580
+ - The props live in a Solid store and new values arrive through `reconcile`, so only what differs changes: one new height moves one bar, and with the Plot's `key` each tower keeps its slat (checked).
581
+ - It copies plain data first, because `reconcile` changes the store's objects in place, and those must never be the app's objects (checked: the app's lists were unchanged after the chart switched between them).
582
+ - `dispose()` removes the chart and empties the element (checked).
583
+
584
+ **TypeScript**: `towers-chart.ts` and `rhp-mount.ts`.
585
+
586
+ ```ts
587
+ // The towers poster, written with rhp's html template: the same file in every app that is not a Solid app
588
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, createSignal, createMemo, Show } from "@bezda/rhp/standalone";
589
+
590
+ // One row of the data
591
+ export type TowerRow = { name: string; city: string; year: number; height: number };
592
+
593
+ // Slat types: one tower, with its name, its bar (one line per floor) and its height; the tower the reader is on turns red
594
+ const Tower = slat<TowerRow & { on: boolean }>({
595
+ thickness: 44,
596
+ room: { start: "auto", end: 60 },
597
+ css: `
598
+ .tower { cursor: default; outline: none; }
599
+ .tower:focus-visible { background: rgb(23 25 28 / .06); }
600
+ .name { max-width: 10em; font-size: 14px; font-weight: 600; line-height: 1.15; white-space: normal; text-align: end; }
601
+ .bar { --rhp-radius: 0px; background: repeating-linear-gradient(var(--rhp-toward-end), #2a2e34 0 6px, #464b53 6px 7px); }
602
+ .on .bar { background: repeating-linear-gradient(var(--rhp-toward-end), #c2381b 0 6px, #d9614a 6px 7px); }
603
+ .height { font-size: 13px; font-weight: 700; color: var(--rhp-muted); }
604
+ .on .height { color: #c2381b; }
605
+ `,
606
+ }, (d) => html`
607
+ <div class=${() => (d.on ? "tower on" : "tower")} data-name=${() => d.name}>
608
+ <${Label} edge="start" class="name">${() => d.name}<//>
609
+ <${Bar} to=${() => d.height} class="bar" />
610
+ <${Label} at=${() => d.height} class="height">${() => d.height} m<//>
611
+ </div>`);
612
+
613
+ // The chart: a poster with a one-line readout over the towers, ranked by height. Its data comes in as props.rows.
614
+ export function TowersChart(props: { rows: TowerRow[] }) {
615
+ const [on, setOn] = createSignal<string | null>(null); // the name of the tower the reader picked
616
+ const current = createMemo(() => props.rows.find((t) => t.name === on()));
617
+ const tallest = createMemo(() => Math.max(...props.rows.map((t) => t.height)));
618
+ // The name of the tower an element is in, or null (between towers, on the text)
619
+ const nameOf = (el: Element) => el.closest("[data-name]")?.getAttribute("data-name") ?? null;
620
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no tower the pick stays
621
+ const pick = (e: Event) => {
622
+ const name = nameOf(e.target as Element);
623
+ if (name != null) setOn(name);
624
+ };
625
+ // Leaving the poster: back to the tower that has focus (a keyboard's or a click's pick), or none
626
+ const leave = (e: PointerEvent) => {
627
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
628
+ const focused = document.activeElement;
629
+ setOn(focused && (e.currentTarget as Element).contains(focused) ? nameOf(focused) : null);
630
+ };
631
+
632
+ return html`
633
+ <${Poster} class="towers"
634
+ kicker="Skyscrapers, 2025"
635
+ title=${"Burj Khalifa still towers 149\u00a0m over the rest"}
636
+ dek="The seven tallest finished buildings in the world, by height to their architectural top."
637
+ note="Source: Council on Tall Buildings and Urban Habitat (CTBUH), 2025."
638
+ onPointerMove=${(e: PointerEvent) => e.pointerType !== "touch" && pick(e)}
639
+ onClick=${pick}
640
+ onFocusIn=${pick}
641
+ onPointerLeave=${leave}
642
+ onFocusOut=${(e: FocusEvent) => !(e.currentTarget as Element).contains(e.relatedTarget as Node | null) && setOn(null)}>
643
+ <p class="readout">
644
+ <${Show} when=${current} fallback="Point at a tower, tap it, or Tab to it.">
645
+ ${(t: () => TowerRow) => html`<b>${() => t().name}</b> in ${() => t().city}, finished ${() => t().year}`}
646
+ <//>
647
+ </p>
648
+ <${Chart} scale=${() => [0, tallest()]} ticks=${false} label="The seven tallest buildings, by height"
649
+ theme=${{ font: '"Archivo", system-ui, sans-serif', ink: "#17191c", muted: "#5d6168" }}>
650
+ <${Plot} rows=${() => props.rows} key="name" order=${sortBy("height", "desc")} on=${(d: TowerRow) => on() === d.name} keyboard=${true}>${Tower}<//>
651
+ <//>
652
+ <//>`;
653
+ }
654
+ ```
655
+
656
+ ```ts
657
+ // Draws an rhp chart into an element of an app that is not a Solid app (React, Vue, Svelte, Angular, a plain script),
658
+ // and hands it new props later. Copy this file as it is.
659
+ import { render, createStore, reconcile, unwrap } from "@bezda/rhp/standalone";
660
+ import type { JSX } from "solid-js";
661
+
662
+ // The chart keeps its own copy of plain data (arrays and plain objects): Solid's store changes the objects it holds, and
663
+ // those must not be the app's. Anything else (a Date, a Map, a function) is passed as it is.
664
+ function copy<T>(value: T): T {
665
+ if (Array.isArray(value)) return value.map(copy) as T;
666
+ if (value === null || typeof value !== "object") return value;
667
+ const proto = Object.getPrototypeOf(value);
668
+ if (proto !== Object.prototype && proto !== null) return value;
669
+ const out: Record<string, unknown> = {};
670
+ for (const [key, item] of Object.entries(value)) out[key] = copy(item);
671
+ return out as T;
672
+ }
673
+
674
+ export interface MountedChart<P> {
675
+ /** New values for the props it names; only what differs reaches the chart (one new number moves one bar). */
676
+ set(next: Partial<P>): void;
677
+ /** Removes the chart; call it when the element goes away. */
678
+ dispose(): void;
679
+ }
680
+
681
+ // mountChart(TowersChart, element, { rows }) draws the chart into the element
682
+ export function mountChart<P extends object>(chart: (props: P) => JSX.Element, element: HTMLElement, props: P): MountedChart<P> {
683
+ const [state, setState] = createStore<P>(copy(props));
684
+ const dispose = render(() => chart(state), element);
685
+ return {
686
+ set: (next) => setState(reconcile({ ...unwrap(state), ...copy(next) })),
687
+ dispose,
688
+ };
689
+ }
690
+ ```
691
+
692
+ - `closest("[data-name]")?.getAttribute("data-name")` instead of `dataset.name`: Angular's default `noPropertyAccessFromIndexSignature` rejects `dataset.name`.
693
+ - `import type { JSX } from "solid-js"` reads the types of the `solid-js` that npm installed with rhp; nothing from it runs.
694
+
695
+ ## 7. React
696
+
697
+ Detect: `react` and `react-dom` in package.json (Vite has `@vitejs/plugin-react`); Next.js is section 8.
698
+ Install: `npm install @bezda/rhp`.
699
+ `@bezda/rhp-react` is not on npm: use the wrapper below.
700
+
701
+ ```
702
+ index.html the fonts (section 2)
703
+ src/rhp/rhp-mount.js the mount helper (section 6), as it is
704
+ src/rhp/RhpChart.jsx the React wrapper, as it is
705
+ src/charts/Towers.jsx the chart and the React component that draws it
706
+ src/charts/towers-chart.css the poster's CSS (section 2)
707
+ src/data/towers.js the data (section 2)
708
+ src/App.jsx the app passes its data
709
+ ```
710
+
711
+ The wrapper, `src/rhp/RhpChart.jsx`:
712
+
713
+ ```jsx
714
+ "use client";
715
+ // <RhpChart chart={TowersChart} rows={towers} />: an rhp chart in a React app. Copy this file as it is.
716
+ import { useEffect, useLayoutEffect, useRef } from "react";
717
+ import { mountChart } from "./rhp-mount.js";
718
+
719
+ // The chart is drawn before the browser paints, so there is no empty frame (a server render runs neither hook)
720
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
721
+
722
+ // chart is a function of props that returns an html template, defined at the top level of a module (never inside a
723
+ // component). Every other prop goes to it, except className and style, which go on the <div> it draws into.
724
+ export function RhpChart({ chart, className, style, ...props }) {
725
+ const box = useRef(null);
726
+ const drawn = useRef(null);
727
+
728
+ // After each render: draw the chart the first time, or again when `chart` is a new function (its module was edited
729
+ // during development); otherwise hand it the props that are no longer the same value.
730
+ useBeforePaint(() => {
731
+ const now = drawn.current;
732
+ if (now?.chart !== chart) {
733
+ now?.mounted.dispose();
734
+ drawn.current = { chart, props, mounted: mountChart(chart, box.current, props) };
735
+ return;
736
+ }
737
+ const changed = {};
738
+ for (const key of new Set([...Object.keys(now.props), ...Object.keys(props)])) {
739
+ if (now.props[key] !== props[key]) changed[key] = props[key];
740
+ }
741
+ now.props = props;
742
+ if (Object.keys(changed).length) now.mounted.set(changed);
743
+ });
744
+
745
+ // Remove the chart when the component goes away (StrictMode's trial unmount included: the effect above redraws it)
746
+ useBeforePaint(() => () => {
747
+ drawn.current?.mounted.dispose();
748
+ drawn.current = null;
749
+ }, []);
750
+
751
+ return <div ref={box} className={className} style={style} />;
752
+ }
753
+ ```
754
+
755
+ - `<RhpChart chart={TowersChart} rows={rows} />`: every prop except `chart`, `className` and `style` goes to the chart; `className` and `style` go on the `<div>` it draws into.
756
+ - After each render it hands the chart only the props that are a different value, and `reconcile` then passes on only what differs inside them.
757
+ Give it a new array when the data changes (React's own rule): a list changed in place keeps the same value, and the chart does not see the change.
758
+ - `chart` must be a function defined at the top level of a module: a new function (an arrow written inside a component) draws a new chart on every render.
759
+ - StrictMode mounts, unmounts and mounts again in development: the wrapper removes the first chart, and the page has one (checked).
760
+ - When the component unmounts, the chart is disposed (checked: nothing left).
761
+ - The `"use client"` line is for Next.js; Vite's build ignored it without a warning.
762
+
763
+ The chart and its component, `src/charts/Towers.jsx`: section 6's `towers-chart.js` with three changes.
764
+ Its first lines import the wrapper and the CSS, `TowersChart` is no longer exported, and the component comes last:
765
+
766
+ ```jsx
767
+ // The towers poster: an rhp chart written with the html template, and the React component that draws it
768
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, createSignal, createMemo, Show } from "@bezda/rhp/standalone";
769
+ import { RhpChart } from "../rhp/RhpChart.jsx";
770
+ import "./towers-chart.css";
771
+
772
+ // ... the slat type and the chart function of section 6, unchanged, but with no "export" before "function TowersChart" ...
773
+
774
+ // The React component: <Towers rows={towers} />
775
+ export function Towers(props) {
776
+ return <RhpChart chart={TowersChart} {...props} />;
777
+ }
778
+ ```
779
+
780
+ `src/App.jsx`:
781
+
782
+ ```jsx
783
+ import { Towers } from "./charts/Towers.jsx";
784
+ import { TOWERS } from "./data/towers.js";
785
+
786
+ export default function App() {
787
+ return (
788
+ <main style={{ padding: "32px 16px" }}>
789
+ <Towers rows={TOWERS} />
790
+ </main>
791
+ );
792
+ }
793
+ ```
794
+
795
+ - Why the chart sits in the component's file: Next.js's Fast Refresh treats a module whose exports are all capitalized functions as React components, so an edit to a separate chart module that exports only `TowersChart` never reached the page: the chart stayed as it was until a reload (checked).
796
+ In one file, an edit re-renders `Towers`, which draws the new chart.
797
+ The Vite dev server passed the separate module's edits on, but one file works in both.
798
+ - Fast Refresh: an edit to `Towers.jsx`, the data, `RhpChart.jsx`, `rhp-mount.js` or `App.jsx` updated the page without a reload, with one chart (checked).
799
+
800
+ **TypeScript**: `src/rhp/rhp-mount.ts` (section 6), `src/rhp/RhpChart.tsx`, and `src/charts/Towers.tsx`, which is section 6's `towers-chart.ts` with the same three changes.
801
+ Checked with `tsc -b` (TypeScript 6.0, strict by default) and `vite build`.
802
+
803
+ ```tsx
804
+ "use client";
805
+ // <RhpChart chart={TowersChart} rows={towers} />: an rhp chart in a React app. Copy this file as it is.
806
+ import { useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
807
+ import type { JSX } from "solid-js";
808
+ import { mountChart, type MountedChart } from "./rhp-mount";
809
+
810
+ // The chart is drawn before the browser paints, so there is no empty frame (a server render runs neither hook)
811
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
812
+
813
+ type Chart<P> = (props: P) => JSX.Element;
814
+ type Drawn<P> = { chart: Chart<P>; props: P; mounted: MountedChart<P> };
815
+
816
+ // chart is a function of props that returns an html template, defined at the top level of a module (never inside a
817
+ // component). Every other prop goes to it, except className and style, which go on the <div> it draws into.
818
+ export function RhpChart<P extends object>({ chart, className, style, ...rest }: { chart: Chart<P>; className?: string; style?: CSSProperties } & P) {
819
+ const props = rest as unknown as P;
820
+ const box = useRef<HTMLDivElement>(null);
821
+ const drawn = useRef<Drawn<P> | null>(null);
822
+
823
+ // After each render: draw the chart the first time, or again when `chart` is a new function (its module was edited
824
+ // during development); otherwise hand it the props that are no longer the same value.
825
+ useBeforePaint(() => {
826
+ const now = drawn.current;
827
+ if (now?.chart !== chart) {
828
+ now?.mounted.dispose();
829
+ drawn.current = { chart, props, mounted: mountChart(chart, box.current!, props) };
830
+ return;
831
+ }
832
+ const before = now.props as Record<string, unknown>;
833
+ const after = props as Record<string, unknown>;
834
+ const changed: Record<string, unknown> = {};
835
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
836
+ if (before[key] !== after[key]) changed[key] = after[key];
837
+ }
838
+ now.props = props;
839
+ if (Object.keys(changed).length) now.mounted.set(changed as Partial<P>);
840
+ });
841
+
842
+ // Remove the chart when the component goes away (StrictMode's trial unmount included: the effect above redraws it)
843
+ useBeforePaint(() => () => {
844
+ drawn.current?.mounted.dispose();
845
+ drawn.current = null;
846
+ }, []);
847
+
848
+ return <div ref={box} className={className} style={style} />;
849
+ }
850
+ ```
851
+
852
+ ```tsx
853
+ // The towers poster: an rhp chart written with the html template, and the React component that draws it
854
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, createSignal, createMemo, Show } from "@bezda/rhp/standalone";
855
+ import { RhpChart } from "../rhp/RhpChart";
856
+ import "./towers-chart.css";
857
+
858
+ // ... the type, the slat type and the chart function of section 6's towers-chart.ts, unchanged, but with no "export" before "function TowersChart" ...
859
+
860
+ // The React component: <Towers rows={towers} />
861
+ export function Towers(props: { rows: TowerRow[] }) {
862
+ return <RhpChart chart={TowersChart} {...props} />;
863
+ }
864
+ ```
865
+
866
+ The data file imports the type: `import type { TowerRow } from "../charts/Towers";`.
867
+
868
+ ## 8. Next.js
869
+
870
+ **App Router** (`app/`): the chart is a client component that the server never renders.
871
+ The page imports a small client file that loads the chart with `next/dynamic` and `ssr: false`, so the server neither draws the chart nor loads rhp.
872
+ Checked: `next build` passes (Next.js 16.3 with Turbopack, TypeScript), the prerendered page has no chart in it, and no server file contains rhp.
873
+
874
+ ```
875
+ app/layout.tsx the fonts, with next/font
876
+ app/page.tsx a server component: it has the data and renders <ClientOnlyTowers rows={...} />
877
+ components/towers/ClientOnlyTowers.tsx loads Towers.tsx in the browser only, and imports the poster's CSS
878
+ components/towers/Towers.tsx the chart and the React component (section 7), with "use client"
879
+ components/towers/towers-chart.css the poster's CSS, with the font variables
880
+ lib/rhp/RhpChart.tsx the React wrapper (section 7), as it is
881
+ lib/rhp/rhp-mount.ts the mount helper (section 6), as it is
882
+ data/towers.ts the data
883
+ ```
884
+
885
+ `components/towers/ClientOnlyTowers.tsx`:
886
+
887
+ ```tsx
888
+ "use client";
889
+ import dynamic from "next/dynamic";
890
+ import "./towers-chart.css";
891
+
892
+ // What pages render: <ClientOnlyTowers rows={towers} />. The chart is drawn in the browser only: the server sends an
893
+ // empty box and never loads rhp. The CSS is imported here so that it comes with the page.
894
+ export const ClientOnlyTowers = dynamic(() => import("./Towers").then((m) => m.Towers), { ssr: false });
895
+ ```
896
+
897
+ `app/page.tsx`:
898
+
899
+ ```tsx
900
+ import { ClientOnlyTowers } from "@/components/towers/ClientOnlyTowers";
901
+ import { TOWERS } from "@/data/towers";
902
+
903
+ // A server component: it holds (or fetches) the data and passes it to the chart
904
+ export default function Home() {
905
+ return (
906
+ <main className="px-4 py-8">
907
+ <ClientOnlyTowers rows={TOWERS} />
908
+ </main>
909
+ );
910
+ }
911
+ ```
912
+
913
+ `components/towers/Towers.tsx` is section 7's `Towers.tsx` with `"use client"` first, the wrapper imported from `@/lib/rhp/RhpChart`, no CSS import, and the next/font variable in the theme:
914
+
915
+ ```tsx
916
+ "use client";
917
+ // The towers poster: an rhp chart written with the html template, and the React component that draws it
918
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, createSignal, createMemo, Show } from "@bezda/rhp/standalone";
919
+ import { RhpChart } from "@/lib/rhp/RhpChart";
920
+
921
+ // ... and in the Chart:
922
+ theme=${{ font: "var(--font-archivo), system-ui, sans-serif", ink: "#17191c", muted: "#5d6168" }}>
923
+ ```
924
+
925
+ `app/layout.tsx`: next/font serves the fonts from the app under generated names, so the CSS and the theme name them by their variables:
926
+
927
+ ```tsx
928
+ import type { Metadata } from "next";
929
+ import { Antonio, Archivo, Geist, Geist_Mono } from "next/font/google";
930
+ import "./globals.css";
931
+
932
+ const geistSans = Geist({
933
+ variable: "--font-geist-sans",
934
+ subsets: ["latin"],
935
+ });
936
+
937
+ const geistMono = Geist_Mono({
938
+ variable: "--font-geist-mono",
939
+ subsets: ["latin"],
940
+ });
941
+
942
+ // The poster's fonts, as CSS variables its CSS and the chart's theme use
943
+ const antonio = Antonio({ variable: "--font-antonio", subsets: ["latin"] });
944
+ const archivo = Archivo({ variable: "--font-archivo", subsets: ["latin"] });
945
+
946
+ export const metadata: Metadata = {
947
+ title: "Create Next App",
948
+ description: "Generated by create next app",
949
+ };
950
+
951
+ export default function RootLayout({ children }: LayoutProps<"/">) {
952
+ return (
953
+ <html
954
+ lang="en"
955
+ className={`${geistSans.variable} ${geistMono.variable} ${antonio.variable} ${archivo.variable} h-full antialiased`}
956
+ >
957
+ <body className="min-h-full flex flex-col">{children}</body>
958
+ </html>
959
+ );
960
+ }
961
+ ```
962
+
963
+ In `towers-chart.css`, the two font stacks become `var(--font-archivo), system-ui, sans-serif` (on `.towers`) and `var(--font-antonio), "Arial Narrow", sans-serif` (on `.headline`).
964
+
965
+ - `ssr: false` is allowed only in a client component: with `dynamic(..., { ssr: false })` in `app/page.tsx`, `next build` failed ("`ssr: false` is not allowed with `next/dynamic` in Server Components").
966
+ That is why the page imports `ClientOnlyTowers.tsx`.
967
+ - The rows pass from the server component to the client as props, so they must be data React can send: plain objects, arrays, strings and numbers (checked with those).
968
+ - The server's HTML has no chart, so the content below it moves down once the chart is drawn; give the parent a `min-height` if that matters.
969
+ - Development: StrictMode (on by default in the App Router) leaves one chart, and Fast Refresh updated the page after an edit to every file, without a reload (checked).
970
+ - Keep the data out of `components/towers/`: a `towers.ts` beside `Towers.tsx` broke the build, because macOS and Windows ignore case in file names and `import("./Towers")` found the data file (TS1149, checked).
971
+ - create-next-app's own page pads its `main` with `px-16` at every width, which leaves the chart 262px of a 390px phone and 192px of a 320px one.
972
+ When you add the chart to that page, let its box break out of the padding on phones: `<div className="-mx-12 self-stretch sm:mx-0">` around it (the `main` is a flex column, so the stretched box takes the margins back).
973
+ Measured in Next.js 16's starter: 358px at 390, 288px at 320, no sideways scroll, and the same box as before from 640px.
974
+
975
+ **Pages Router** (`pages/`): a page may call `dynamic` with `ssr: false` itself.
976
+ `Towers.jsx` is section 7's file with the wrapper imported from `@/lib/rhp/RhpChart.jsx` and no CSS import.
977
+
978
+ `pages/index.js`:
979
+
980
+ ```jsx
981
+ import dynamic from "next/dynamic";
982
+ import { TOWERS } from "@/data/towers.js";
983
+
984
+ // The chart is drawn in the browser only: the server sends an empty box and never loads rhp
985
+ const Towers = dynamic(() => import("@/components/towers/Towers.jsx").then((m) => m.Towers), { ssr: false });
986
+
987
+ // The data comes from the server, at build time here (getServerSideProps works the same way)
988
+ export function getStaticProps() {
989
+ return { props: { towers: TOWERS } };
990
+ }
991
+
992
+ export default function Home({ towers }) {
993
+ return (
994
+ <main style={{ padding: "32px 16px" }}>
995
+ <Towers rows={towers} />
996
+ </main>
997
+ );
998
+ }
999
+ ```
1000
+
1001
+ `pages/_app.js` and `pages/_document.js`:
1002
+
1003
+ ```jsx
1004
+ import "@/styles/globals.css";
1005
+ import "@/components/towers/towers-chart.css";
1006
+
1007
+ export default function App({ Component, pageProps }) {
1008
+ return <Component {...pageProps} />;
1009
+ }
1010
+ ```
1011
+
1012
+ ```jsx
1013
+ import { Html, Head, Main, NextScript } from "next/document";
1014
+
1015
+ export default function Document() {
1016
+ return (
1017
+ <Html lang="en">
1018
+ <Head>
1019
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
1020
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
1021
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap" />
1022
+ </Head>
1023
+ <body>
1024
+ <Main />
1025
+ <NextScript />
1026
+ </body>
1027
+ </Html>
1028
+ );
1029
+ }
1030
+ ```
1031
+
1032
+ - The Pages Router allows global CSS only in `pages/_app.js`: importing `towers-chart.css` in `Towers.jsx` failed `next build` ("Global CSS cannot be imported from files other than your Custom <App>", checked).
1033
+ - Checked: `next build`, `next start`, no rhp in the server output.
1034
+
1035
+ ## 9. Vue and Nuxt
1036
+
1037
+ **Vue** (Vite): `npm install @bezda/rhp`.
1038
+
1039
+ ```
1040
+ index.html the fonts (section 2)
1041
+ src/rhp/rhp-mount.js the mount helper (section 6)
1042
+ src/charts/towers-chart.js the chart module (section 6)
1043
+ src/charts/towers-chart.css the poster's CSS (section 2)
1044
+ src/data/towers.js the data (section 2)
1045
+ src/components/Towers.vue the component that draws the chart
1046
+ src/App.vue the app passes its data
1047
+ ```
1048
+
1049
+ `src/components/Towers.vue`:
1050
+
1051
+ ```vue
1052
+ <script setup>
1053
+ import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
1054
+ import { mountChart } from "../rhp/rhp-mount.js";
1055
+ import { TowersChart } from "../charts/towers-chart.js";
1056
+ import "../charts/towers-chart.css";
1057
+
1058
+ const props = defineProps({ rows: { type: Array, required: true } });
1059
+ const box = useTemplateRef("box");
1060
+ let chart;
1061
+
1062
+ onMounted(() => {
1063
+ chart = mountChart(TowersChart, box.value, { rows: props.rows });
1064
+ });
1065
+ // deep: a change inside the list (one tower's height) reaches the chart too
1066
+ watch(() => props.rows, (rows) => chart.set({ rows }), { deep: true });
1067
+ onBeforeUnmount(() => chart.dispose());
1068
+ </script>
1069
+
1070
+ <template>
1071
+ <div ref="box"></div>
1072
+ </template>
1073
+ ```
1074
+
1075
+ `src/App.vue`:
1076
+
1077
+ ```vue
1078
+ <script setup>
1079
+ import Towers from "./components/Towers.vue";
1080
+ import { TOWERS } from "./data/towers.js";
1081
+ </script>
1082
+
1083
+ <template>
1084
+ <main style="padding: 32px 16px">
1085
+ <Towers :rows="TOWERS" />
1086
+ </main>
1087
+ </template>
1088
+ ```
1089
+
1090
+ - `watch(..., { deep: true })` hands the chart a change inside the list too (checked: `rows.value[2].height = 700` moved that bar).
1091
+ - `onBeforeUnmount` disposes the chart (checked with `v-if`).
1092
+ - Import the CSS file in the script.
1093
+ A `<style scoped>` block does not reach the chart: Vue compiled `.towers` to `.towers[data-v-...]`, and rhp's elements have no such attribute (checked: no background).
1094
+ - Development: an edit to the chart module, the data, the component or the mount helper updated the page without a reload, with one chart (checked).
1095
+
1096
+ **Nuxt** (Nuxt 4, whose source folder is `app/`): the same component, in `app/components/Towers.vue` (Nuxt imports components by name), with its imports written from `~/`:
1097
+
1098
+ ```js
1099
+ import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
1100
+ import { mountChart } from "~/rhp/rhp-mount.js";
1101
+ import { TowersChart } from "~/charts/towers-chart.js";
1102
+ import "~/charts/towers-chart.css";
1103
+ ```
1104
+
1105
+ The helper, the chart module and the CSS go in `app/rhp/` and `app/charts/`, the data in `app/data/towers.js`.
1106
+ `app/pages/index.vue` (with `app/app.vue` holding `<NuxtPage />`):
1107
+
1108
+ ```vue
1109
+ <script setup>
1110
+ import { TOWERS } from "~/data/towers.js";
1111
+ </script>
1112
+
1113
+ <template>
1114
+ <main style="padding: 32px 16px">
1115
+ <Towers :rows="TOWERS" />
1116
+ </main>
1117
+ </template>
1118
+ ```
1119
+
1120
+ `nuxt.config.ts`, with the fonts:
1121
+
1122
+ ```ts
1123
+ // https://nuxt.com/docs/api/configuration/nuxt-config
1124
+ export default defineNuxtConfig({
1125
+ compatibilityDate: '2025-07-15',
1126
+ devtools: { enabled: true },
1127
+ // The poster's fonts
1128
+ app: {
1129
+ head: {
1130
+ link: [
1131
+ { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
1132
+ { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
1133
+ { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap' },
1134
+ ],
1135
+ },
1136
+ },
1137
+ })
1138
+ ```
1139
+
1140
+ - The server renders the component's empty `<div>`, and `onMounted` draws the chart in the browser after hydration (checked: no chart in the server's HTML, no warning in the browser).
1141
+ - Do not name it `Towers.client.vue`.
1142
+ During hydration Nuxt runs a `.client` component's `onMounted` before its template exists, so the ref is null and drawing failed (`Cannot read properties of null (reading 'firstChild')`, checked).
1143
+ The plain component needs nothing more.
1144
+
1145
+ ## 10. Svelte and SvelteKit
1146
+
1147
+ **Svelte** (Vite, Svelte 5): `npm install @bezda/rhp`.
1148
+
1149
+ ```
1150
+ index.html the fonts (section 2)
1151
+ src/lib/rhp/rhp-mount.js the mount helper (section 6)
1152
+ src/lib/charts/towers-chart.js the chart module (section 6)
1153
+ src/lib/charts/towers-chart.css the poster's CSS (section 2)
1154
+ src/lib/data/towers.js the data (section 2)
1155
+ src/lib/Towers.svelte the component that draws the chart
1156
+ src/App.svelte the app passes its data
1157
+ ```
1158
+
1159
+ `src/lib/Towers.svelte`:
1160
+
1161
+ ```svelte
1162
+ <script>
1163
+ import { onMount } from "svelte";
1164
+ import { mountChart } from "./rhp/rhp-mount.js";
1165
+ import { TowersChart } from "./charts/towers-chart.js";
1166
+ import "./charts/towers-chart.css";
1167
+
1168
+ let { rows } = $props();
1169
+ let box;
1170
+ let chart;
1171
+
1172
+ onMount(() => {
1173
+ chart = mountChart(TowersChart, box, { rows });
1174
+ return () => chart.dispose();
1175
+ });
1176
+
1177
+ // Runs after onMount, then whenever rows changes, in place too (set reads every row, so Svelte tracks them all)
1178
+ $effect(() => chart.set({ rows }));
1179
+ </script>
1180
+
1181
+ <div bind:this={box}></div>
1182
+ ```
1183
+
1184
+ `src/App.svelte`:
1185
+
1186
+ ```svelte
1187
+ <script>
1188
+ import Towers from "./lib/Towers.svelte";
1189
+ import { TOWERS } from "./lib/data/towers.js";
1190
+ </script>
1191
+
1192
+ <main style="padding: 32px 16px">
1193
+ <Towers rows={TOWERS} />
1194
+ </main>
1195
+ ```
1196
+
1197
+ - `$effect` runs after `onMount` (it is declared after it), then whenever `rows` changes, inside the list too: `set` reads every row, so Svelte tracks them all (checked: `rows[2].height = 700` on a `$state` list moved that bar).
1198
+ - `onMount`'s returned function disposes the chart (checked with `{#if}`).
1199
+ - Import the CSS file in the script.
1200
+ A component's `<style>` is scoped: Svelte dropped the poster's rules as "Unused CSS selector" (checked).
1201
+ - Development: an edit to the chart module, the data, the component or the mount helper updated the page without a reload (checked).
1202
+
1203
+ **SvelteKit** (SvelteKit 3, TypeScript as its starter sets it up): the same component with types, the typed helper and chart module (section 6) in `src/lib/rhp/` and `src/lib/charts/`, and the data from the page's `load`.
1204
+
1205
+ `src/lib/Towers.svelte`:
1206
+
1207
+ ```svelte
1208
+ <!-- <Towers rows={towers} />: the towers poster. The server renders the empty box; the chart is drawn in the browser. -->
1209
+ <script lang="ts">
1210
+ import { onMount } from "svelte";
1211
+ import { mountChart, type MountedChart } from "./rhp/rhp-mount";
1212
+ import { TowersChart, type TowerRow } from "./charts/towers-chart";
1213
+ import "./charts/towers-chart.css";
1214
+
1215
+ let { rows }: { rows: TowerRow[] } = $props();
1216
+ let box: HTMLDivElement;
1217
+ let chart: MountedChart<{ rows: TowerRow[] }>;
1218
+
1219
+ onMount(() => {
1220
+ chart = mountChart(TowersChart, box, { rows });
1221
+ return () => chart.dispose();
1222
+ });
1223
+
1224
+ // Runs after onMount, then whenever rows changes, in place too (set reads every row, so Svelte tracks them all)
1225
+ $effect(() => chart.set({ rows }));
1226
+ </script>
1227
+
1228
+ <div bind:this={box}></div>
1229
+ ```
1230
+
1231
+ `src/routes/+page.ts` and `src/routes/+page.svelte`:
1232
+
1233
+ ```ts
1234
+ import { TOWERS } from "#lib/data/towers.js";
1235
+ import type { PageLoad } from "./$types";
1236
+
1237
+ // The page's data: from a file here; a fetch or a database works the same way
1238
+ export const load: PageLoad = () => ({ towers: TOWERS });
1239
+ ```
1240
+
1241
+ ```svelte
1242
+ <script lang="ts">
1243
+ import Towers from "#lib/Towers.svelte";
1244
+ import type { PageProps } from "./$types";
1245
+
1246
+ let { data }: PageProps = $props();
1247
+ </script>
1248
+
1249
+ <main style="padding: 32px 16px">
1250
+ <Towers rows={data.towers} />
1251
+ </main>
1252
+ ```
1253
+
1254
+ The fonts go in `src/app.html`, before `%sveltekit.head%`:
1255
+
1256
+ ```html
1257
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
1258
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1259
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap" />
1260
+ %sveltekit.head%
1261
+ ```
1262
+
1263
+ - SvelteKit 3 maps `#lib/*` to `src/lib/*` (package.json `imports`).
1264
+ Write the file's extension, and `.js` for a `.ts` file (`#lib/data/towers.js`): without it, `svelte-check` could not find the module (checked).
1265
+ A SvelteKit 2 project writes `$lib/...` instead (not tested).
1266
+ - The server renders the empty box; `onMount` and `$effect` run in the browser only.
1267
+ The server imports the chart module, which is safe (section 6).
1268
+ - Checked: `svelte-check` (0 errors, 0 warnings), `vite build`, `vite preview`.
1269
+ A local build prints adapter-auto's "Could not detect a supported production environment", which is expected.
1270
+
1271
+ ## 11. Angular
1272
+
1273
+ Angular 22, standalone components, zoneless (the default of a new app): `npm install @bezda/rhp`.
1274
+
1275
+ ```
1276
+ src/index.html the fonts
1277
+ src/app/rhp/rhp-mount.ts the mount helper (section 6, TypeScript)
1278
+ src/app/towers/towers-chart.ts the chart module (section 6, TypeScript)
1279
+ src/app/towers/towers-chart.css the poster's CSS (section 2)
1280
+ src/app/towers/towers.ts the component that draws the chart
1281
+ src/app/data/towers.ts the data
1282
+ src/app/app.ts the app passes its data
1283
+ ```
1284
+
1285
+ `src/app/towers/towers.ts`:
1286
+
1287
+ ```ts
1288
+ import { Component, ElementRef, OnDestroy, ViewEncapsulation, afterNextRender, effect, input, viewChild } from "@angular/core";
1289
+ import { mountChart, type MountedChart } from "../rhp/rhp-mount";
1290
+ import { TowersChart, type TowerRow } from "./towers-chart";
1291
+
1292
+ // <app-towers [rows]="towers" />: the towers poster
1293
+ @Component({
1294
+ selector: "app-towers",
1295
+ template: `<div #box></div>`,
1296
+ styleUrl: "./towers-chart.css",
1297
+ // rhp makes the chart's elements, not this template, so the poster's CSS must not be scoped to the template
1298
+ encapsulation: ViewEncapsulation.None,
1299
+ })
1300
+ export class Towers implements OnDestroy {
1301
+ readonly rows = input.required<TowerRow[]>();
1302
+ private readonly box = viewChild.required<ElementRef<HTMLDivElement>>("box");
1303
+ private chart?: MountedChart<{ rows: TowerRow[] }>;
1304
+
1305
+ constructor() {
1306
+ // Draw the chart after the first render, in the browser only (never during server rendering)
1307
+ afterNextRender(() => {
1308
+ this.chart = mountChart(TowersChart, this.box().nativeElement, { rows: this.rows() });
1309
+ });
1310
+ // Hand it the new rows whenever the input changes
1311
+ effect(() => {
1312
+ const rows = this.rows();
1313
+ this.chart?.set({ rows });
1314
+ });
1315
+ }
1316
+
1317
+ ngOnDestroy() {
1318
+ this.chart?.dispose();
1319
+ }
1320
+ }
1321
+ ```
1322
+
1323
+ `src/app/app.ts`:
1324
+
1325
+ ```ts
1326
+ import { Component, signal } from '@angular/core';
1327
+ import { Towers } from './towers/towers';
1328
+ import { TOWERS } from './data/towers';
1329
+
1330
+ // The app holds the data and passes it to the chart
1331
+ @Component({
1332
+ imports: [Towers],
1333
+ selector: 'app-root',
1334
+ template: `
1335
+ <main style="padding: 32px 16px">
1336
+ <app-towers [rows]="towers()" />
1337
+ </main>
1338
+ `,
1339
+ })
1340
+ export class App {
1341
+ protected readonly towers = signal(TOWERS);
1342
+ }
1343
+ ```
1344
+
1345
+ The fonts, in `src/index.html`'s head:
1346
+
1347
+ ```html
1348
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1349
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1350
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">
1351
+ ```
1352
+
1353
+ - `afterNextRender` draws the chart after the first render, in the browser only, so the component suits Angular's server rendering too (not tested: the tested app has none).
1354
+ - `effect` hands the chart new rows whenever the input changes.
1355
+ Give it a new array (signals compare by reference): `rows.update((rows) => rows.map(...))` moved one bar (checked).
1356
+ - `ngOnDestroy` disposes the chart (checked with `@if`).
1357
+ - The poster's CSS is the component's `styleUrl` with `encapsulation: ViewEncapsulation.None`.
1358
+ With Angular's default (emulated) encapsulation, the CSS is scoped to the component's template and never reaches rhp's elements (checked: no background).
1359
+ `src/styles.css` works too.
1360
+ - Never import a CSS file from a `.ts` file: TypeScript 6 fails the build with TS2882 (no types for a side-effect import, checked).
1361
+ - Checked: `ng build`, the built `dist/angular/browser` served as static files.
1362
+ Apps that still use zone.js were not tested.
1363
+
1364
+ ## 12. Astro
1365
+
1366
+ **Without a framework integration**, a custom element in an Astro component draws the html-template chart in the browser.
1367
+ `src/components/Towers.astro`, with the helper and the chart module (section 6) in `src/rhp/` and `src/charts/`:
1368
+
1369
+ ```astro
1370
+ ---
1371
+ // <Towers rows={towers} />: the towers poster in an Astro page, drawn in the browser by a custom element
1372
+ import "../charts/towers-chart.css";
1373
+
1374
+ const { rows } = Astro.props;
1375
+ ---
1376
+
1377
+ <towers-chart data-rows={JSON.stringify(rows)}></towers-chart>
1378
+
1379
+ <script>
1380
+ import { mountChart } from "../rhp/rhp-mount.js";
1381
+ import { TowersChart } from "../charts/towers-chart.js";
1382
+
1383
+ // The chart is drawn when the element enters the page and removed when it leaves (view transitions included).
1384
+ // A script can give it new data: element.dataset.rows = JSON.stringify(rows)
1385
+ class TowersChartElement extends HTMLElement {
1386
+ static observedAttributes = ["data-rows"];
1387
+ chart?: ReturnType<typeof mountChart>;
1388
+
1389
+ rows() {
1390
+ return JSON.parse(this.dataset.rows ?? "[]");
1391
+ }
1392
+
1393
+ connectedCallback() {
1394
+ this.chart = mountChart(TowersChart, this, { rows: this.rows() });
1395
+ }
1396
+
1397
+ attributeChangedCallback() {
1398
+ this.chart?.set({ rows: this.rows() });
1399
+ }
1400
+
1401
+ disconnectedCallback() {
1402
+ this.chart?.dispose();
1403
+ this.chart = undefined;
1404
+ }
1405
+ }
1406
+
1407
+ customElements.define("towers-chart", TowersChartElement);
1408
+ </script>
1409
+
1410
+ <style>
1411
+ towers-chart { display: block; }
1412
+ </style>
1413
+ ```
1414
+
1415
+ `src/pages/index.astro`:
1416
+
1417
+ ```astro
1418
+ ---
1419
+ import Towers from "../components/Towers.astro";
1420
+ import { TOWERS } from "../data/towers.js";
1421
+ ---
1422
+
1423
+ <html lang="en">
1424
+ <head>
1425
+ <meta charset="utf-8" />
1426
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
1427
+ <link rel="icon" href="/favicon.ico" />
1428
+ <meta name="viewport" content="width=device-width" />
1429
+ <meta name="generator" content={Astro.generator} />
1430
+ <title>Astro</title>
1431
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
1432
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1433
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap" />
1434
+ </head>
1435
+ <body>
1436
+ <main style="padding: 32px 16px">
1437
+ <Towers rows={TOWERS} />
1438
+ </main>
1439
+ </body>
1440
+ </html>
1441
+ ```
1442
+
1443
+ - The data goes into the element as JSON in `data-rows`; the server's HTML has the element and no chart.
1444
+ - A script can give it new data (`element.dataset.rows = JSON.stringify(rows)`), and removing the element disposes the chart (checked).
1445
+ - Import the CSS in the frontmatter.
1446
+ A component's `<style>` is scoped: Astro compiled `.towers` to `.towers[data-astro-cid-...]`, which rhp's elements never have (checked: no background).
1447
+ The `towers-chart { display: block; }` rule works there because the element is in the component's own markup.
1448
+
1449
+ **With `@astrojs/solid-js`** (`integrations: [solidJs()]` in `astro.config.mjs`), the JSX chart of section 4 is an island:
1450
+
1451
+ ```astro
1452
+ ---
1453
+ import { TowersChart } from "../components/TowersChart.jsx";
1454
+ import { TOWERS } from "../data/towers.js";
1455
+ ---
1456
+ ```
1457
+
1458
+ The island, in the page's body:
1459
+
1460
+ ```astro
1461
+ <TowersChart client:load rows={TOWERS} />
1462
+ ```
1463
+
1464
+ - `client:load`: Astro draws the chart on the server and hydrates it in the browser (checked: the server's HTML has the poster and its 7 slats, and the browser logs no hydration warning).
1465
+ - `client:visible` does the same once the chart scrolls into view, and `client:only="solid-js"` sends nothing from the server and draws in the browser; both checked.
1466
+
1467
+ ## 13. Check a chart inside an app
1468
+
1469
+ The checker mounts a file's default export, or its only exported component, with no props.
1470
+ A chart that takes its data from the app therefore needs a small entry file that draws it with fixed data, its CSS and its fonts.
1471
+ Write it next to the chart, run the checker on it, and keep it or delete it: the app never imports it.
1472
+
1473
+ For a chart module (Vue, Nuxt, Svelte, SvelteKit, Astro):
1474
+
1475
+ ```js
1476
+ // The chart with fixed data, for the checker: npx -y @bezda/rhp-mcp check src/charts/towers-chart.check.js
1477
+ // The app never imports this file. It loads the chart's CSS and the fonts the app loads.
1478
+ import { TowersChart } from "./towers-chart.js";
1479
+ import { TOWERS } from "../data/towers.js";
1480
+ import "./towers-chart.css";
1481
+
1482
+ document.head.insertAdjacentHTML("beforeend", '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">');
1483
+
1484
+ export default () => TowersChart({ rows: TOWERS });
1485
+ ```
1486
+
1487
+ For React and Next.js, render the component; the project's package.json (react without solid-js), or an import from `react`, tells the checker that the file is React:
1488
+
1489
+ ```jsx
1490
+ // The chart with fixed data, for the checker: npx -y @bezda/rhp-mcp check src/charts/towers.check.jsx
1491
+ // The app never imports this file. It loads the fonts the app's index.html loads.
1492
+ import { StrictMode } from "react";
1493
+ import { Towers } from "./Towers.jsx";
1494
+ import { TOWERS } from "../data/towers.js";
1495
+
1496
+ document.head.insertAdjacentHTML("beforeend", '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">');
1497
+
1498
+ export default function Check() {
1499
+ return <StrictMode><Towers rows={TOWERS} /></StrictMode>;
1500
+ }
1501
+ ```
1502
+
1503
+ For Solid:
1504
+
1505
+ ```jsx
1506
+ // The chart with fixed data, for the checker: npx -y @bezda/rhp-mcp check src/charts/TowersChart.check.jsx
1507
+ // The app never imports this file. It loads the fonts the app's index.html loads.
1508
+ import { TowersChart } from "./TowersChart.jsx";
1509
+ import { TOWERS } from "../data/towers.js";
1510
+
1511
+ document.head.insertAdjacentHTML("beforeend", '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">');
1512
+
1513
+ export default function Check() {
1514
+ return <TowersChart rows={TOWERS} />;
1515
+ }
1516
+ ```
1517
+
1518
+ - Next.js: the entry defines the next/font variables too:
1519
+
1520
+ ```tsx
1521
+ document.head.insertAdjacentHTML("beforeend", `
1522
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Antonio:wght@700&family=Archivo:wght@400;600;700&display=swap">
1523
+ <style>:root { --font-antonio: "Antonio"; --font-archivo: "Archivo"; }</style>`);
1524
+ ```
1525
+
1526
+ - TypeScript apps take `.ts` and `.tsx` entries, and their build type-checks them like any other file.
1527
+ - Angular: name the entry `.js`, so that the app's TypeScript build (which includes every `src/**/*.ts`) skips it; a `.ts` entry with a CSS import failed `ng build` (TS2882).
1528
+ - The checker found no problems in each of the eleven entries tested: React, React with TypeScript, Next.js, Solid, Solid with TypeScript, Vue, Nuxt, Svelte, SvelteKit, Astro and Angular.
1529
+
1530
+ ## 14. Caveats for every app
1531
+
1532
+ 1. The app's own CSS reaches the poster: inherited text styles (font, color, `letter-spacing`, `text-align`, line height) and rules for elements (`p`, `figure`).
1533
+ SolidStart's starter CSS (`p { max-width: 14rem }`) cut the readout at 390px until the readout set `max-width: none` (checked).
1534
+ Give the poster's root its own font, color, `letter-spacing` and `text-align`, and your own elements inside it their own sizes and margins.
1535
+ 2. Scoped component styles never reach the chart (Vue `scoped`, Svelte, Astro, Angular's emulated encapsulation): rhp makes the chart's elements, not the component's template.
1536
+ Use a plain CSS file, imported where section 2's table says.
1537
+ 3. Style regular slat blocks with the slat type's `css` and the Chart's `theme`: page CSS cannot change their protected appearance (api.md, section 11).
1538
+ The experimental [ManyDots collection](api.md#manydots-experimental-point-collection) instead accepts application classes and `pointStyle` for point appearance while retaining its geometry guard.
1539
+ In frameworks with scoped component CSS, put those application classes in the plain CSS file too.
1540
+ 4. Only Solid draws the chart on the server (SolidStart, Astro islands).
1541
+ Everywhere else the server sends an empty box and the browser draws the chart, so the content below moves down once; give the box a `min-height` if that matters.
1542
+ 5. Data the app loads (a fetch, a Refresh button, a feed) has three more states than the chart: loading, failed and empty.
1543
+ Draw each in the chart's place, at the chart's height: a quiet "Loading…", a line that says what failed with a way to try again, and a sentence for no data ("No activity yet"), never an empty plot, an axis of 0.2, 0.4…, or a headline computed from nothing.
1544
+ 6. macOS and Windows ignore case in file names: `towers.ts` beside `Towers.tsx` made `./Towers` load the data file (checked in Next.js).
1545
+ Give such files different names or folders.
1546
+ 7. In an app that is not a Solid app, import everything from `@bezda/rhp/standalone` and nothing from `solid-js` (api.md, section 2).
1547
+ 8. Solid's html template compiles each template with `new Function`, so a page whose Content-Security-Policy forbids `unsafe-eval` cannot draw html-template charts: allow it for that page, or use a Solid app with JSX, which needs no eval.
1548
+
1549
+ ## 15. What was verified, and the versions
1550
+
1551
+ On 2026-10-03, on macOS 26.5 with Node 24.15.0 and npm 11.12.1, with `@bezda/rhp` 2.0.1 installed from npm (from jsDelivr for the plain page), each environment passed:
1552
+
1553
+ - its production build, with no warning about the chart's code;
1554
+ - the built app, served by its production server (`vite preview`, `next start`, `node .output/server/index.mjs`, or a static server for Astro's and Angular's output), opened in Playwright 1.63's Chromium at 1280px and 390px: no console error or warning, one chart with 7 slats, no sideways scroll, the readout on one line for every tower on hover, the readout cleared when the pointer leaves, Tab and the arrow keys, and a tap on a touch phone;
1555
+ - a test page where the app's state changes the data (one height changes and a tower is added, then back), changes a row in place where the framework allows it, and removes and adds the chart: each tower keeps its slat, the app's data stays unchanged, and nothing is left behind;
1556
+ - a look at the screenshots.
1557
+
1558
+ On 2026-10-04 the chart's pointer handlers changed (a tap or a click picks on `click`, a scroll never picks, a pick stays between towers and on the focused tower).
1559
+ The plain page and the Solid component were checked again in Playwright's Chromium (mouse, keyboard, a tap and a scroll at 390px, and the kit's checker), and the TypeScript files with `tsc` in the Solid and Angular projects; every other environment draws the same chart module.
1560
+
1561
+ | Environment | Versions | Also checked |
1562
+ |---|---|---|
1563
+ | plain page | Chrome 154, Safari 26.5.2 and Firefox 155 (Playwright's build) from `file://`; Playwright's Chromium | |
1564
+ | Solid | solid-js 1.9.15, vite 8.3.2, vite-plugin-solid 2.11.14 | dev server updates |
1565
+ | Solid with TypeScript | the same, typescript 6.0.3 | `tsc -b` |
1566
+ | SolidStart | @solidjs/start 2.0.5, @solidjs/router 1.0.0, nitro 3.0.260610-beta, solid-js 1.9.15, vite 8.3.2 | the server's HTML has the chart |
1567
+ | React | react 19.3.0, react-dom 19.3.0, vite 8.3.2, @vitejs/plugin-react 6.1.1 | StrictMode, Fast Refresh |
1568
+ | React with TypeScript | the same, typescript 6.0.3 | `tsc -b` |
1569
+ | Next.js App Router | next 16.3.8 (Turbopack), react 19.2.8, typescript 5.9.3, tailwindcss 4.3.3 | no rhp on the server, StrictMode, Fast Refresh |
1570
+ | Next.js Pages Router | next 16.3.8, react 19.2.8 | no rhp on the server |
1571
+ | Vue | vue 3.5.43, vite 8.3.2, @vitejs/plugin-vue 6.0.9 | dev server updates |
1572
+ | Nuxt | nuxt 4.5.2, vue 3.5.43, vite 8.3.2, nitropack 2.13.4 | |
1573
+ | Svelte | svelte 5.57.1, vite 8.3.2, @sveltejs/vite-plugin-svelte 7.3.1 | dev server updates |
1574
+ | SvelteKit | @sveltejs/kit 3.0.0, svelte 5.57.1, vite 8.3.2, @sveltejs/adapter-auto 8.0.0, svelte-check 4.7.6, typescript 6.0.3 | `svelte-check` |
1575
+ | Angular | @angular/core 22.2.1, @angular/build 22.2.1, @angular/cli 22.2.1, typescript 6.0.3 | zoneless |
1576
+ | Astro | astro 7.3.5, vite 8.3.2 | |
1577
+ | Astro with Solid | astro 7.3.5, @astrojs/solid-js 7.0.2, solid-js 1.9.15 | `client:load`, `client:visible`, `client:only` |