@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
@@ -210,24 +210,84 @@ render(() => html`<${GroceryChart} />`, document.getElementById("chart"));
210
210
  </script>
211
211
  <style id="poster-download-styles">
212
212
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
213
- .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; }
214
- .poster-download svg { display: block; width: 20px; height: 20px; }
215
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
216
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
217
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
213
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
214
+ .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; }
215
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
216
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
217
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
218
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
219
+ .poster-fit-frame, .poster-fit-content { display: contents; }
220
+ .poster-fit-frame { max-height: 95svh; }
221
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
222
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
218
223
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
219
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
224
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
220
225
  </style>
221
226
  <script type="module" id="poster-download-script">
227
+ function addPosterFit(poster, button, changed) {
228
+ const frame = document.createElement('div'), content = document.createElement('div');
229
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
230
+ poster.before(frame); frame.append(content); content.append(poster);
231
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
232
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
233
+ let fitted = false, scale = 1, request = 0, layout = null;
234
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
235
+ const layoutKey = () => {
236
+ const css = getComputedStyle(poster);
237
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
238
+ };
239
+ const restore = () => original.forEach(([name, value, priority]) => {
240
+ if (value) poster.style.setProperty(name, value, priority);
241
+ else poster.style.removeProperty(name);
242
+ });
243
+ function update() {
244
+ request = 0;
245
+ if (fitted && layout === layoutKey()) return;
246
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
247
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
248
+ if (fitted) {
249
+ const box = poster.getBoundingClientRect();
250
+ scale = Math.min(1, availableHeight() / box.height);
251
+ poster.style.setProperty('box-sizing', 'border-box');
252
+ poster.style.setProperty('width', `${box.width}px`);
253
+ poster.style.setProperty('max-width', 'none');
254
+ poster.style.setProperty('margin', '0');
255
+ content.style.width = `${box.width}px`;
256
+ content.style.transform = `scale(${scale})`;
257
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
258
+ frame.classList.add('is-fitted');
259
+ }
260
+ layout = layoutKey();
261
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
262
+ button.setAttribute('aria-label', label); button.title = label;
263
+ button.setAttribute('aria-pressed', String(fitted));
264
+ 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>`;
265
+ changed();
266
+ poster.dispatchEvent(new Event('poster-view-change'));
267
+ }
268
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
269
+ new ResizeObserver(schedule).observe(poster);
270
+ addEventListener('resize', schedule);
271
+ document.fonts.ready.then(schedule);
272
+ update();
273
+ return {
274
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
275
+ dimensions() {
276
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
277
+ const css = getComputedStyle(poster);
278
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
279
+ },
280
+ };
281
+ }
222
282
  function addPosterDownloads() {
223
283
  document.querySelectorAll('.poster').forEach((poster, index) => {
224
284
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
225
285
  const tools = navigation || document.createElement('div');
226
286
  if (tools.querySelector('.poster-download')) return;
287
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
227
288
  if (!navigation) {
228
289
  tools.className = 'poster-download-tools';
229
290
  poster.before(tools);
230
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
231
291
  new ResizeObserver(align).observe(poster);
232
292
  align();
233
293
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -245,27 +305,43 @@ function addPosterDownloads() {
245
305
  button.setAttribute('aria-label', 'Download poster as PNG');
246
306
  button.title = 'Download poster as PNG';
247
307
  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>';
308
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
309
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
310
+ if (!poster.id) {
311
+ let id = `rhp-poster-${index + 1}`;
312
+ while (document.getElementById(id)) id += '-view';
313
+ poster.id = id;
314
+ }
315
+ fitButton.setAttribute('aria-controls', poster.id);
316
+ fitButton.setAttribute('data-poster-view-toggle', '');
317
+ const fit = addPosterFit(poster, fitButton, align);
318
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
319
+ actions.append(fitButton, button);
248
320
  const error = document.createElement('p');
249
321
  error.className = 'poster-download-error';
250
322
  error.setAttribute('role', 'alert');
251
323
  error.hidden = true;
252
324
  tools.after(error);
253
325
  // Download is part of the poster interaction, even though its control sits outside the image.
254
- button.addEventListener('pointerdown', event => {
326
+ actions.addEventListener('pointerdown', event => {
255
327
  event.stopPropagation();
256
328
  });
257
- button.addEventListener('mousedown', event => {
329
+ actions.addEventListener('mousedown', event => {
258
330
  // Pointer activation downloads without taking focus from the selected chart item.
259
331
  event.preventDefault();
260
332
  event.stopPropagation();
261
333
  });
262
334
  poster.addEventListener('focusout', event => {
263
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
335
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
264
336
  }, true);
265
337
  poster.addEventListener('pointerleave', event => {
266
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
338
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
267
339
  }, true);
268
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
340
+ poster.addEventListener('pointermove', event => {
341
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
342
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
343
+ }, true);
344
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
269
345
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
270
346
  });
271
347
  let exporter, retries = 0, busy = false;
@@ -275,6 +351,7 @@ function addPosterDownloads() {
275
351
  busy = true;
276
352
  button.setAttribute('aria-disabled', 'true');
277
353
  button.setAttribute('aria-busy', 'true');
354
+ fitButton.setAttribute('aria-disabled', 'true');
278
355
  button.setAttribute('aria-label', 'Preparing poster PNG');
279
356
  error.hidden = true;
280
357
  let url;
@@ -282,35 +359,53 @@ function addPosterDownloads() {
282
359
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
283
360
  const { domToBlob } = await exporter;
284
361
  await document.fonts.ready;
285
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
286
- const autoMargins = new Set();
287
- const marginRules = rules => [...rules].map(rule => {
362
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
363
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
364
+ const autoMargins = new Set(), positioned = new Set();
365
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
366
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
367
+ const layoutRules = rules => [...rules].map(rule => {
288
368
  if (rule.selectorText && rule.style) {
289
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
369
+ const margins = [...rule.style].filter(isMargin);
290
370
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
291
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
371
+ const insets = [...rule.style].filter(isInset);
372
+ if (insets.length) positioned.add(rule.selectorText);
373
+ const layout = [...margins, ...insets];
374
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
292
375
  }
293
- if (!rule.cssRules) return '';
294
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
295
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
296
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
376
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
377
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
378
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
379
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
297
380
  }).join('\n');
298
- const margins = [...document.styleSheets].map(sheet => {
299
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
381
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
382
+ const layout = sheets.map(sheet => {
383
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
300
384
  }).join('\n');
301
385
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
302
386
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
303
- const restoreMargins = (source, clone) => {
304
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
305
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
306
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
387
+ const restoreLayout = (source, clone) => {
388
+ if (clone.style && source !== poster) {
389
+ const margins = [...autoMargins].some(selector => source.matches(selector));
390
+ const computed = getComputedStyle(source);
391
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
392
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
393
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
394
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
395
+ if (insets) {
396
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
397
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
398
+ const axis = source.getAttribute('data-rhp-o');
399
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
400
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
401
+ }
307
402
  }
308
403
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
309
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
404
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
310
405
  };
311
406
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
312
407
  const fontFiles = new Map();
313
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
408
+ const fontRules = await Promise.all(sheets.map(async sheet => {
314
409
  let css;
315
410
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
316
411
  catch (cause) {
@@ -361,12 +456,12 @@ function addPosterDownloads() {
361
456
  node.style.backgroundImage = background;
362
457
  };
363
458
  const blob = await domToBlob(poster, {
364
- scale: 2, style: { margin: '0' }, filter: includeNode,
459
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
365
460
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
366
461
  onCloneNode: clone => {
367
- restoreMargins(poster, clone);
462
+ restoreLayout(poster, clone);
368
463
  const style = document.createElement('style');
369
- style.textContent = margins;
464
+ style.textContent = layout;
370
465
  clone.prepend(style);
371
466
  },
372
467
  // CSS content's alternative text follows a slash and must not be painted.
@@ -394,10 +489,11 @@ function addPosterDownloads() {
394
489
  busy = false;
395
490
  button.removeAttribute('aria-disabled');
396
491
  button.removeAttribute('aria-busy');
492
+ fitButton.removeAttribute('aria-disabled');
397
493
  button.setAttribute('aria-label', 'Download poster as PNG');
398
494
  }
399
495
  });
400
- tools.append(button);
496
+ tools.append(actions);
401
497
  });
402
498
  }
403
499
  // A chart module may await fonts or data after DOMContentLoaded has already fired.
@@ -173,24 +173,84 @@ render(() => html`<${RicherSince1990} />`, document.getElementById("chart"));
173
173
  </script>
174
174
  <style id="poster-download-styles">
175
175
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
176
- .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; }
177
- .poster-download svg { display: block; width: 20px; height: 20px; }
178
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
179
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
180
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
176
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
177
+ .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; }
178
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
179
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
180
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
181
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
182
+ .poster-fit-frame, .poster-fit-content { display: contents; }
183
+ .poster-fit-frame { max-height: 95svh; }
184
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
185
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
181
186
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
182
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
187
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
183
188
  </style>
184
189
  <script type="module" id="poster-download-script">
190
+ function addPosterFit(poster, button, changed) {
191
+ const frame = document.createElement('div'), content = document.createElement('div');
192
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
193
+ poster.before(frame); frame.append(content); content.append(poster);
194
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
195
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
196
+ let fitted = false, scale = 1, request = 0, layout = null;
197
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
198
+ const layoutKey = () => {
199
+ const css = getComputedStyle(poster);
200
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
201
+ };
202
+ const restore = () => original.forEach(([name, value, priority]) => {
203
+ if (value) poster.style.setProperty(name, value, priority);
204
+ else poster.style.removeProperty(name);
205
+ });
206
+ function update() {
207
+ request = 0;
208
+ if (fitted && layout === layoutKey()) return;
209
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
210
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
211
+ if (fitted) {
212
+ const box = poster.getBoundingClientRect();
213
+ scale = Math.min(1, availableHeight() / box.height);
214
+ poster.style.setProperty('box-sizing', 'border-box');
215
+ poster.style.setProperty('width', `${box.width}px`);
216
+ poster.style.setProperty('max-width', 'none');
217
+ poster.style.setProperty('margin', '0');
218
+ content.style.width = `${box.width}px`;
219
+ content.style.transform = `scale(${scale})`;
220
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
221
+ frame.classList.add('is-fitted');
222
+ }
223
+ layout = layoutKey();
224
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
225
+ button.setAttribute('aria-label', label); button.title = label;
226
+ button.setAttribute('aria-pressed', String(fitted));
227
+ 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>`;
228
+ changed();
229
+ poster.dispatchEvent(new Event('poster-view-change'));
230
+ }
231
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
232
+ new ResizeObserver(schedule).observe(poster);
233
+ addEventListener('resize', schedule);
234
+ document.fonts.ready.then(schedule);
235
+ update();
236
+ return {
237
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
238
+ dimensions() {
239
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
240
+ const css = getComputedStyle(poster);
241
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
242
+ },
243
+ };
244
+ }
185
245
  function addPosterDownloads() {
186
246
  document.querySelectorAll('.poster').forEach((poster, index) => {
187
247
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
188
248
  const tools = navigation || document.createElement('div');
189
249
  if (tools.querySelector('.poster-download')) return;
250
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
190
251
  if (!navigation) {
191
252
  tools.className = 'poster-download-tools';
192
253
  poster.before(tools);
193
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
194
254
  new ResizeObserver(align).observe(poster);
195
255
  align();
196
256
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -208,27 +268,43 @@ function addPosterDownloads() {
208
268
  button.setAttribute('aria-label', 'Download poster as PNG');
209
269
  button.title = 'Download poster as PNG';
210
270
  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>';
271
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
272
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
273
+ if (!poster.id) {
274
+ let id = `rhp-poster-${index + 1}`;
275
+ while (document.getElementById(id)) id += '-view';
276
+ poster.id = id;
277
+ }
278
+ fitButton.setAttribute('aria-controls', poster.id);
279
+ fitButton.setAttribute('data-poster-view-toggle', '');
280
+ const fit = addPosterFit(poster, fitButton, align);
281
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
282
+ actions.append(fitButton, button);
211
283
  const error = document.createElement('p');
212
284
  error.className = 'poster-download-error';
213
285
  error.setAttribute('role', 'alert');
214
286
  error.hidden = true;
215
287
  tools.after(error);
216
288
  // Download is part of the poster interaction, even though its control sits outside the image.
217
- button.addEventListener('pointerdown', event => {
289
+ actions.addEventListener('pointerdown', event => {
218
290
  event.stopPropagation();
219
291
  });
220
- button.addEventListener('mousedown', event => {
292
+ actions.addEventListener('mousedown', event => {
221
293
  // Pointer activation downloads without taking focus from the selected chart item.
222
294
  event.preventDefault();
223
295
  event.stopPropagation();
224
296
  });
225
297
  poster.addEventListener('focusout', event => {
226
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
298
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
227
299
  }, true);
228
300
  poster.addEventListener('pointerleave', event => {
229
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
301
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
230
302
  }, true);
231
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
303
+ poster.addEventListener('pointermove', event => {
304
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
305
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
306
+ }, true);
307
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
232
308
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
233
309
  });
234
310
  let exporter, retries = 0, busy = false;
@@ -238,6 +314,7 @@ function addPosterDownloads() {
238
314
  busy = true;
239
315
  button.setAttribute('aria-disabled', 'true');
240
316
  button.setAttribute('aria-busy', 'true');
317
+ fitButton.setAttribute('aria-disabled', 'true');
241
318
  button.setAttribute('aria-label', 'Preparing poster PNG');
242
319
  error.hidden = true;
243
320
  let url;
@@ -245,35 +322,53 @@ function addPosterDownloads() {
245
322
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
246
323
  const { domToBlob } = await exporter;
247
324
  await document.fonts.ready;
248
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
249
- const autoMargins = new Set();
250
- const marginRules = rules => [...rules].map(rule => {
325
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
326
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
327
+ const autoMargins = new Set(), positioned = new Set();
328
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
329
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
330
+ const layoutRules = rules => [...rules].map(rule => {
251
331
  if (rule.selectorText && rule.style) {
252
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
332
+ const margins = [...rule.style].filter(isMargin);
253
333
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
254
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
334
+ const insets = [...rule.style].filter(isInset);
335
+ if (insets.length) positioned.add(rule.selectorText);
336
+ const layout = [...margins, ...insets];
337
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
255
338
  }
256
- if (!rule.cssRules) return '';
257
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
258
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
259
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
339
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
340
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
341
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
342
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
260
343
  }).join('\n');
261
- const margins = [...document.styleSheets].map(sheet => {
262
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
344
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
345
+ const layout = sheets.map(sheet => {
346
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
263
347
  }).join('\n');
264
348
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
265
349
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
266
- const restoreMargins = (source, clone) => {
267
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
268
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
269
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
350
+ const restoreLayout = (source, clone) => {
351
+ if (clone.style && source !== poster) {
352
+ const margins = [...autoMargins].some(selector => source.matches(selector));
353
+ const computed = getComputedStyle(source);
354
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
355
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
356
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
357
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
358
+ if (insets) {
359
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
360
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
361
+ const axis = source.getAttribute('data-rhp-o');
362
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
363
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
364
+ }
270
365
  }
271
366
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
272
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
367
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
273
368
  };
274
369
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
275
370
  const fontFiles = new Map();
276
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
371
+ const fontRules = await Promise.all(sheets.map(async sheet => {
277
372
  let css;
278
373
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
279
374
  catch (cause) {
@@ -324,12 +419,12 @@ function addPosterDownloads() {
324
419
  node.style.backgroundImage = background;
325
420
  };
326
421
  const blob = await domToBlob(poster, {
327
- scale: 2, style: { margin: '0' }, filter: includeNode,
422
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
328
423
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
329
424
  onCloneNode: clone => {
330
- restoreMargins(poster, clone);
425
+ restoreLayout(poster, clone);
331
426
  const style = document.createElement('style');
332
- style.textContent = margins;
427
+ style.textContent = layout;
333
428
  clone.prepend(style);
334
429
  },
335
430
  // CSS content's alternative text follows a slash and must not be painted.
@@ -357,10 +452,11 @@ function addPosterDownloads() {
357
452
  busy = false;
358
453
  button.removeAttribute('aria-disabled');
359
454
  button.removeAttribute('aria-busy');
455
+ fitButton.removeAttribute('aria-disabled');
360
456
  button.setAttribute('aria-label', 'Download poster as PNG');
361
457
  }
362
458
  });
363
- tools.append(button);
459
+ tools.append(actions);
364
460
  });
365
461
  }
366
462
  // A chart module may await fonts or data after DOMContentLoaded has already fired.