@bezda/rhp-mcp 0.1.5 → 0.1.7
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/package.json +6 -1
- package/skill/SKILL.md +113 -22
- package/skill/recipes/area.html +129 -33
- package/skill/recipes/bar.html +129 -33
- package/skill/recipes/box-plot.html +129 -33
- package/skill/recipes/bubble.html +129 -33
- package/skill/recipes/bullet.html +129 -33
- package/skill/recipes/candlestick.html +129 -33
- package/skill/recipes/column.html +129 -33
- package/skill/recipes/dial.html +129 -33
- package/skill/recipes/diverging-bars.html +129 -33
- package/skill/recipes/donut.html +129 -33
- package/skill/recipes/dumbbell.html +129 -33
- package/skill/recipes/gantt.html +129 -33
- package/skill/recipes/grouped-bars.html +129 -33
- package/skill/recipes/heatmap.html +129 -33
- package/skill/recipes/histogram.html +129 -33
- package/skill/recipes/line.html +129 -33
- package/skill/recipes/live.html +129 -33
- package/skill/recipes/log-line.html +129 -33
- package/skill/recipes/lollipop.html +129 -33
- package/skill/recipes/multi-line.html +129 -33
- package/skill/recipes/pyramid.html +129 -33
- package/skill/recipes/race.html +129 -33
- package/skill/recipes/radial-bars.html +129 -33
- package/skill/recipes/scatter.html +129 -33
- package/skill/recipes/silhouettes.html +129 -33
- package/skill/recipes/slope.html +129 -33
- package/skill/recipes/sparklines.html +129 -33
- package/skill/recipes/stacked-100.html +129 -33
- package/skill/recipes/stacked-bars.html +129 -33
- package/skill/recipes/strip.html +129 -33
- package/skill/recipes/tape.html +578 -0
- package/skill/recipes/unit-stack.html +129 -33
- package/skill/recipes/violin.html +129 -33
- package/skill/recipes/waffle.html +129 -33
- package/skill/recipes/waterfall.html +129 -33
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/download.md +135 -35
- package/skill/references/forms.md +3 -2
- package/skill/references/interaction.md +8 -4
- package/skill/references/pitfalls.md +3 -2
- package/skill/scripts/add-download.mjs +33 -0
- package/src/check/README.md +29 -5
- package/src/check/build.js +4 -3
- package/src/check/colors.js +4 -2
- package/src/check/findings.js +99 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +292 -5
- package/src/check/interact.js +22 -4
- package/src/check/png.js +6 -1
- package/src/check/report.js +9 -2
- package/src/check/selection-controls.js +105 -0
- package/src/check/selection.js +54 -12
- package/src/check/source.js +21 -0
- package/src/server.js +4 -4
|
@@ -5,7 +5,11 @@ Copy the following block before `</body>`, after the chart's module script.
|
|
|
5
5
|
Keep it outside the poster so the saved PNG includes the whole poster, its headline and source note, without the download control or gallery navigation.
|
|
6
6
|
The button joins `.recipe-navigation` when the gallery supplies one, inheriting the gallery link's color.
|
|
7
7
|
Otherwise it creates its own row above each poster, sized to the poster on desktop and phones.
|
|
8
|
-
The
|
|
8
|
+
The adjacent fit-to-screen icon toggles between full size and a proportional view at up to 95% of the viewport height.
|
|
9
|
+
Its icon changes to show the available action, and fitting preserves the poster's layout and selection.
|
|
10
|
+
The fitted view uses the stable small viewport height, so browser controls appearing or disappearing during scrolling do not change its scale.
|
|
11
|
+
The exporter loads only when clicked, waits for fonts, and saves the current poster at twice its full-size resolution, including when the view is fitted.
|
|
12
|
+
It preserves authored margins and absolute-position rules, including adopted chart stylesheets, because Safari's computed offsets can be wrong inside subgrids.
|
|
9
13
|
Activating the download control preserves the poster's selection, including keyboard focus transitions, and does not bubble into outside-click handlers.
|
|
10
14
|
Like the chart's CDN import, the first download needs a connection.
|
|
11
15
|
For an embedded chart inside an existing app, follow the app's export controls instead of adding page navigation.
|
|
@@ -13,24 +17,84 @@ For an embedded chart inside an existing app, follow the app's export controls i
|
|
|
13
17
|
```html
|
|
14
18
|
<style id="poster-download-styles">
|
|
15
19
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
16
|
-
.poster-
|
|
17
|
-
.poster-download
|
|
18
|
-
.poster-
|
|
19
|
-
.poster-
|
|
20
|
-
.poster-
|
|
20
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
21
|
+
.poster-download, .poster-fit-toggle { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
|
|
22
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
23
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
24
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
25
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
26
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
27
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
28
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
29
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
21
30
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
22
|
-
@media print { .poster-download-tools, .poster-
|
|
31
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
23
32
|
</style>
|
|
24
33
|
<script type="module" id="poster-download-script">
|
|
34
|
+
function addPosterFit(poster, button, changed) {
|
|
35
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
36
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
37
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
38
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
39
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
40
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
41
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
42
|
+
const layoutKey = () => {
|
|
43
|
+
const css = getComputedStyle(poster);
|
|
44
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
45
|
+
};
|
|
46
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
47
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
48
|
+
else poster.style.removeProperty(name);
|
|
49
|
+
});
|
|
50
|
+
function update() {
|
|
51
|
+
request = 0;
|
|
52
|
+
if (fitted && layout === layoutKey()) return;
|
|
53
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
54
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
55
|
+
if (fitted) {
|
|
56
|
+
const box = poster.getBoundingClientRect();
|
|
57
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
58
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
59
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
60
|
+
poster.style.setProperty('max-width', 'none');
|
|
61
|
+
poster.style.setProperty('margin', '0');
|
|
62
|
+
content.style.width = `${box.width}px`;
|
|
63
|
+
content.style.transform = `scale(${scale})`;
|
|
64
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
65
|
+
frame.classList.add('is-fitted');
|
|
66
|
+
}
|
|
67
|
+
layout = layoutKey();
|
|
68
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
69
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
70
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
71
|
+
button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${fitted ? 'M15 3h6v6m0-6-7 7M9 21H3v-6m0 6 7-7' : 'M4 4l6 6m0-6v6H4m16 10-6-6m0 6v-6h6'}"/></svg>`;
|
|
72
|
+
changed();
|
|
73
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
74
|
+
}
|
|
75
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
76
|
+
new ResizeObserver(schedule).observe(poster);
|
|
77
|
+
addEventListener('resize', schedule);
|
|
78
|
+
document.fonts.ready.then(schedule);
|
|
79
|
+
update();
|
|
80
|
+
return {
|
|
81
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
82
|
+
dimensions() {
|
|
83
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
84
|
+
const css = getComputedStyle(poster);
|
|
85
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
25
89
|
function addPosterDownloads() {
|
|
26
90
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
27
91
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
28
92
|
const tools = navigation || document.createElement('div');
|
|
29
93
|
if (tools.querySelector('.poster-download')) return;
|
|
94
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
30
95
|
if (!navigation) {
|
|
31
96
|
tools.className = 'poster-download-tools';
|
|
32
97
|
poster.before(tools);
|
|
33
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
34
98
|
new ResizeObserver(align).observe(poster);
|
|
35
99
|
align();
|
|
36
100
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -48,27 +112,43 @@ function addPosterDownloads() {
|
|
|
48
112
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
49
113
|
button.title = 'Download poster as PNG';
|
|
50
114
|
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
|
|
115
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
116
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
117
|
+
if (!poster.id) {
|
|
118
|
+
let id = `rhp-poster-${index + 1}`;
|
|
119
|
+
while (document.getElementById(id)) id += '-view';
|
|
120
|
+
poster.id = id;
|
|
121
|
+
}
|
|
122
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
123
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
124
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
125
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
126
|
+
actions.append(fitButton, button);
|
|
51
127
|
const error = document.createElement('p');
|
|
52
128
|
error.className = 'poster-download-error';
|
|
53
129
|
error.setAttribute('role', 'alert');
|
|
54
130
|
error.hidden = true;
|
|
55
131
|
tools.after(error);
|
|
56
132
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
57
|
-
|
|
133
|
+
actions.addEventListener('pointerdown', event => {
|
|
58
134
|
event.stopPropagation();
|
|
59
135
|
});
|
|
60
|
-
|
|
136
|
+
actions.addEventListener('mousedown', event => {
|
|
61
137
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
62
138
|
event.preventDefault();
|
|
63
139
|
event.stopPropagation();
|
|
64
140
|
});
|
|
65
141
|
poster.addEventListener('focusout', event => {
|
|
66
|
-
if (
|
|
142
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
67
143
|
}, true);
|
|
68
144
|
poster.addEventListener('pointerleave', event => {
|
|
69
|
-
if (
|
|
145
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
146
|
+
}, true);
|
|
147
|
+
poster.addEventListener('pointermove', event => {
|
|
148
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
149
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
70
150
|
}, true);
|
|
71
|
-
for (const type of ['keydown', 'keyup'])
|
|
151
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
72
152
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
73
153
|
});
|
|
74
154
|
let exporter, retries = 0, busy = false;
|
|
@@ -78,6 +158,7 @@ function addPosterDownloads() {
|
|
|
78
158
|
busy = true;
|
|
79
159
|
button.setAttribute('aria-disabled', 'true');
|
|
80
160
|
button.setAttribute('aria-busy', 'true');
|
|
161
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
81
162
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
82
163
|
error.hidden = true;
|
|
83
164
|
let url;
|
|
@@ -85,35 +166,53 @@ function addPosterDownloads() {
|
|
|
85
166
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
86
167
|
const { domToBlob } = await exporter;
|
|
87
168
|
await document.fonts.ready;
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
const
|
|
169
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
170
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
171
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
172
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
173
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
174
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
91
175
|
if (rule.selectorText && rule.style) {
|
|
92
|
-
const margins = [...rule.style].filter(
|
|
176
|
+
const margins = [...rule.style].filter(isMargin);
|
|
93
177
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
94
|
-
|
|
178
|
+
const insets = [...rule.style].filter(isInset);
|
|
179
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
180
|
+
const layout = [...margins, ...insets];
|
|
181
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
95
182
|
}
|
|
96
|
-
if (!rule.cssRules) return '';
|
|
97
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
98
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
99
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
183
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
184
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
185
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
186
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
100
187
|
}).join('\n');
|
|
101
|
-
const
|
|
102
|
-
|
|
188
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
189
|
+
const layout = sheets.map(sheet => {
|
|
190
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
103
191
|
}).join('\n');
|
|
104
192
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
105
193
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
106
|
-
const
|
|
107
|
-
if (clone.style && source !== poster
|
|
108
|
-
[...
|
|
109
|
-
|
|
194
|
+
const restoreLayout = (source, clone) => {
|
|
195
|
+
if (clone.style && source !== poster) {
|
|
196
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
197
|
+
const computed = getComputedStyle(source);
|
|
198
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
199
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
200
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
201
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
202
|
+
if (insets) {
|
|
203
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
204
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
205
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
206
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
207
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
208
|
+
}
|
|
110
209
|
}
|
|
111
210
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
112
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
211
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
113
212
|
};
|
|
114
213
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
115
214
|
const fontFiles = new Map();
|
|
116
|
-
const fontRules = await Promise.all(
|
|
215
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
117
216
|
let css;
|
|
118
217
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
119
218
|
catch (cause) {
|
|
@@ -164,12 +263,12 @@ function addPosterDownloads() {
|
|
|
164
263
|
node.style.backgroundImage = background;
|
|
165
264
|
};
|
|
166
265
|
const blob = await domToBlob(poster, {
|
|
167
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
266
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
168
267
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
169
268
|
onCloneNode: clone => {
|
|
170
|
-
|
|
269
|
+
restoreLayout(poster, clone);
|
|
171
270
|
const style = document.createElement('style');
|
|
172
|
-
style.textContent =
|
|
271
|
+
style.textContent = layout;
|
|
173
272
|
clone.prepend(style);
|
|
174
273
|
},
|
|
175
274
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -197,10 +296,11 @@ function addPosterDownloads() {
|
|
|
197
296
|
busy = false;
|
|
198
297
|
button.removeAttribute('aria-disabled');
|
|
199
298
|
button.removeAttribute('aria-busy');
|
|
299
|
+
fitButton.removeAttribute('aria-disabled');
|
|
200
300
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
201
301
|
}
|
|
202
302
|
});
|
|
203
|
-
tools.append(
|
|
303
|
+
tools.append(actions);
|
|
204
304
|
});
|
|
205
305
|
}
|
|
206
306
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
@@ -217,6 +317,6 @@ else {
|
|
|
217
317
|
```
|
|
218
318
|
|
|
219
319
|
The block above is the maintained source for the recipe controls.
|
|
220
|
-
After changing it, run `node scripts/sync-poster-download.mjs` from the rhp repository to update every recipe.
|
|
320
|
+
After changing it, run `node scripts/sync-poster-download.mjs` from the rhp repository to update every recipe and existing example poster.
|
|
221
321
|
Test an actual PNG download on a desktop and a phone, including a poster taller than the viewport, and verify that the image includes the bottom note and excludes the controls.
|
|
222
322
|
The implementation uses [modern-screenshot](https://github.com/qq15725/modern-screenshot) to preserve computed styles, pseudo-elements, images and embedded fonts.
|
|
@@ -30,7 +30,7 @@ rhp draws all of these; the recipe named has the technique, not the design.
|
|
|
30
30
|
| a route or a sequence in space (stops, stages, a course, a river) | one line with its stations in order | `stacked-100` (its key is a metro line), `gantt`, `slope` |
|
|
31
31
|
| a document the reader knows (a receipt, a ledger, a timetable, a scoreboard, a league table, a menu) | the chart laid out as that document, its lines the slats | `lollipop`, `waterfall`, `sparklines`, `live` |
|
|
32
32
|
| a place (states, districts, seats in a hall, a pitch, a shelf) | a grid of tiles that echoes the place | `heatmap`'s composition (a Plot of rows, each a Plot of Cells) |
|
|
33
|
-
| an instrument that measures it (a thermometer, a gauge, a ruler, a clock, a scale) | the instrument's own scale and pointer | a `Scale` drawn your own way, a Tick as the pointer, `radial-bars` |
|
|
33
|
+
| an instrument that measures it (a thermometer, a gauge, a ruler, a clock, a scale) | the instrument's own scale and pointer | a `Scale` drawn your own way, a Tick as the pointer, `radial-bars`, `tape` (a ruler's rules and numerals along a whole cut into lengths) |
|
|
34
34
|
|
|
35
35
|
- The native form is the hero, and the numbers still have to be read: print each value on or beside its mark, since a ring, an outline or a tile is read less exactly than a bar on a shared baseline.
|
|
36
36
|
- When an exact comparison is the whole point, keep bars from one baseline and let the subject show in how the marks are drawn (design.md section 2).
|
|
@@ -76,7 +76,7 @@ The headline will be its answer.
|
|
|
76
76
|
| see a value over two categories | a grid of values (day by hour) | heatmap | `heatmap` |
|
|
77
77
|
| see two groups across ordered bands | two values per band | pyramid | `pyramid` |
|
|
78
78
|
| see things known by their shape at their size | one value per item, each item with an outline | silhouettes to scale | `silhouettes` |
|
|
79
|
-
| see how a day or another cycle splits | parts that add up to the cycle | a dial | `dial` |
|
|
79
|
+
| see how a day or another cycle splits | parts that add up to the cycle | a dial, or a tape cut into lengths | `dial`, `tape` |
|
|
80
80
|
| follow one part of a total over periods, and count it | parts per period that add up | unit stacks with the part at the foot | `unit-stack` |
|
|
81
81
|
| see a schedule and its progress | tasks with start, end, progress | gantt | `gantt` |
|
|
82
82
|
| follow prices with their range | open, high, low, close per day | candlestick | `candlestick` |
|
|
@@ -150,6 +150,7 @@ The interaction listed is the recipe's; interaction.md has the others.
|
|
|
150
150
|
| `pyramid` | two sides of a population by age | 2 | an age picked lights everyone that age or older |
|
|
151
151
|
| `silhouettes` | ten towers to scale, each in its own outline, on one street | 1, vertical, `shape()` per row | readout of the tower picked, its level carried to the tallest |
|
|
152
152
|
| `dial` | the parts of a day around a 24-hour dial | 10 + 9 | a hand that turns to the part picked, a readout in the hub |
|
|
153
|
+
| `tape` | the parts of a day laid end to end along a tape measure cut into three 8-hour lengths | 2 (an `overlap` Plot of Bars from each part's start to its end), a Chart per length + 9 | readout of the part picked, its key lines picking too |
|
|
153
154
|
| `log-line` | ten years of a price on a log scale with a line at each doubling, its falls shaded | 5 (the falls as one filled Line) + 8 + 9, log scale | a crosshair readout by pointer, tap, drag or keys |
|
|
154
155
|
| `unit-stack` | monthly spending as piles of coins, one part at the foot of every pile | 2 (the picked part and the rest), units by a mask | a picker of parts |
|
|
155
156
|
| `gantt` | tasks over weeks, progress, today | 2 + 8 + 9 | readout of a task and its dependencies |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Interaction
|
|
2
2
|
|
|
3
|
-
How to
|
|
3
|
+
How to choose interaction behavior and presentation for the reader's task, and how to build an interaction a user asks for.
|
|
4
4
|
rhp keeps no interaction state: the app holds it in a signal, passes it to the Plot as data, and the slat shows it.
|
|
5
5
|
|
|
6
6
|
Contents:
|
|
@@ -71,10 +71,14 @@ The page's CSS styles what sits around the chart (the poster, the readout, butto
|
|
|
71
71
|
|
|
72
72
|
## 1. Pick the interaction from the story
|
|
73
73
|
|
|
74
|
-
When the user names an interaction,
|
|
75
|
-
When they name none,
|
|
74
|
+
When the user names an interaction, preserve the behavior they requested and use the sections below for the techniques it needs.
|
|
75
|
+
When they name none, decide what the reader should be able to inspect, compare or change, and what visible response helps them do it.
|
|
76
|
+
The table offers working examples, not fixed pairings between stories, chart types and controls.
|
|
77
|
+
Reuse reliable state and event handling, while choosing the controls, feedback, placement and transitions for this chart.
|
|
78
|
+
The same selection state can drive an inline annotation, a comparison or a detail reveal; a separate readout band is only one way to present it.
|
|
79
|
+
Keep a familiar pattern when it fits the task and design; difference alone is not a reason to change it.
|
|
76
80
|
|
|
77
|
-
| The
|
|
81
|
+
| The reader's task | Example interaction | Section | Recipes demonstrating the technique |
|
|
78
82
|
|---|---|---|---|
|
|
79
83
|
| compare items: which is bigger, by how much | readout of the slat under the pointer, finger or focus, with the fields the bars do not show | 2 | bar, column, pyramid, donut |
|
|
80
84
|
| a ranking whose order depends on the measure | sort toggle | 3 | grouped-bars, bullet, dumbbell |
|
|
@@ -95,7 +95,7 @@ Start from a recipe, never from what another library does.
|
|
|
95
95
|
6. **Numbers show long decimals while they move** [26]: in the JS version (`animate`) values are fractional mid-way; round what you print.
|
|
96
96
|
7. **Bars vanish, or a slat jumps to the first place, after the data changes** [4]: a `style` given as a string that changed (or turned to nothing) replaced the variables rhp keeps in the element's style.
|
|
97
97
|
Give a slat root's changing style as an object: `style=${() => ({ "--n": d.n })}`.
|
|
98
|
-
Blocks need the same workaround on
|
|
98
|
+
Blocks need the same workaround only on rhp 2.0.2; 2.0.3 fixed Block style replacements.
|
|
99
99
|
8. **`animate` is on, but a value still jumps**: the JS version never moves a group given as a function of the row (`v=${(d) => ...}`). Give the changing numbers as a list (`v=${() => values()}`), a single value or a field of `rows`.
|
|
100
100
|
9. **A race stops at each step, or a live feed's marks stop between readings**: to play back data you have, never step through it with a timer; move a playhead on every frame, give the Plot the values interpolated between the steps around it, and keep the JS version's default 150 ms, so the marks glide and Pause leaves them between steps (interaction.md section 10). For a live feed, whose next value is not known yet, ease linear into each new reading over a little more than one interval (`ease: "linear"`, 1.5 intervals); with ease-out or a shorter duration the marks rest between readings.
|
|
101
101
|
10. **Marks trail behind data that changes rapidly or continuously** (a live feed, a slider or drag that drives the data, a play button, a bar chart race): in the default CSS version every new value restarts a CSS transition from wherever the mark is, so under steady change the marks always lag, and Safari can stall transitions that input events retarget. Give the Plot `animate` for the data groups that change (`animate=${["value"]}`, or `animate=${true}`), or the Chart when the scale moves with the data: rhp's JS clock carries each move into the next value. Measured with a slider changing the data every frame: the CSS version trailed the data by 10 points of 100, the JS version by 6.
|
|
@@ -116,12 +116,13 @@ Start from a recipe, never from what another library does.
|
|
|
116
116
|
|
|
117
117
|
## The layout jumps when the reader interacts
|
|
118
118
|
|
|
119
|
-
The checker warns (`layout-jump`) when a control or the chart moves after one of its interactions; these are the usual causes. It tries only some controls, so try every one yourself and compare.
|
|
119
|
+
The checker warns (`layout-jump`) when a control or the chart moves after one of its interactions, (`text-shift`) when a text that still says the same thing moves, and (`jitter`) when a text moves as the pointer goes along a chart that follows it (a line read day by day); these are the usual causes. It tries only some controls, so try every one yourself and compare.
|
|
120
120
|
|
|
121
121
|
1. **Text that changes with a switch moves the controls below it** (a headline or a dek per question): stack every version in one grid cell (a wrapper with `display: grid`, each version at `grid-area: 1 / 1`, `visibility: hidden` except the current one), so the cell is always as tall as the longest version.
|
|
122
122
|
Align the versions to the cell's end (`align-self: end`) when the chart is below them, so a short version leaves no empty line above the chart.
|
|
123
123
|
2. **A readout that wraps on a phone pushes the chart down**: give it a `min-height` for its longest text at 320px (`min-height: 2lh`, or `3lh`), counting the longest numbers and dates that gain a year ("Jan 4, 2027").
|
|
124
124
|
3. **Numbers that change width shift what follows them**: use `font-variant-numeric: tabular-nums`, and give a column of numbers a fixed room; in an "auto" end room, align them across slats with `justify-self: stretch` and `justify-content: flex-end` on the label.
|
|
125
|
+
In a readout, tabular figures alone are not enough when the number of digits changes ("$654" to "$84,176") or a date's month names differ in width: start each part where nothing before it changes width (a grid with a column per part, or each part on its own line), or give the value a box with a `min-width` in `ch` for the longest value it can show, right-aligned. A label or a unit after a value on the same line ("8,101 tons in 2026") slides at every step of a pointer moving along the chart.
|
|
125
126
|
4. **Every bar slides when a slat is lit**: the lit name turns bold in a `room.start` of "auto", which measures the widest name again and moves the plot.
|
|
126
127
|
Change only the lit name's color, or keep its bold width at rest: room in px, or the bold text drawn hidden under the name (`.name::after { content: attr(data-name); display: block; height: 0; overflow: hidden; visibility: hidden; font-weight: 700; }`, with `data-name=${() => d.name}` on the Label; measured: the bars held still at 1280px, where a plain bold name moved them 10.8px).
|
|
127
128
|
5. **A button whose text changes (Play, Pause) moves its neighbors**: stack both texts in one grid cell, the hidden one `visibility: hidden`, so the button is always as wide as the longer one.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Adds the poster's fit-to-screen and PNG download block to a standalone HTML page, before </body>.
|
|
2
|
+
// The block is the same in every recipe; this copies it from one, so the agent never reads it.
|
|
3
|
+
// Usage: node scripts/add-download.mjs <page.html>
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const page = process.argv[2];
|
|
10
|
+
if (!page || !fs.existsSync(page)) {
|
|
11
|
+
console.error("Usage: node scripts/add-download.mjs <page.html> (the page must exist)");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const html = fs.readFileSync(page, "utf8");
|
|
16
|
+
if (html.includes('id="poster-download-script"')) {
|
|
17
|
+
console.log(`${page} already has the download block.`);
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
if (!/<\/body>/i.test(html)) {
|
|
21
|
+
console.error(`${page} has no </body>: add the block by hand, from <style id="poster-download-styles"> to </body> of any recipe.`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const recipe = fs.readFileSync(path.join(here, "../recipes/bar.html"), "utf8");
|
|
26
|
+
const start = recipe.indexOf('<style id="poster-download-styles">');
|
|
27
|
+
const end = recipe.lastIndexOf("</body>");
|
|
28
|
+
const block = recipe.slice(start, end);
|
|
29
|
+
|
|
30
|
+
// We insert before the last </body>, so a </body> inside a script or a comment is left alone.
|
|
31
|
+
const at = html.toLowerCase().lastIndexOf("</body>");
|
|
32
|
+
fs.writeFileSync(page, html.slice(0, at) + block + html.slice(at));
|
|
33
|
+
console.log(`Added the fit-to-screen and download block to ${page} (${block.length} characters). The poster needs the class "poster" for the block to find it.`);
|
package/src/check/README.md
CHANGED
|
@@ -19,14 +19,26 @@ The CLI (`rhp-mcp check`) and the MCP tool (`rhp_check`) print the same report.
|
|
|
19
19
|
It runs at each width checked, and on a page of its own at each of the page's own breakpoints between that width and the next narrower one (both sides of each `min-width` or `max-width` of its media queries, up to six per width), where a layout made only for the widths checked jumps: a readout that takes another line for one slat, just past the width where the poster's padding changes.
|
|
20
20
|
With reduced motion a pick shows at once, so two frames after each are enough: the sweep takes a few seconds per width.
|
|
21
21
|
Widths between those (a jump only from 322 to 338px) are not swept; a readout that keeps its height by design (one line, or the same number of lines whatever it says) has none.
|
|
22
|
+
While the sweep picks, the texts around the charts that a pick rewrites are noted: that is the readout.
|
|
23
|
+
When the main chart draws a Line or an Area, a layer over the plot takes the pointer (the main Plot's first slat cannot be pointed at) or the main Plot has one or two slats (a line per series), the reader moves along the chart, and the sweep first **scrubs** it (`scrub()` in index.js): the mouse goes through 41 points across the plot's middle, along the scale of the plot that draws the line or the area (or of a Plot of one or two slats) and across the main Plot's slats otherwise, at every width (a finger dragging along a phone's plot shows the same readout).
|
|
24
|
+
After each point the layout is measured as for a pick, and so is every line of text around the charts, each text node on its own (its first line box, in the box it floats in), against the page with the pointer away: a text that still says the same thing and moved jitters, and so does one whose words changed and that kept none of its edges (its start, its end or, on one line, its middle) where it was.
|
|
25
|
+
When the page's text changes at three steps in four or more, the readout follows the pointer along the chart (a line read day by day), and a whole sentence that slides counts, though a pick lets one slide. When it changes less often, it changes once per mark crossed (bubbles that a layer covers), and a slide along the line counts as for a pick: only for a text that still says the same thing, and not for a sentence. A text that moves up or down (a readout's name centered beside a description that takes another line) counts either way.
|
|
26
|
+
The report says when no slat could be picked, rather than that nothing moved.
|
|
27
|
+
Then, when the main chart is taller than three quarters of the window (664px at a phone's width), the page is scrolled so that the chart's middle is in the middle of the window, and the controls of the chart's section (not its fit and download icons) and the readout are noted when all of them are out of view.
|
|
28
|
+
At the first width, with a mouse, the **glide** (`glide()` in index.js) then moves the pointer from a slat to its neighbor in twelve steps, for the first two slats, two in the middle and the last two, and reads after each step the page's visible text and how the slats are drawn (two hashes).
|
|
29
|
+
When both slats show something of their own and a step between them shows the text, or the slats, as they are with the pointer away, the readout or the lit slat flickers in the gap.
|
|
22
30
|
6. **Interact** (interact.js), on a fresh page with motion on, as a reader gets it.
|
|
23
31
|
At the first width: point at a mark of the first, middle and last slat of the largest chart (in its first Plot that takes the pointer), and of the slat whose edge Label is widest when it is wider than theirs (lit, a name that gets bolder can make room "auto" wider); click each button and toggle, change each range and select, press an arrow key on a slider (`role=slider` first, else a range), and press Tab and the arrow keys when slats take focus.
|
|
24
32
|
Then Tab through the page (up to 8 stops), and compare a screenshot of each stop's box (with 12px around it for an outline) focused and blurred: under 20 pixels that change (by more than 24 in all three channels) is focus that shows nothing.
|
|
25
33
|
At a phone's width, in a window 664px tall (what Safari shows of a page on a 390 by 844 iPhone): tap a mark of the first and the last slat, and note when the tap changed text the reader can see (wrote it, or showed it) but all of that text lies outside the window while the tapped slat is in it; a style the tap changed in view (the tapped bar lit) does not tell the reader what the text says.
|
|
26
34
|
A point on a mark is one that reaches its slat: on its marks, inside an SVG shape in it (`isPointInFill`, for a donut's wedge that fills a small part of its box), or at a Label's center. When none does and the slat takes the pointer, something covers it, and the step says "could not reach slat N" and what covers it instead of pointing at a corner of a box.
|
|
27
|
-
After each step the guard reads again what the slats read once (stale values, static charts), and the pass notes what moved on the page (the layout jumps).
|
|
35
|
+
After each step the guard reads again what the slats read once (stale values, static charts), and the pass notes what moved on the page (the layout jumps, and the texts that moved).
|
|
36
|
+
A step that is not a pick (a button, a range, a select, a key on a slider) is also read five times as it runs, every 200ms, so a text that jitters while Play runs the years counts even when it is back in place at the end.
|
|
37
|
+
A text is measured in the box it floats in when it floats (fixed, sticky or absolute: a readout pinned to the window, a tooltip that follows the pointer), else on the page, and only when its own text (its text nodes, not its children's) is the same as before: a value that changed may change its size, but the label beside it stays put.
|
|
38
|
+
A text that moves by as much as something that jumped is reported with that jump, not again.
|
|
28
39
|
Controls go by their accessible name, slats by their Labels' text as the reader sees it (not hidden text, nor screen-reader text).
|
|
29
40
|
7. **Report** (findings.js turns probe results into findings, report.js writes the text): errors first, then warnings, then notes, what passed, the interactions (with one line for the sweep: how many slats, and every width it picked them at) and the screenshots.
|
|
41
|
+
A finding whose fix the skill's references explain names that section on a line of its own (`See: pitfalls.md, "The layout jumps when the reader interacts"`, from `SEE` in findings.js), so an agent whose fix did not hold reads that section, not the whole file; the headings it names must stay as they are.
|
|
30
42
|
When pointing at slats (and tapping them) changes nothing but buttons or a slider change the chart, the report says so in one line.
|
|
31
43
|
When nothing changes and the page has no controls, it says "no interaction found (fine when none was asked for)" in one line, instead of a line per slat (slats that others cover, in an overlap Plot with no interaction, are left out of it, as long as one slat was reached).
|
|
32
44
|
|
|
@@ -141,8 +153,12 @@ The tests read this table: every code in it must have a chart in `test/broken/`
|
|
|
141
153
|
| Code | Level | What triggers it | Fix it gives |
|
|
142
154
|
|---|---|---|---|
|
|
143
155
|
| `unnamed-control` | warning | A button, input, select or control with a role (slider, switch, tab...) that has no accessible name: no text, aria-label, aria-labelledby or label, so a screen reader says only what kind of control it is. | Visible text, an aria-label on an icon button, or a `<label>` around an input or a select. |
|
|
144
|
-
| `layout-jump` | warning | An interaction of the pass, or a pick of the sweep, moves a control other than the one it used, or a chart's top, by more than 2px (a readout that takes a second line, a button whose label changes its width); or, while it lights a slat (pointing, a tap, the keyboard), it moves an edge of the plot (a lit name that gets bolder makes room "auto" wider, and every bar slides) or the end of the chart's section (its outermost box in the page: a readout after the chart that takes a second line), by more than 2px. A control in a slat that moves with its slat (rows sorted or removed) is left out, and so are fixed and sticky elements. | A box that keeps its size: a readout on one line with an ellipsis, or a min-height for its longest text; buttons that keep their width; names that keep their weight and size when lit, or room in px. |
|
|
156
|
+
| `layout-jump` | warning | An interaction of the pass, or a pick of the sweep, moves a control other than the one it used, or a chart's top, by more than 2px (a readout that takes a second line, a button whose label changes its width); or, while it lights a slat (pointing, a tap, the keyboard), it moves an edge of the plot (a lit name that gets bolder makes room "auto" wider, and every bar slides) or the end of the chart's section (its outermost box in the page: a readout after the chart that takes a second line), or it changes the size of a box around the charts that has a background, a border or a shadow and holds no chart (a readout's card that shrinks to its text, a legend's panel), by more than 2px. A control in a slat that moves with its slat (rows sorted or removed) is left out, and so are fixed and sticky elements; a box that floats (fixed or absolute: a tooltip) is left out. | A box that keeps its size: a readout on one line with an ellipsis, or a min-height for its longest text; a card or a panel with a fixed width, or a min-width and min-height for its longest content; buttons that keep their width; names that keep their weight and size when lit, or room in px. |
|
|
157
|
+
| `text-shift` | warning | An interaction of the pass, a pick of the sweep, or a moment while a control runs (Play), moves a text around the charts whose own text did not change, by more than 2px, inside the box it floats in or on the page: a label after a value that got wider, a unit after a number in proportional figures, a readout's lines that change order, a legend that reflows, a column that resizes to its widest value. One finding per width, each text at the most it moved; a text that moved with a layout jump is that jump. | Numbers in tabular figures in a column of their own or with a min-width in ch, right-aligned; labels in their own column (a grid), not after a value on one line; lines in a fixed order; a min-height for the readout's longest text. |
|
|
158
|
+
| `jitter` | warning | While the scrub moves the pointer along the chart, a text around the charts moves by more than 2px against the page at rest, inside the box it floats in or on the page: a label or a unit after a value that changed width ("8,101 tons in 2026", where " in 2026" slides), a phrase after a date whose month names differ in width, a sentence whose values change, a line that wraps on some days and not others. One finding per width, each text at the most it moved, named with the line it is part of; a text that moved with a layout jump is that jump, and a text found here is left out of the width's text-shift. | Start each part of the readout where nothing before it changes width: a grid with a column per part, or each part on a line of its own. Values in tabular figures with a min-width in ch for the longest value they can show, right-aligned when they end a column; the readout on its lines whatever it says (nowrap, or a min-height). |
|
|
159
|
+
| `flicker` | warning | With a mouse, between two neighboring slats that each show something of their own, a step of the glide shows the page's text, or the slats, as they are with the pointer away: the pick clears in the gap between two bars and comes back, so the readout or the lit slat blinks as the reader moves along the chart. | Keep the pick while the pointer is between slats: change it only on another slat, clear it only when the pointer leaves the chart or the poster. |
|
|
145
160
|
| `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). |
|
|
161
|
+
| `scrolls-away` | info | The main chart is taller than three quarters of the window (664px at a phone's width), and with its middle in the middle of the window, all of its section's controls (not its fit and download icons), or all of its readout (the texts a pick rewrites), are out of view: the reader has to scroll back to change the year or to read the values. A note: keeping them in view is usually better, not always. | Consider `position: sticky; top: env(safe-area-inset-top, 0px)` on the row that holds them, with a background and a z-index over the plot's 1, compact on a phone, and `scroll-margin-top` on the slats so a picked slat is never under it. |
|
|
146
162
|
| `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
163
|
|
|
148
164
|
### Conditional selection repair
|
|
@@ -150,17 +166,25 @@ The tests read this table: every code in it must have a chart in `test/broken/`
|
|
|
150
166
|
The shared checker runs this audit after creation, through both `rhp_check` and the CLI (`npx -y @bezda/rhp-mcp check <file>`).
|
|
151
167
|
It does not require either click selection or hover, and these checks do not choose an interaction for the chart.
|
|
152
168
|
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
|
-
|
|
169
|
+
That observed combination qualifies for the click-selection checks.
|
|
154
170
|
It then checks a pointer move and a click on noninteractive text or margin inside the same poster, outside every chart and control.
|
|
155
171
|
The initial appearance is the reset reference, preserving an existing default without guessing which subject should be selected.
|
|
172
|
+
If the poster also has a visible range, number input, select, or external slider, the audit first checks whether two of its values reproduce two different chart hover states.
|
|
173
|
+
Only a control proven to select the same data changes the reset policy; an unrelated filter does not qualify.
|
|
174
|
+
For a proven selection control, the audit checks that leaving the chart restores its committed value and chosen view, and moving focus or clicking noninteractive poster space preserves that choice.
|
|
175
|
+
The visible control may follow the hovered point during preview, provided it returns to the committed choice when preview ends.
|
|
176
|
+
Native thumbs and focus rings are excluded from the image comparison, while the chart and its readout remain in the comparison.
|
|
177
|
+
The initial-state reset recommendation is omitted for these persistent control selections.
|
|
156
178
|
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.
|
|
179
|
+
The audit samples up to three reachable slats of the main Plot at the first checked width, or both ends and the middle of a single continuous hit surface.
|
|
180
|
+
Controls outside the viewport or without two observable matching selection states are inconclusive.
|
|
158
181
|
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
182
|
|
|
160
183
|
| Code | Level | What triggers it | Fix it gives |
|
|
161
184
|
|---|---|---|---|
|
|
185
|
+
| `selection-control-reset` | warning | A control demonstrably selects the same data as chart hover, but leaving the chart, moving focus, or clicking noninteractive poster space loses its committed value or chosen view. | Keep the committed input selection separate from hover; allow a synchronized preview while preserving the choice through pointer and focus changes, and share it with chart click, tap, and keyboard selection. |
|
|
162
186
|
| `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. |
|
|
187
|
+
| `selection-reset` | warning | The same verified pattern, without a proven external selection control: 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
188
|
|
|
165
189
|
### Colors (read from screenshots)
|
|
166
190
|
|
package/src/check/build.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from "path";
|
|
|
10
10
|
import crypto from "crypto";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
12
|
import * as esbuild from "esbuild";
|
|
13
|
+
import { moduleSpecifiers } from "./source.js";
|
|
13
14
|
|
|
14
15
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const GUARD = path.join(here, "../guard");
|
|
@@ -423,12 +424,12 @@ export async function htmlProblems(file, text) {
|
|
|
423
424
|
}
|
|
424
425
|
|
|
425
426
|
if (module) {
|
|
426
|
-
for (const
|
|
427
|
-
if (/^(https?:|data:|blob
|
|
427
|
+
for (const { source: spec, line } of moduleSpecifiers(body, file)) {
|
|
428
|
+
if (/^(https?:|data:|blob:|\.|\/)/.test(spec) || spec in mapped || Object.keys(mapped).some((k) => k.endsWith("/") && spec.startsWith(k))) continue;
|
|
428
429
|
problems.push({
|
|
429
430
|
level: "error",
|
|
430
431
|
code: "missing-import-map",
|
|
431
|
-
message: `${path.basename(file)}:${start}: the module script imports "${spec}", but no import map says where it comes from.`,
|
|
432
|
+
message: `${path.basename(file)}:${start + line - 1}: the module script imports "${spec}", but no import map says where it comes from.`,
|
|
432
433
|
fix: spec.startsWith("@bezda/rhp") ? 'Add <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script> before the module script.' : `Add "${spec}" to the page's import map, or import it by URL.`,
|
|
433
434
|
});
|
|
434
435
|
}
|
package/src/check/colors.js
CHANGED
|
@@ -164,11 +164,13 @@ export function colorBlind(items, hidden, k) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
for (const marks of charts.values()) {
|
|
167
|
-
// one
|
|
167
|
+
// One authored paint is still one series over a photograph, even when transparent marks
|
|
168
|
+
// blend with different parts of it. Also merge colors that already look the same.
|
|
168
169
|
const colors = [];
|
|
169
170
|
for (const m of marks) {
|
|
170
171
|
const rgb = m.rgb;
|
|
171
|
-
|
|
172
|
+
const samePaint = (other) => other.opacity === m.opacity && other.color.every((v, i) => v === m.color[i]);
|
|
173
|
+
let c = colors.find((x) => x.marks.some(samePaint) || difference(x.rgb, rgb) < 2);
|
|
172
174
|
if (!c) colors.push((c = { rgb, names: new Set(), marks: [] }));
|
|
173
175
|
c.marks.push(m);
|
|
174
176
|
for (const n of m.names) {
|