@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 +16 -2
- package/package.json +3 -3
- package/skill/SKILL.md +4 -1
- package/skill/recipes/bar.html +3 -1
- package/skill/recipes/box-plot.html +3 -1
- package/skill/recipes/candlestick.html +3 -1
- package/skill/recipes/dial.html +2 -2
- package/skill/recipes/donut.html +3 -1
- package/skill/recipes/gantt.html +3 -1
- package/skill/recipes/heatmap.html +3 -1
- package/skill/recipes/histogram.html +3 -1
- package/skill/recipes/pyramid.html +3 -1
- package/skill/recipes/scatter.html +17 -4
- package/skill/recipes/silhouettes.html +5 -3
- package/skill/recipes/slope.html +17 -4
- package/skill/recipes/strip.html +3 -1
- package/skill/references/interaction.md +4 -1
- package/skill/references/performance.md +143 -0
- package/src/check/README.md +18 -1
- package/src/check/browser.js +11 -5
- package/src/check/findings.js +14 -0
- package/src/check/index.js +81 -19
- package/src/check/inpage.js +2 -2
- package/src/check/interact.js +1 -19
- package/src/check/network.js +168 -0
- package/src/check/png.js +18 -0
- package/src/check/selection.js +123 -0
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
|
-
##
|
|
176
|
+
## Local checking
|
|
177
177
|
|
|
178
|
-
The checker renders your chart in a browser on your machine
|
|
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.
|
|
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.
|
|
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=${
|
|
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.
|
package/skill/recipes/bar.html
CHANGED
|
@@ -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=${
|
|
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=${
|
|
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=${
|
|
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>
|
package/skill/recipes/dial.html
CHANGED
|
@@ -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
|
|
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
|
package/skill/recipes/donut.html
CHANGED
|
@@ -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=${
|
|
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" }}>
|
package/skill/recipes/gantt.html
CHANGED
|
@@ -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=${
|
|
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=${
|
|
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=${
|
|
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=${
|
|
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),
|
|
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) :
|
|
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=${
|
|
184
|
-
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(
|
|
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:
|
|
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:
|
|
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=${
|
|
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}
|
package/skill/recipes/slope.html
CHANGED
|
@@ -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),
|
|
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) :
|
|
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=${
|
|
184
|
-
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(
|
|
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" }}>
|
package/skill/recipes/strip.html
CHANGED
|
@@ -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=${
|
|
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=${
|
|
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.
|
package/src/check/README.md
CHANGED
|
@@ -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 |
|