@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.
Files changed (57) hide show
  1. package/package.json +6 -1
  2. package/skill/SKILL.md +113 -22
  3. package/skill/recipes/area.html +129 -33
  4. package/skill/recipes/bar.html +129 -33
  5. package/skill/recipes/box-plot.html +129 -33
  6. package/skill/recipes/bubble.html +129 -33
  7. package/skill/recipes/bullet.html +129 -33
  8. package/skill/recipes/candlestick.html +129 -33
  9. package/skill/recipes/column.html +129 -33
  10. package/skill/recipes/dial.html +129 -33
  11. package/skill/recipes/diverging-bars.html +129 -33
  12. package/skill/recipes/donut.html +129 -33
  13. package/skill/recipes/dumbbell.html +129 -33
  14. package/skill/recipes/gantt.html +129 -33
  15. package/skill/recipes/grouped-bars.html +129 -33
  16. package/skill/recipes/heatmap.html +129 -33
  17. package/skill/recipes/histogram.html +129 -33
  18. package/skill/recipes/line.html +129 -33
  19. package/skill/recipes/live.html +129 -33
  20. package/skill/recipes/log-line.html +129 -33
  21. package/skill/recipes/lollipop.html +129 -33
  22. package/skill/recipes/multi-line.html +129 -33
  23. package/skill/recipes/pyramid.html +129 -33
  24. package/skill/recipes/race.html +129 -33
  25. package/skill/recipes/radial-bars.html +129 -33
  26. package/skill/recipes/scatter.html +129 -33
  27. package/skill/recipes/silhouettes.html +129 -33
  28. package/skill/recipes/slope.html +129 -33
  29. package/skill/recipes/sparklines.html +129 -33
  30. package/skill/recipes/stacked-100.html +129 -33
  31. package/skill/recipes/stacked-bars.html +129 -33
  32. package/skill/recipes/strip.html +129 -33
  33. package/skill/recipes/tape.html +578 -0
  34. package/skill/recipes/unit-stack.html +129 -33
  35. package/skill/recipes/violin.html +129 -33
  36. package/skill/recipes/waffle.html +129 -33
  37. package/skill/recipes/waterfall.html +129 -33
  38. package/skill/references/api.md +6 -6
  39. package/skill/references/design.md +18 -0
  40. package/skill/references/download.md +135 -35
  41. package/skill/references/forms.md +3 -2
  42. package/skill/references/interaction.md +8 -4
  43. package/skill/references/pitfalls.md +3 -2
  44. package/skill/scripts/add-download.mjs +33 -0
  45. package/src/check/README.md +29 -5
  46. package/src/check/build.js +4 -3
  47. package/src/check/colors.js +4 -2
  48. package/src/check/findings.js +99 -3
  49. package/src/check/index.js +158 -17
  50. package/src/check/inpage.js +292 -5
  51. package/src/check/interact.js +22 -4
  52. package/src/check/png.js +6 -1
  53. package/src/check/report.js +9 -2
  54. package/src/check/selection-controls.js +105 -0
  55. package/src/check/selection.js +54 -12
  56. package/src/check/source.js +21 -0
  57. package/src/server.js +4 -4
@@ -163,24 +163,84 @@ render(() => html`<${BloodTypes} />`, document.getElementById("chart"));
163
163
  </script>
164
164
  <style id="poster-download-styles">
165
165
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
166
- .poster-download { 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; }
167
- .poster-download svg { display: block; width: 20px; height: 20px; }
168
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
169
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
170
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
166
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
167
+ .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; }
168
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
169
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
170
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
171
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
172
+ .poster-fit-frame, .poster-fit-content { display: contents; }
173
+ .poster-fit-frame { max-height: 95svh; }
174
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
175
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
171
176
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
172
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
177
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
173
178
  </style>
174
179
  <script type="module" id="poster-download-script">
180
+ function addPosterFit(poster, button, changed) {
181
+ const frame = document.createElement('div'), content = document.createElement('div');
182
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
183
+ poster.before(frame); frame.append(content); content.append(poster);
184
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
185
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
186
+ let fitted = false, scale = 1, request = 0, layout = null;
187
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
188
+ const layoutKey = () => {
189
+ const css = getComputedStyle(poster);
190
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
191
+ };
192
+ const restore = () => original.forEach(([name, value, priority]) => {
193
+ if (value) poster.style.setProperty(name, value, priority);
194
+ else poster.style.removeProperty(name);
195
+ });
196
+ function update() {
197
+ request = 0;
198
+ if (fitted && layout === layoutKey()) return;
199
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
200
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
201
+ if (fitted) {
202
+ const box = poster.getBoundingClientRect();
203
+ scale = Math.min(1, availableHeight() / box.height);
204
+ poster.style.setProperty('box-sizing', 'border-box');
205
+ poster.style.setProperty('width', `${box.width}px`);
206
+ poster.style.setProperty('max-width', 'none');
207
+ poster.style.setProperty('margin', '0');
208
+ content.style.width = `${box.width}px`;
209
+ content.style.transform = `scale(${scale})`;
210
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
211
+ frame.classList.add('is-fitted');
212
+ }
213
+ layout = layoutKey();
214
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
215
+ button.setAttribute('aria-label', label); button.title = label;
216
+ button.setAttribute('aria-pressed', String(fitted));
217
+ 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>`;
218
+ changed();
219
+ poster.dispatchEvent(new Event('poster-view-change'));
220
+ }
221
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
222
+ new ResizeObserver(schedule).observe(poster);
223
+ addEventListener('resize', schedule);
224
+ document.fonts.ready.then(schedule);
225
+ update();
226
+ return {
227
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
228
+ dimensions() {
229
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
230
+ const css = getComputedStyle(poster);
231
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
232
+ },
233
+ };
234
+ }
175
235
  function addPosterDownloads() {
176
236
  document.querySelectorAll('.poster').forEach((poster, index) => {
177
237
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
178
238
  const tools = navigation || document.createElement('div');
179
239
  if (tools.querySelector('.poster-download')) return;
240
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
180
241
  if (!navigation) {
181
242
  tools.className = 'poster-download-tools';
182
243
  poster.before(tools);
183
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
184
244
  new ResizeObserver(align).observe(poster);
185
245
  align();
186
246
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -198,27 +258,43 @@ function addPosterDownloads() {
198
258
  button.setAttribute('aria-label', 'Download poster as PNG');
199
259
  button.title = 'Download poster as PNG';
200
260
  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>';
261
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
262
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
263
+ if (!poster.id) {
264
+ let id = `rhp-poster-${index + 1}`;
265
+ while (document.getElementById(id)) id += '-view';
266
+ poster.id = id;
267
+ }
268
+ fitButton.setAttribute('aria-controls', poster.id);
269
+ fitButton.setAttribute('data-poster-view-toggle', '');
270
+ const fit = addPosterFit(poster, fitButton, align);
271
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
272
+ actions.append(fitButton, button);
201
273
  const error = document.createElement('p');
202
274
  error.className = 'poster-download-error';
203
275
  error.setAttribute('role', 'alert');
204
276
  error.hidden = true;
205
277
  tools.after(error);
206
278
  // Download is part of the poster interaction, even though its control sits outside the image.
207
- button.addEventListener('pointerdown', event => {
279
+ actions.addEventListener('pointerdown', event => {
208
280
  event.stopPropagation();
209
281
  });
210
- button.addEventListener('mousedown', event => {
282
+ actions.addEventListener('mousedown', event => {
211
283
  // Pointer activation downloads without taking focus from the selected chart item.
212
284
  event.preventDefault();
213
285
  event.stopPropagation();
214
286
  });
215
287
  poster.addEventListener('focusout', event => {
216
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
288
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
217
289
  }, true);
218
290
  poster.addEventListener('pointerleave', event => {
219
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
291
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
220
292
  }, true);
221
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
293
+ poster.addEventListener('pointermove', event => {
294
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
295
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
296
+ }, true);
297
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
222
298
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
223
299
  });
224
300
  let exporter, retries = 0, busy = false;
@@ -228,6 +304,7 @@ function addPosterDownloads() {
228
304
  busy = true;
229
305
  button.setAttribute('aria-disabled', 'true');
230
306
  button.setAttribute('aria-busy', 'true');
307
+ fitButton.setAttribute('aria-disabled', 'true');
231
308
  button.setAttribute('aria-label', 'Preparing poster PNG');
232
309
  error.hidden = true;
233
310
  let url;
@@ -235,35 +312,53 @@ function addPosterDownloads() {
235
312
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
236
313
  const { domToBlob } = await exporter;
237
314
  await document.fonts.ready;
238
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
239
- const autoMargins = new Set();
240
- const marginRules = rules => [...rules].map(rule => {
315
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
316
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
317
+ const autoMargins = new Set(), positioned = new Set();
318
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
319
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
320
+ const layoutRules = rules => [...rules].map(rule => {
241
321
  if (rule.selectorText && rule.style) {
242
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
322
+ const margins = [...rule.style].filter(isMargin);
243
323
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
244
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
324
+ const insets = [...rule.style].filter(isInset);
325
+ if (insets.length) positioned.add(rule.selectorText);
326
+ const layout = [...margins, ...insets];
327
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
245
328
  }
246
- if (!rule.cssRules) return '';
247
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
248
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
249
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
329
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
330
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
331
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
332
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
250
333
  }).join('\n');
251
- const margins = [...document.styleSheets].map(sheet => {
252
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
334
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
335
+ const layout = sheets.map(sheet => {
336
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
253
337
  }).join('\n');
254
338
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
255
339
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
256
- const restoreMargins = (source, clone) => {
257
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
258
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
259
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
340
+ const restoreLayout = (source, clone) => {
341
+ if (clone.style && source !== poster) {
342
+ const margins = [...autoMargins].some(selector => source.matches(selector));
343
+ const computed = getComputedStyle(source);
344
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
345
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
346
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
347
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
348
+ if (insets) {
349
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
350
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
351
+ const axis = source.getAttribute('data-rhp-o');
352
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
353
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
354
+ }
260
355
  }
261
356
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
262
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
357
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
263
358
  };
264
359
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
265
360
  const fontFiles = new Map();
266
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
361
+ const fontRules = await Promise.all(sheets.map(async sheet => {
267
362
  let css;
268
363
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
269
364
  catch (cause) {
@@ -314,12 +409,12 @@ function addPosterDownloads() {
314
409
  node.style.backgroundImage = background;
315
410
  };
316
411
  const blob = await domToBlob(poster, {
317
- scale: 2, style: { margin: '0' }, filter: includeNode,
412
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
318
413
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
319
414
  onCloneNode: clone => {
320
- restoreMargins(poster, clone);
415
+ restoreLayout(poster, clone);
321
416
  const style = document.createElement('style');
322
- style.textContent = margins;
417
+ style.textContent = layout;
323
418
  clone.prepend(style);
324
419
  },
325
420
  // CSS content's alternative text follows a slash and must not be painted.
@@ -347,10 +442,11 @@ function addPosterDownloads() {
347
442
  busy = false;
348
443
  button.removeAttribute('aria-disabled');
349
444
  button.removeAttribute('aria-busy');
445
+ fitButton.removeAttribute('aria-disabled');
350
446
  button.setAttribute('aria-label', 'Download poster as PNG');
351
447
  }
352
448
  });
353
- tools.append(button);
449
+ tools.append(actions);
354
450
  });
355
451
  }
356
452
  // A chart module may await fonts or data after DOMContentLoaded has already fired.
@@ -125,24 +125,84 @@ render(() => html`<${ProfitBridge} />`, document.getElementById("chart"));
125
125
  </script>
126
126
  <style id="poster-download-styles">
127
127
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
128
- .poster-download { 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; }
129
- .poster-download svg { display: block; width: 20px; height: 20px; }
130
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
131
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
132
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
128
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
129
+ .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; }
130
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
131
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
132
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
133
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
134
+ .poster-fit-frame, .poster-fit-content { display: contents; }
135
+ .poster-fit-frame { max-height: 95svh; }
136
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
137
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
133
138
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
134
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
139
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
135
140
  </style>
136
141
  <script type="module" id="poster-download-script">
142
+ function addPosterFit(poster, button, changed) {
143
+ const frame = document.createElement('div'), content = document.createElement('div');
144
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
145
+ poster.before(frame); frame.append(content); content.append(poster);
146
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
147
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
148
+ let fitted = false, scale = 1, request = 0, layout = null;
149
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
150
+ const layoutKey = () => {
151
+ const css = getComputedStyle(poster);
152
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
153
+ };
154
+ const restore = () => original.forEach(([name, value, priority]) => {
155
+ if (value) poster.style.setProperty(name, value, priority);
156
+ else poster.style.removeProperty(name);
157
+ });
158
+ function update() {
159
+ request = 0;
160
+ if (fitted && layout === layoutKey()) return;
161
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
162
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
163
+ if (fitted) {
164
+ const box = poster.getBoundingClientRect();
165
+ scale = Math.min(1, availableHeight() / box.height);
166
+ poster.style.setProperty('box-sizing', 'border-box');
167
+ poster.style.setProperty('width', `${box.width}px`);
168
+ poster.style.setProperty('max-width', 'none');
169
+ poster.style.setProperty('margin', '0');
170
+ content.style.width = `${box.width}px`;
171
+ content.style.transform = `scale(${scale})`;
172
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
173
+ frame.classList.add('is-fitted');
174
+ }
175
+ layout = layoutKey();
176
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
177
+ button.setAttribute('aria-label', label); button.title = label;
178
+ button.setAttribute('aria-pressed', String(fitted));
179
+ 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>`;
180
+ changed();
181
+ poster.dispatchEvent(new Event('poster-view-change'));
182
+ }
183
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
184
+ new ResizeObserver(schedule).observe(poster);
185
+ addEventListener('resize', schedule);
186
+ document.fonts.ready.then(schedule);
187
+ update();
188
+ return {
189
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
190
+ dimensions() {
191
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
192
+ const css = getComputedStyle(poster);
193
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
194
+ },
195
+ };
196
+ }
137
197
  function addPosterDownloads() {
138
198
  document.querySelectorAll('.poster').forEach((poster, index) => {
139
199
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
140
200
  const tools = navigation || document.createElement('div');
141
201
  if (tools.querySelector('.poster-download')) return;
202
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
142
203
  if (!navigation) {
143
204
  tools.className = 'poster-download-tools';
144
205
  poster.before(tools);
145
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
146
206
  new ResizeObserver(align).observe(poster);
147
207
  align();
148
208
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -160,27 +220,43 @@ function addPosterDownloads() {
160
220
  button.setAttribute('aria-label', 'Download poster as PNG');
161
221
  button.title = 'Download poster as PNG';
162
222
  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>';
223
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
224
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
225
+ if (!poster.id) {
226
+ let id = `rhp-poster-${index + 1}`;
227
+ while (document.getElementById(id)) id += '-view';
228
+ poster.id = id;
229
+ }
230
+ fitButton.setAttribute('aria-controls', poster.id);
231
+ fitButton.setAttribute('data-poster-view-toggle', '');
232
+ const fit = addPosterFit(poster, fitButton, align);
233
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
234
+ actions.append(fitButton, button);
163
235
  const error = document.createElement('p');
164
236
  error.className = 'poster-download-error';
165
237
  error.setAttribute('role', 'alert');
166
238
  error.hidden = true;
167
239
  tools.after(error);
168
240
  // Download is part of the poster interaction, even though its control sits outside the image.
169
- button.addEventListener('pointerdown', event => {
241
+ actions.addEventListener('pointerdown', event => {
170
242
  event.stopPropagation();
171
243
  });
172
- button.addEventListener('mousedown', event => {
244
+ actions.addEventListener('mousedown', event => {
173
245
  // Pointer activation downloads without taking focus from the selected chart item.
174
246
  event.preventDefault();
175
247
  event.stopPropagation();
176
248
  });
177
249
  poster.addEventListener('focusout', event => {
178
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
250
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
179
251
  }, true);
180
252
  poster.addEventListener('pointerleave', event => {
181
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
253
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
254
+ }, true);
255
+ poster.addEventListener('pointermove', event => {
256
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
257
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
182
258
  }, true);
183
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
259
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
184
260
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
185
261
  });
186
262
  let exporter, retries = 0, busy = false;
@@ -190,6 +266,7 @@ function addPosterDownloads() {
190
266
  busy = true;
191
267
  button.setAttribute('aria-disabled', 'true');
192
268
  button.setAttribute('aria-busy', 'true');
269
+ fitButton.setAttribute('aria-disabled', 'true');
193
270
  button.setAttribute('aria-label', 'Preparing poster PNG');
194
271
  error.hidden = true;
195
272
  let url;
@@ -197,35 +274,53 @@ function addPosterDownloads() {
197
274
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
198
275
  const { domToBlob } = await exporter;
199
276
  await document.fonts.ready;
200
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
201
- const autoMargins = new Set();
202
- const marginRules = rules => [...rules].map(rule => {
277
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
278
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
279
+ const autoMargins = new Set(), positioned = new Set();
280
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
281
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
282
+ const layoutRules = rules => [...rules].map(rule => {
203
283
  if (rule.selectorText && rule.style) {
204
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
284
+ const margins = [...rule.style].filter(isMargin);
205
285
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
206
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
286
+ const insets = [...rule.style].filter(isInset);
287
+ if (insets.length) positioned.add(rule.selectorText);
288
+ const layout = [...margins, ...insets];
289
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
207
290
  }
208
- if (!rule.cssRules) return '';
209
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
210
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
211
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
291
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
292
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
293
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
294
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
212
295
  }).join('\n');
213
- const margins = [...document.styleSheets].map(sheet => {
214
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
296
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
297
+ const layout = sheets.map(sheet => {
298
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
215
299
  }).join('\n');
216
300
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
217
301
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
218
- const restoreMargins = (source, clone) => {
219
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
220
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
221
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
302
+ const restoreLayout = (source, clone) => {
303
+ if (clone.style && source !== poster) {
304
+ const margins = [...autoMargins].some(selector => source.matches(selector));
305
+ const computed = getComputedStyle(source);
306
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
307
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
308
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
309
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
310
+ if (insets) {
311
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
312
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
313
+ const axis = source.getAttribute('data-rhp-o');
314
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
315
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
316
+ }
222
317
  }
223
318
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
224
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
319
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
225
320
  };
226
321
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
227
322
  const fontFiles = new Map();
228
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
323
+ const fontRules = await Promise.all(sheets.map(async sheet => {
229
324
  let css;
230
325
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
231
326
  catch (cause) {
@@ -276,12 +371,12 @@ function addPosterDownloads() {
276
371
  node.style.backgroundImage = background;
277
372
  };
278
373
  const blob = await domToBlob(poster, {
279
- scale: 2, style: { margin: '0' }, filter: includeNode,
374
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
280
375
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
281
376
  onCloneNode: clone => {
282
- restoreMargins(poster, clone);
377
+ restoreLayout(poster, clone);
283
378
  const style = document.createElement('style');
284
- style.textContent = margins;
379
+ style.textContent = layout;
285
380
  clone.prepend(style);
286
381
  },
287
382
  // CSS content's alternative text follows a slash and must not be painted.
@@ -309,10 +404,11 @@ function addPosterDownloads() {
309
404
  busy = false;
310
405
  button.removeAttribute('aria-disabled');
311
406
  button.removeAttribute('aria-busy');
407
+ fitButton.removeAttribute('aria-disabled');
312
408
  button.setAttribute('aria-label', 'Download poster as PNG');
313
409
  }
314
410
  });
315
- tools.append(button);
411
+ tools.append(actions);
316
412
  });
317
413
  }
318
414
  // A chart module may await fonts or data after DOMContentLoaded has already fired.
@@ -42,7 +42,7 @@ Both `@bezda/rhp` and `@bezda/rhp/standalone` export rhp's whole API:
42
42
 
43
43
  - components: `Chart`, `Plot`, `Scale`, `Axis`, `Theme`, `Poster`
44
44
  - blocks: `Bar`, `Dot`, `Tick`, `Label`, `Cell`, `Place`, `Area`, `Line`
45
- - experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in builds of this repository that export it
45
+ - experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in rhp 2.0.3 and later
46
46
  - functions: `slat`, `restyle`, `linkedCss`, `shape`, `at`, `useOrientation`, `series`, `cycle`, `sortBy`, `extent`, `every`, `nice`, `stackUp`, `shares`, `running`, `summary`, `bins`, `density`, `animated`, `curve`, `drawing`
47
47
  - the object `THEME` (the default theme)
48
48
 
@@ -661,10 +661,10 @@ render(ChangeChart, document.getElementById("chart"));
661
661
  ### ManyDots (experimental point collection)
662
662
 
663
663
  `ManyDots` draws a bulk collection of points directly inside a `Chart` with `scale` and `cross`.
664
- It is part of the upcoming npm 2.0.3 release.
664
+ It is in rhp 2.0.3 and later, from npm and from the `@2` CDN URL.
665
665
  It is a collection rather than a block in a slat, so do not wrap it in a `Plot` or supply a slat function.
666
- Use a repository build that exports `ManyDots`; the version-2 CDN URL in the page skeleton does not guarantee this experimental API is available.
667
- Both package entry points export it in that build, and Solid users can import `ManyDotsProps<T>` and `ManyDotsValue<T, V>` for its types.
666
+ It is experimental: its API may still change.
667
+ Both package entry points export it, and Solid users can import `ManyDotsProps<T>` and `ManyDotsValue<T, V>` for its types.
668
668
 
669
669
  Choose it explicitly for bulk HTML points, or keep `Dot` for individual blocks, rich point content, and Plot behavior at any point count.
670
670
  Neither component switches into the other automatically, including at 500 points.
@@ -1601,8 +1601,8 @@ Every one of these was found by running rhp 2.0.1.
1601
1601
  4. **Use style objects on Chart, Plot and a slat's root.**
1602
1602
  `style="pointer-events: none"` on a Plot or Chart is dropped without a message; write `style=${{ "pointer-events": "none" }}`.
1603
1603
  On a slat's root, a string that changes or a style that becomes `undefined` replaces the whole style attribute and erases the stack position, so keep it an object.
1604
- Published rhp 2.0.2 has the same problem on Blocks: a Bar can disappear and SVG geometry can be lost, so use `style=${() => ({ "--n": d.n })}` there too.
1605
- Current source fixes the Block replacement bug for the upcoming 2.0.3 release; Blocks then retain their geometry when strings change or style is removed.
1604
+ rhp 2.0.2 has the same problem on Blocks: a Bar can disappear and SVG geometry can be lost, so use `style=${() => ({ "--n": d.n })}` there too.
1605
+ rhp 2.0.3 fixed it: Blocks keep their geometry when strings change or style is removed.
1606
1606
  Plain children that hold no rhp geometry can use changing strings.
1607
1607
  5. **`className` breaks a block.**
1608
1608
  It replaces rhp's class, so the block is no longer drawn.
@@ -537,6 +537,24 @@ At 390px:
537
537
  Use an ellipsis only when the full name is in the slat's `aria-label` and in the readout.
538
538
  - `room: { start: "auto" }` sizes the names' room to the longest name, up to `--rhp-gutter-max` (40% of the chart).
539
539
 
540
+ ### Fit the label gutters
541
+
542
+ For a poster row containing one main plot, usually fit whichever side gutters contain labels to the text that actually occupies them, so the plot uses the remaining width.
543
+ This helps smaller screens and keeps the outer labels aligned with the caption, controls, readout and footer throughout the poster.
544
+ Treat this as a design preference and let the intended composition take priority, including deliberate spacing or different alignments.
545
+ The poster's own padding supplies the outer margins; do not add a second empty buffer past the labels unless the design calls for one.
546
+
547
+ - Prefer `room: { start: "auto", end: "auto" }` when the labels are direct edge Labels, or use "auto" only on the side that needs it.
548
+ - Labels anchored with `at` are not measured by "auto".
549
+ Measure their rendered sizes and pass reactive pixel values to the appropriate `room` sides instead of choosing fixed desktop and phone guesses.
550
+ - Measure after fonts load and when text, font size, wrapping or stacking changes, including gradual changes between breakpoints.
551
+ Keep each label's box tight to its content so an unnecessarily wide wrapper does not become empty gutter space.
552
+ - Include the clearance between a point and its label, but account for a point sitting inside the scale's end so that inset is not reserved twice.
553
+ The outermost text should reach the poster's content edge without crossing it.
554
+ - Use natural layout sizes, such as `ResizeObserver` border-box sizes, so fitting or zooming the poster does not alter its gutters.
555
+ Schedule size updates in an animation frame outside ResizeObserver delivery to avoid resize loops.
556
+ - Check both label layouts and the widths between breakpoints, then fit the poster and verify that its labels, marks and PNG export keep the same alignment.
557
+
540
558
  How many slats:
541
559
 
542
560
  - Horizontal slats of 28 to 50px; up to about 15 read at a glance, about 30 at 22px each.