@bezda/rhp-mcp 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,13 +173,27 @@ npx -y @bezda/rhp-mcp install-browser
173
173
  It downloads Chromium's headless shell into Playwright's usual folder and leaves the other browsers there alone.
174
174
  Other options go to Playwright's installer: on Linux, `install-browser --with-deps` also installs the system libraries the browser needs.
175
175
 
176
- ## Nothing leaves your machine
176
+ ## Local checking
177
177
 
178
- The checker renders your chart in a browser on your machine, and your chart's code and screenshots stay there.
178
+ The checker renders your chart in a browser on your machine and does not upload its source or screenshots.
179
179
  It answers the page's requests for `@bezda/rhp` from the copy of rhp it ships with.
180
180
  The only other requests are the ones your chart's page makes itself, such as its Google Fonts, and each gets 5 seconds before the check goes on without it.
181
181
  There is no telemetry.
182
182
 
183
+ The checker treats the chart's page as code it did not write, and gives it only what a chart needs to render:
184
+
185
+ - It permits public HTTP GET, HEAD and OPTIONS requests for assets and data, without forwarding request bodies; POST and other methods are blocked.
186
+ WebSockets are blocked because they can send arbitrary messages.
187
+ This is not a guarantee against data sent in a public URL or its headers.
188
+ - It reaches public addresses only: never this machine (`localhost`), your network or a cloud's metadata service, including through a redirect or a DNS answer.
189
+ Remote responses have a 5-second overall deadline across redirects and a 64 MB size limit, including decompressed content.
190
+ A rejecting local proxy blocks browser connections that bypass the asset router, including worker sockets.
191
+ - It reads the files next to the chart and in folders under it, but not hidden ones (`.env`, `.git`) and nothing outside the chart's folder, including through symlinks.
192
+ These restrictions apply to standalone HTML pages and bundled charts.
193
+ - The browser runs with Chromium's sandbox on (without it only where it cannot start, as in some Linux containers), with no service workers and no downloads.
194
+
195
+ A request the checker refuses is reported in the check, and the check goes on without it.
196
+
183
197
  ## For maintainers
184
198
 
185
199
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bezda/rhp-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Make charts with rhp from any AI agent: an MCP server with rhp's guide, references and recipes, and a checker that renders a chart in a browser and reports what is wrong, with screenshots",
5
5
  "keywords": [
6
6
  "rhp",
@@ -47,11 +47,11 @@
47
47
  "scripts": {
48
48
  "build": "node scripts/build.js",
49
49
  "prepack": "npm run build",
50
- "test": "node test/settle-regression.js && node test/mcp.js && node test/run.js"
50
+ "test": "node test/network.js && node test/settle-regression.js && node test/isolation.js && node test/selection-regression.js && node test/mcp.js && node test/run.js"
51
51
  },
52
52
  "dependencies": {
53
53
  "@babel/core": "^7.29.7",
54
- "@bezda/rhp": "^2.0.3",
54
+ "@bezda/rhp": "^2.0.4",
55
55
  "@modelcontextprotocol/sdk": "^1.32.0",
56
56
  "babel-preset-solid": "^1.9.15",
57
57
  "esbuild": "^0.28.2",
package/skill/SKILL.md CHANGED
@@ -350,13 +350,15 @@ function FruitChart() {
350
350
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
351
351
  setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
352
352
  };
353
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
354
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
353
355
  return html`
354
356
  <${Poster}
355
357
  kicker="Market stall, Saturday"
356
358
  title="Bananas outsell everything else"
357
359
  dek=${() => (on() == null ? "Crates sold by midday. Tap or point at a fruit." : `${fruit[on()]}: ${sold[on()]} crates by midday.`)}
358
360
  note="Illustrative data."
359
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
361
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
360
362
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
361
363
  <${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} label="Crates of fruit sold by midday"
362
364
  theme=${{ font: "Inter, system-ui, sans-serif", ink: "#1f1a14", muted: "#6f665a", grid: "#e6dfd2" }}>
@@ -442,6 +444,7 @@ Read the ones the chart needs; each is self-contained.
442
444
  - [design.md](references/design.md): art direction from the subject (idea, marks, composition, palette, type), the poster, color, type, layout, annotation, charts inside an app, a style the user asks for.
443
445
  - [looks.md](references/looks.md): eight looks written out in full (tokens, fonts, texture, type), as examples of how a look is written, not a menu.
444
446
  - [interaction.md](references/interaction.md): which interaction for which story, with complete code for each.
447
+ - [performance.md](references/performance.md): measured optimizations for an expensive chart, preserving its slats, styling, state, and interactions.
445
448
  - [environments.md](references/environments.md): plain HTML, Solid, Astro, React, Next.js, Vue, Svelte and Angular, each verified, and the html template to JSX rules.
446
449
  - [api.md](references/api.md): every component, prop, helper and CSS variable, exactly.
447
450
  - [pitfalls.md](references/pitfalls.md): mistakes and their fixes.
@@ -118,13 +118,15 @@ function SpokenLanguages() {
118
118
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
119
119
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
120
120
  };
121
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
122
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
121
123
  return html`
122
124
  <${Poster}
123
125
  kicker="The world's languages"
124
126
  title=${html`<em>English</em> is the world's most spoken language`}
125
127
  dek="Total speakers in millions, counting everyone who speaks it as a first or a second language. Mandarin has more native speakers; English has more learners. Tap or point at a language."
126
128
  note="Source: Ethnologue, 2023 (speakers); United Nations, 2023 (world population, 8.05 billion)."
127
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
129
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
128
130
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
129
131
  <p class="readout" aria-live="polite">
130
132
  <b>${() => percent.format(LANGUAGES[lit()].millions / WORLD)}</b>
@@ -132,13 +132,15 @@ function CommuteTimes() {
132
132
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
133
133
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
134
134
  };
135
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
136
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
135
137
  return html`
136
138
  <${Poster}
137
139
  kicker="Commute times, door to door"
138
140
  title="Bikes beat cars, and keep to time"
139
141
  dek=${`One-way commutes timed over a month, ${MODES.reduce((n, m) => n + m.trips, 0)} trips in all. A cyclist's trips stay within a few minutes of each other; a driver's can take twice as long on a bad day. Tap or point at a mode for its numbers.`}
140
142
  note="Illustrative data."
141
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
143
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
142
144
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
143
145
  <ul class="key">
144
146
  <li><i class="box"></i>Middle half of trips</li>
@@ -149,13 +149,15 @@ function SharePrice() {
149
149
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
150
150
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
151
151
  };
152
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
153
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
152
154
  return html`
153
155
  <${Poster}
154
156
  kicker="Share price · March 2026"
155
157
  title="A 15% drop on earnings day erased the month's gains"
156
158
  dek="Each candle is a trading day. Its body runs from the opening price to the closing price, hollow when the price rose and filled when it fell; its wick spans the day's low to its high."
157
159
  note="Illustrative data. Point at a day, tap it, or Tab into the chart and use the arrow keys."
158
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
160
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
159
161
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
160
162
  <p class="ticker">
161
163
  <b>${() => day.format(new Date(shown().date))}</b>
@@ -85,7 +85,7 @@
85
85
  .key ul { margin: 0; padding: 0; list-style: none; }
86
86
  .key li { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: baseline; gap: 10px; padding: 3px 0; font-size: 15px; line-height: 1.35; cursor: default; }
87
87
  .key li i { align-self: center; width: 16px; height: 7px; border-radius: 2px; background: var(--quiet); }
88
- .key li b { font: 600 15px/1.35 var(--display); font-stretch: 108%; font-variant-numeric: tabular-nums; }
88
+ .key li b { font: 600 15px/1.35 var(--display); font-stretch: 108%; font-variant-numeric: tabular-nums; white-space: nowrap; }
89
89
  .key li.on { color: var(--accent); }
90
90
  .key li.on i { background: var(--accent); }
91
91
 
@@ -131,7 +131,7 @@ const DAY = [
131
131
  { group: 2, name: "Shopping and errands", short: "Errands", hours: 0.69 },
132
132
  { group: 2, name: "Caring for others", short: "Care", hours: 0.64 }, // household members (0.49) and others (0.15)
133
133
  { group: 3, name: "Watching TV", short: "TV", hours: 2.61 },
134
- { group: 3, name: "Reading, games and other leisure", short: "Leisure", hours: 1.63 }, // leisure (5.16) less the three around it
134
+ { group: 3, name: "Reading, games and leisure", short: "Leisure", hours: 1.63 }, // leisure (5.16) less the three around it
135
135
  { group: 3, name: "Socializing", short: "Social", hours: 0.58 },
136
136
  { group: 3, name: "Sports and exercise", short: "Sport", hours: 0.34 },
137
137
  { group: 4, name: "Worship, volunteering, calls, mail and other", short: "Other", hours: 0.74 }, // 0.28 + 0.19 + 0.27
@@ -199,6 +199,8 @@ function PhoneMakers() {
199
199
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
200
200
  setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
201
201
  };
202
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
203
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
202
204
  const company = () => (on() == null ? null : COMPANIES[on()]);
203
205
  return html`
204
206
  <${Poster}
@@ -206,7 +208,7 @@ function PhoneMakers() {
206
208
  title=${html`<em>Apple</em> and <em>Samsung</em> ship more than a third of all smartphones`}
207
209
  dek="Of 1.24 billion smartphones shipped in 2024, the two biggest companies shipped 455.5 million. The next three together shipped 380.2 million. Tap or point at a slice."
208
210
  note="Source: IDC Worldwide Quarterly Mobile Phone Tracker, preliminary results for 2024 (January 2025), in millions of phones."
209
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
211
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
210
212
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
211
213
  <${Chart} class="chart" aspect=${1} ticks=${false} label="Smartphones shipped in 2024 by company, as shares of the total"
212
214
  theme=${{ font: "Geist, 'Helvetica Neue', Arial, sans-serif", ink: "#16181b", muted: "#5d636c", surface: "#f1f1ef" }}>
@@ -169,13 +169,15 @@ function LaunchPlan() {
169
169
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
170
170
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
171
171
  };
172
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
173
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
172
174
  return html`
173
175
  <${Poster}
174
176
  kicker=${`Launch plan · week ${Math.floor(now) + 1} of ${WEEKS}`}
175
177
  title="The build is a week behind, and testing waits on it"
176
178
  dek=${html`<span>Twelve weeks from scope to launch day, as of ${long.format(dateOf(now))}. Each bar is a task: <i class="done"></i>done, <i class="late"></i>should be done by now, <i class="todo"></i>still to do.</span>`}
177
179
  note="Illustrative data. Point at a task or tap it to see what it waits on, or Tab into the chart and use the arrow keys."
178
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
180
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
179
181
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
180
182
  <p class="readout">
181
183
  ${ROWS.map((t) => html`
@@ -148,6 +148,8 @@ function Commits() {
148
148
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
149
149
  setAt(e.currentTarget.contains(document.activeElement) ? held : null);
150
150
  };
151
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
152
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
151
153
  const readout = () => {
152
154
  const [day, hour] = at() ?? [1, 15];
153
155
  const lead = at() ? "" : "Busiest hour: ";
@@ -163,7 +165,7 @@ function Commits() {
163
165
  title="More than half of all commits land on weekday afternoons"
164
166
  dek="One team's commits over a year, by weekday and hour. The keys darken from 2 to 5 pm on every working day, the lunch hour dips, and weekends barely move."
165
167
  note="Illustrative data, in local time. Point at a key or tap it to read its hour; Tab into the chart and use the arrow keys to read a day."
166
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${click} onFocusIn=${focusDay}
168
+ onPointerMove=${move} onClick=${click} onFocusIn=${focusDay}
167
169
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setAt(null)}>
168
170
  <div class="readout">
169
171
  <output aria-live="polite">${readout}</output>
@@ -169,6 +169,8 @@ function FinishTimes() {
169
169
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
170
170
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
171
171
  };
172
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
173
+ const move = (e) => (e.pointerType !== "touch" || sideways(e)) && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
172
174
  return html`
173
175
  <${Poster}
174
176
  kicker=${`City marathon · ${count.format(FINISH_TIMES.length)} finishers`}
@@ -177,7 +179,7 @@ function FinishTimes() {
177
179
  finished between ${clock(FOCUS)} and ${clock(FOCUS + BIN - 1)}, and only ${count.format(tally[opening + 1])} in the 5 minutes after.</span>`}
178
180
  note="Illustrative data. Point at a column, tap it or drag across the chart, or Tab into it and use the arrow keys."
179
181
  onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
180
- onPointerMove=${(e) => (e.pointerType !== "touch" || sideways(e)) && pick(e)} onClick=${pick} onFocusIn=${pick}
182
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
181
183
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
182
184
  <div class="board">
183
185
  <div><b>${() => `${clock(x0[shown()])} to ${clock(x1[shown()] - 1)}`}</b><span>Finish time</span></div>
@@ -132,6 +132,8 @@ function AgeingJapan() {
132
132
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
133
133
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
134
134
  };
135
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
136
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
135
137
  const older = () => MEN.slice(from()).concat(WOMEN.slice(from())).reduce((sum, v) => sum + v, 0);
136
138
  return html`
137
139
  <${Poster}
@@ -139,7 +141,7 @@ function AgeingJapan() {
139
141
  title="Nearly three in ten people in Japan are 65 or older"
140
142
  dek="Men to the left, women to the right, in millions, the oldest at the top. Two bulges show the baby booms of the late 1940s and early 1970s; the base keeps shrinking."
141
143
  note="Source: Statistics Bureau of Japan, population estimates for October 1, 2023 (124.35 million people), rounded to the nearest 10,000. Point at an age band, tap it, or Tab into the chart and use the arrow keys."
142
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
144
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
143
145
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
144
146
  <p class="readout" aria-live="polite">
145
147
  <b>${() => percent.format(older() / TOTAL)}</b>
@@ -135,6 +135,7 @@ const Country = slat({
135
135
  // keys, and the United States otherwise
136
136
  function WealthChart() {
137
137
  const [picked, setPicked] = createSignal(null);
138
+ const [pinned, setPinned] = createSignal(null); // the dot a click or a tap picked: the chart comes back to it
138
139
  const shown = () => COUNTRIES[picked() ?? STORY];
139
140
  let plot;
140
141
  const nearest = (e) => {
@@ -161,18 +162,30 @@ function WealthChart() {
161
162
  const i = nearest(e);
162
163
  if (i != null) setPicked(i);
163
164
  };
165
+ // A tap or a click picks it too, and pins it
166
+ const pin = (e) => {
167
+ point(e);
168
+ setPinned(picked());
169
+ };
164
170
  // The keyboard picks the dot it focuses
165
171
  const focus = (e) => {
166
172
  const i = rowOf(e.target);
167
173
  if (i != null) setPicked(i);
168
174
  };
169
- // Leaving the chart: back to the dot that has focus (the keyboard's pick), or none
175
+ // Leaving the chart: back to the dot that has focus (the keyboard's pick), else the pinned one, else none
170
176
  const leave = (e) => {
171
177
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
172
- setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
178
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : pinned());
179
+ };
180
+ // Clicking poster text or margin resets this existing pick to the chart's original default.
181
+ const reset = (e) => {
182
+ if (e.target.closest(".plot, a, button, input, select, textarea, label, [role=button], [role=slider]")) return;
183
+ setPinned(null);
184
+ setPicked(null);
173
185
  };
174
186
  return html`
175
187
  <${Poster}
188
+ onClick=${reset}
176
189
  kicker="Wealth and health of nations"
177
190
  title="Life expectancy climbs fast with income, then levels off"
178
191
  dek="Twenty countries in 2023. Above about $25,000 a person, higher income goes with little extra life: the United States, richer than all but Norway here, has a lower life expectancy than Costa Rica, with about a third of its income. Tap or point at a dot."
@@ -180,8 +193,8 @@ function WealthChart() {
180
193
  <p class="readout"><b>${() => shown().name}</b><span>${() => `: ${dollars.format(shown().gdp)} a person, ${years.format(shown().life)} years`}</span></p>
181
194
  <p class="axis">Life expectancy at birth, years</p>
182
195
  <div class="plot"
183
- onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${point} onFocusIn=${focus}
184
- onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
196
+ onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${pin} onFocusIn=${focus}
197
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(pinned())}>
185
198
  <${Chart} scale=${X} cross=${Y} crossTicks=${[50, 60, 70, 80, 90]} height=${340}
186
199
  label="Life expectancy at birth against GDP per person, twenty countries, 2023"
187
200
  theme=${{ font: 'Manrope, "Helvetica Neue", Arial, sans-serif', ink: "#191c1f", muted: "#686b70", grid: "#dfdbd0" }}>
@@ -72,7 +72,7 @@
72
72
  .poster .headline, .poster .kicker, .poster .dek { position: relative; z-index: 2; }
73
73
  .poster .headline { padding-right: 28px; }
74
74
  .poster .dek { align-self: start; margin-top: 2px; padding-right: 20px; font-size: 16px; }
75
- .poster .stage { grid-area: 1 / 1 / 4 / 4; align-self: end; margin-top: 64px; }
75
+ .poster .stage { grid-area: 1 / 1 / 4 / 4; align-self: end; margin-top: 32px; }
76
76
  .poster .key { display: none; }
77
77
  }
78
78
  @media (max-width: 640px) {
@@ -273,7 +273,7 @@ const tower = (d) => html`
273
273
  <//>
274
274
  </div>`;
275
275
  // The same tower with the room each width needs: names on the street on wide screens, ranks on narrow ones
276
- const TowerNamed = slat({ room: { start: 88, end: 0, after: 0 }, css: TOWER_CSS }, tower);
276
+ const TowerNamed = slat({ room: { start: 88, end: 32, after: 32 }, css: TOWER_CSS }, tower);
277
277
  const TowerRanked = slat({ room: { start: 32, end: 32, after: 46 }, css: TOWER_CSS }, tower);
278
278
 
279
279
  // The chart's font and colors, read from the page's tokens
@@ -310,6 +310,8 @@ function Skyline() {
310
310
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
311
311
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
312
312
  };
313
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
314
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
313
315
  const towers = (type) => html`
314
316
  <${Plot} rows=${BUILDINGS} order=${POS} keyboard=${true} rank=${RANK} outline=${OUTLINE} shift=${SHIFT}
315
317
  lit=${(d) => d.index === lit()} lead=${(d) => d.index === LEAD} span=${(d) => span(d.index)}
@@ -320,7 +322,7 @@ function Skyline() {
320
322
  title=${`${cap(word(AS_OF - lead.year))} years on, the ${lead.name} still tops the rest by ${metres(margin)}`}
321
323
  dek=${`The ${word(BUILDINGS.length)} tallest finished buildings on Earth, from ${word(cities)} cities, share one street here, each in its own outline and all at one scale. Heights in metres.`}
322
324
  note=${SOURCE}
323
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
325
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
324
326
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
325
327
  <div class="stage" style=${() => `--street: ${wide() ? 88 : 32}px`}>
326
328
  <${Chart} orientation="vertical" height=${() => (wide() ? 680 : 420)} scale=${[scale.min, scale.max]} ticks=${false}
@@ -138,6 +138,7 @@ const Item = slat({
138
138
  // picked out and the others fade
139
139
  function GroceryChart() {
140
140
  const [picked, setPicked] = createSignal(null);
141
+ const [pinned, setPinned] = createSignal(null); // the item a click or a tap picked: the chart comes back to it
141
142
  let plot;
142
143
  const nearest = (e) => {
143
144
  const r = plot.getBoundingClientRect();
@@ -164,24 +165,36 @@ function GroceryChart() {
164
165
  const i = nearest(e);
165
166
  if (i != null) setPicked(i);
166
167
  };
168
+ // A tap or a click picks it too, and pins it
169
+ const pin = (e) => {
170
+ point(e);
171
+ setPinned(picked());
172
+ };
167
173
  // The keyboard picks the item it focuses
168
174
  const focus = (e) => {
169
175
  const i = rowOf(e.target);
170
176
  if (i != null) setPicked(i);
171
177
  };
172
- // Leaving the chart: back to the item that has focus (the keyboard's pick), or none
178
+ // Leaving the chart: back to the item that has focus (the keyboard's pick), else the pinned one, else none
173
179
  const leave = (e) => {
174
180
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
175
- setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
181
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : pinned());
182
+ };
183
+ // Clicking poster text or margin resets this existing pick to the chart's original default.
184
+ const reset = (e) => {
185
+ if (e.target.closest(".plot, a, button, input, select, textarea, label, [role=button], [role=slider]")) return;
186
+ setPinned(null);
187
+ setPicked(null);
176
188
  };
177
189
  return html`
178
190
  <${Poster}
191
+ onClick=${reset}
179
192
  kicker="Price check, US city average"
180
193
  title="A dozen eggs cost twice as much in 2024 as in 2020"
181
194
  dek="Six staples, each at its average price in 2020 and in 2024. Coffee went up 43%, bread 36%, beef 31% and milk 20%; bananas went up 4 cents."
182
195
  note="Source: US Bureau of Labor Statistics, average price data for 2020 and 2024 (the mean of each year's twelve monthly prices). Point at a line, tap it, or Tab into the chart and use the arrow keys.">
183
- <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${point} onFocusIn=${focus}
184
- onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
196
+ <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${pin} onFocusIn=${focus}
197
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(pinned())}>
185
198
  <${Chart} scale=${[0, 1]} cross=${[0, TOP]} crossTicks=${false} height=${HEIGHT}
186
199
  label=${`Average US prices of six groceries in ${YEARS[0]} and ${YEARS[1]}`}
187
200
  theme=${{ font: '"Azeret Mono", ui-monospace, Menlo, monospace', ink: "#1d1d1b", muted: "#55534e" }}>
@@ -192,13 +192,15 @@ function RestaurantRatings() {
192
192
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
193
193
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
194
194
  };
195
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
196
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
195
197
  return html`
196
198
  <${Poster}
197
199
  kicker="City food guide · ratings out of 5"
198
200
  title="Burger places are hit or miss"
199
201
  dek="Every dot is a restaurant, placed by its average rating. Ethiopian kitchens all score between 4.3 and 4.8; burgers run from under 3 to almost 5."
200
202
  note="Illustrative data for invented restaurants. Point at a dot or tap it for the restaurant, or Tab into a cuisine and use the arrow keys."
201
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
203
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
202
204
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
203
205
  <div class="readout">
204
206
  <span class="stars">${() => one.format(place().rating)} ★</span>
@@ -142,9 +142,11 @@ function RainChart() {
142
142
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
143
143
  setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
144
144
  };
145
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
146
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
145
147
  return html`
146
148
  <${Poster} title="City A gets more than three times City D's October rain" note="Illustrative data."
147
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
149
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
148
150
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
149
151
  <p class="readout" aria-hidden="true">${() => (on() == null ? "Tap or point at a city." : say(on()))}</p>
150
152
  <${Chart} scale=${[0, 200]} ticks=${[0, 50, 100, 150, 200]} label="Rain in October, in mm">
@@ -183,6 +185,7 @@ render(() => html`<${RainChart} />`, document.getElementById("chart"));
183
185
  Checked: the checker reported a change for each slat it pointed at (City A, City C, City F) and tapped (City A, City F); its Tab reached City A and ArrowDown moved focus to City B.
184
186
  Playwright with a mouse: at rest City A was lit and the readout gave the hint; on City C's bar the readout named City C, City C was lit, and the five others were at opacity .3 with rhp's transition still on their bars; from City F down onto the axis the readout stayed on City F; leaving the poster cleared it all.
185
187
  With focus on City A, the mouse over City D took the readout, and leaving the poster gave it back to City A.
188
+ Off the chart, in Chromium and WebKit: from City C onto the headline the readout went back to the hint, and with City A clicked, from City D onto the readout it went back to City A; the checker reported nothing.
186
189
  At 390px with touch: a tap on City B kept the readout after the finger lifted, a scroll that started on City E moved the page and left the readout on City B, a tap on City E's number picked City E, and a tap on the headline or below the poster cleared it.
187
190
  With slats 110px thick (a chart taller than a 390 × 664 screen), a readout pinned with `position: sticky` and `z-index: 2`, after the chart or above it, showed City B on screen, and City E after a scroll, with its hairline edge; without `z-index: 2` the slats covered it.
188
191
  Scrolled to the chart before any pick, the readout above it that pins only while a pick exists was out of the way, and one pinned at rest covered the plot's top.
@@ -0,0 +1,143 @@
1
+ # Keeping a big chart light
2
+
3
+ Use this reference when a finished chart has measurable interaction or loading costs, particularly with many slats or many blocks per slat.
4
+ Measure the chart before choosing an optimization.
5
+ These patterns preserve rhp's slats, blocks, nested Plots, and public APIs.
6
+ They do not require any particular interaction.
7
+
8
+ ## Measure the work
9
+
10
+ Count elements in the page and hidden slats, and observe attribute and child-list mutations during a representative action.
11
+ Separate DOM writes from rendering cost: fewer JavaScript writes can still cause the browser to restyle and paint many marks.
12
+ Compare actual interaction latency as well as counts when speed matters.
13
+
14
+ ```js
15
+ const chart = document.querySelector('.rhp-chart');
16
+ console.log(chart.querySelectorAll('*').length);
17
+ const changes = [];
18
+ const observer = new MutationObserver(records => changes.push(...records));
19
+ observer.observe(chart, { subtree: true, attributes: true, childList: true });
20
+ // Perform the action, then disconnect and inspect changes.
21
+ ```
22
+
23
+ ## Switch mark colors through a slat class
24
+
25
+ When one slat's state changes many marks' colors, give the marks their colors once and switch a CSS variable through a class on the owning slat.
26
+ Keep the `color` API so all consumers of the mark's color, including outlines or descendant styling, continue to see the active color.
27
+ A generic `background` shorthand override can erase background images and changes only that paint property.
28
+ Use an override of a specific paint property only when that is the intended behavior of the existing chart.
29
+
30
+ ```js
31
+ const Segment = slat({
32
+ css: `
33
+ .seg { --active-tone: var(--base-tone); }
34
+ .lit .seg { --active-tone: var(--lit-tone); }
35
+ `,
36
+ }, s => html`
37
+ <${Bar} class="seg" from=${() => s.from} to=${() => s.to}
38
+ color="var(--active-tone)"
39
+ style=${() => ({ '--base-tone': s.tone, '--lit-tone': s.gold })} />`);
40
+
41
+ const Shelf = slat({ thickness: 24, room: { start: 70, end: 40 } }, d => html`
42
+ <div class=${() => d.lit ? 'shelf lit' : 'shelf'}>
43
+ <${Label} edge="start">${() => d.name}<//>
44
+ <${Plot} overlap=${true} from=${() => d.from} to=${() => d.to}
45
+ tone=${() => d.tones} gold=${() => d.golds}>${Segment}<//>
46
+ </div>`);
47
+ ```
48
+
49
+ `tone` and `gold` here are CSS colors, including theme variables such as `var(--rhp-series-8)`, rather than bare theme-key strings.
50
+ Keep their data groups independent of the interaction state.
51
+ Continue passing reactive getters when the underlying data or palette can change.
52
+ Only the shelf's `lit` data group needs to read the state that chooses the highlighted shelf.
53
+ This does not change how the chart chooses that state.
54
+
55
+ Put the rule in the `css` of the slat type that owns the mark, here `Segment`.
56
+ Its selector may name ancestors inside the same chart, including the shelf root.
57
+ A shelf's slat CSS does not style another nested slat type's marks.
58
+ See api.md, “Slat CSS scope”.
59
+
60
+ ## Put shared dimming state on a parent
61
+
62
+ When a shared selection dims many slats, a class on their Plot or grouping slat can replace a flag rewritten on every row.
63
+ Keep the classes that identify the exceptions.
64
+ For example, inside the mark-owning slat's CSS:
65
+
66
+ ```css
67
+ .picking .shelf:not(.lit):not(.pointed) .seg { opacity: .3; }
68
+ ```
69
+
70
+ Set `picking` on a Plot or slat root inside the chart when the existing interaction has an active selection.
71
+ Keep `lit` and `pointed` consistent with the existing selection and preview behavior.
72
+ Charts with multiple selections, disabled marks, separate groups, or other exceptions need selectors that preserve all those states.
73
+ A class outside the chart, on the poster for example, is outside slat CSS's ancestor scope.
74
+
75
+ ## Render hidden slats' contents conditionally
76
+
77
+ A Plot row whose `d.position` is `null` is hidden, but its contents normally remain mounted and reactive.
78
+ For expensive rows hidden by a filter or top-N view, Solid's `Show` can leave only the slat root mounted:
79
+
80
+ ```js
81
+ const Shelf = slat({ thickness: 24, room: { start: 70, end: 40 } }, d => html`
82
+ <div class=${() => d.lit ? 'shelf lit' : 'shelf'}>
83
+ <${Show} when=${() => d.position != null}>
84
+ <${Label} edge="start">${() => d.name}<//>
85
+ <${Plot} overlap=${true} from=${() => d.from} to=${() => d.to}
86
+ tone=${() => d.tones} gold=${() => d.golds}>${Segment}<//>
87
+ <//>
88
+ </div>`);
89
+ ```
90
+
91
+ This is normal Solid composition and retains rhp's slat design.
92
+ Keep the root outside `Show`, so rhp retains its identity, position, focus target, and keyboard order.
93
+ The contents mount when the row receives a position and unmount when it loses that position.
94
+ Check leaving animations against when the chart's own order actually becomes `null`.
95
+
96
+ State owned by the slat root survives this cycle.
97
+ State, native input values, subscriptions, and effects owned inside the conditional content follow Solid's mount and cleanup lifecycle.
98
+ Keep state that must survive in the root or application, or leave that content mounted.
99
+ Showing many rows at once can cost more because their blocks must be created then.
100
+ Measure that action before deciding whether this tradeoff suits the chart.
101
+
102
+ Automatic gutters already follow the visible edge labels in the tested eager and conditional versions.
103
+ Both widened when a longer label became visible, with identical widths before and after filtering.
104
+ Conditional rendering does not by itself require fixed room.
105
+ Verify the chart's gutter geometry, including its own sizing constraints, when changing which labels are mounted.
106
+
107
+ ## Verified example and limits
108
+
109
+ The vocabulary article already uses these patterns.
110
+ A review on 2026-10-16 compared four isolated variants of the article at source commit `4101fdb`: eager contents with per-mark color and dimming updates, then each optimization added in turn.
111
+ The test used Chromium and WebKit, widths 1280 and 390, and dark and light themes, for 32 configurations.
112
+
113
+ | Measurement | Earlier implementation | Optimized implementation |
114
+ |---|---:|---:|
115
+ | Style writes for one tested shelf hover | 14 | 0 |
116
+ | Shelf class changes for that hover | 1 | 1 |
117
+ | Changed DOM nodes in the era chart after opening a rapper | 122 | 9 |
118
+ | Page elements at desktop rest | 3,101 | 1,449 |
119
+ | Descendants retained inside hidden ranking slats | 1,652 | 0 |
120
+ | Retained JS heap after collection, Chromium desktop | 16.7 MB | about 10 MB |
121
+
122
+ These figures describe this page and these actions, not a complexity or frame-rate guarantee.
123
+ Visible mark geometry, computed colors and opacity, readout text, tab order, and accessible breakdown content matched in seven states: rest, hover, open breakdown, normalized measure, era filter, show all, and search.
124
+ The hovered ranking screenshots matched pixel for pixel in all eight engine/width/theme combinations.
125
+ Color transition values matched at 0, 100, and 200ms, and face dimming matched at 0, 100, and 250ms in both engines.
126
+ A separate lifecycle fixture verified root identity, root-owned state, arrow-key navigation, nested state reset, native input reset, and cleanup behavior in both engines.
127
+ The color-variable example above was also checked with reactive palette changes, an outline that consumes the mark color, and a background image in both engines.
128
+
129
+ In five fresh-page trials per action at 390px with Chromium CPU throttled 4 times, showing all 149 rows took a median 175ms with mounted contents and 258ms with conditional contents, measured from the action through two animation frames with reduced motion.
130
+ Search medians were 239ms and 217ms respectively, with overlapping ranges (206 to 310ms and 195 to 310ms), so these trials establish no consistent search advantage or regression.
131
+ CPU throttling is a reproducible browser test setting, not a measurement on a particular phone.
132
+
133
+ ## Validate the specific chart
134
+
135
+ Compare before and after at the same viewport, theme, scroll position, and interaction state.
136
+ Check geometry, colors, labels, readouts, keyboard navigation, touch actions, and accessibility content.
137
+ For motion, pause animations at equal times from their starts and compare intermediate frames as well as settled output.
138
+ Use a before-versus-before control when screenshots vary between identical builds.
139
+ Run the checker again after the change.
140
+
141
+ Use the class patterns when they reduce measured work and preserve the chart's styling and state semantics.
142
+ Choose conditional contents when their lifecycle and reveal cost fit the chart, and explain a material tradeoff to the user before changing existing behavior.
143
+ No pattern here removes an rhp capability or calls for replacing slats with a different rendering model.
@@ -88,7 +88,7 @@ The tests read this table: every code in it must have a chart in `test/broken/`
88
88
  | `interaction-error` | error | A step of the interaction pass threw or logged an error. | As for `runtime-error`. |
89
89
  | `rhp-warning` | error | rhp printed one of its `rhp:` warnings. | Do what the warning says. |
90
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. |
91
+ | `remote-failed` | info | A remote file (fonts, a CDN) could not be fetched, or the page asked for something the checker does not fetch (a request that sends data, an address that is not public); the check goes on without it. | Check the URL; offline, fonts fall back. Load data and assets with plain GET requests from public URLs, or from files next to the chart. |
92
92
 
93
93
  ### How rhp is used (the guard)
94
94
 
@@ -145,6 +145,23 @@ The tests read this table: every code in it must have a chart in `test/broken/`
145
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
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
147
 
148
+ ### Conditional selection repair
149
+
150
+ The shared checker runs this audit after creation, through both `rhp_check` and the CLI (`npx -y @bezda/rhp-mcp check <file>`).
151
+ It does not require either click selection or hover, and these checks do not choose an interaction for the chart.
152
+ On an isolated page, it first verifies that a click persists after the pointer leaves the poster, another slat's hover overrides it, and leaving the poster restores the clicked selection.
153
+ Only that observed combination qualifies.
154
+ It then checks a pointer move and a click on noninteractive text or margin inside the same poster, outside every chart and control.
155
+ The initial appearance is the reset reference, preserving an existing default without guessing which subject should be selected.
156
+ Moving pages, unreachable marks and sequences that scroll are inconclusive rather than findings.
157
+ The audit samples up to three reachable slats of the main Plot at the first checked width.
158
+ It never changes the source; its conditional fix is returned to the authoring agent through the skill's existing check-and-fix step, with explicit user interaction instructions taking precedence.
159
+
160
+ | Code | Level | What triggers it | Fix it gives |
161
+ |---|---|---|---|
162
+ | `selection-hover-stays` | warning | Verified click selection with a temporary hover override: moving off the chart but staying in the poster does not restore the clicked selection, although leaving the poster does. | Clear only the hover preview on leaving the chart area; preserve the clicked selection, unless the user explicitly asked for different behavior. |
163
+ | `selection-reset` | warning | The same verified pattern: clicking noninteractive poster space outside the chart fails to restore the initial appearance. | Clear the selection if no default existed, or restore the chart's meaningful default; preserve explicitly requested behavior and other controls. |
164
+
148
165
  ### Colors (read from screenshots)
149
166
 
150
167
  | Code | Level | What triggers it | Fix it gives |