@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`<${WealthChart} />`, 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.
@@ -352,24 +352,84 @@ render(() => html`<${Skyline} />`, document.getElementById("chart"));
352
352
  </script>
353
353
  <style id="poster-download-styles">
354
354
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
355
- .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; }
356
- .poster-download svg { display: block; width: 20px; height: 20px; }
357
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
358
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
359
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
355
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
356
+ .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; }
357
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
358
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
359
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
360
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
361
+ .poster-fit-frame, .poster-fit-content { display: contents; }
362
+ .poster-fit-frame { max-height: 95svh; }
363
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
364
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
360
365
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
361
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
366
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
362
367
  </style>
363
368
  <script type="module" id="poster-download-script">
369
+ function addPosterFit(poster, button, changed) {
370
+ const frame = document.createElement('div'), content = document.createElement('div');
371
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
372
+ poster.before(frame); frame.append(content); content.append(poster);
373
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
374
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
375
+ let fitted = false, scale = 1, request = 0, layout = null;
376
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
377
+ const layoutKey = () => {
378
+ const css = getComputedStyle(poster);
379
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
380
+ };
381
+ const restore = () => original.forEach(([name, value, priority]) => {
382
+ if (value) poster.style.setProperty(name, value, priority);
383
+ else poster.style.removeProperty(name);
384
+ });
385
+ function update() {
386
+ request = 0;
387
+ if (fitted && layout === layoutKey()) return;
388
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
389
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
390
+ if (fitted) {
391
+ const box = poster.getBoundingClientRect();
392
+ scale = Math.min(1, availableHeight() / box.height);
393
+ poster.style.setProperty('box-sizing', 'border-box');
394
+ poster.style.setProperty('width', `${box.width}px`);
395
+ poster.style.setProperty('max-width', 'none');
396
+ poster.style.setProperty('margin', '0');
397
+ content.style.width = `${box.width}px`;
398
+ content.style.transform = `scale(${scale})`;
399
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
400
+ frame.classList.add('is-fitted');
401
+ }
402
+ layout = layoutKey();
403
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
404
+ button.setAttribute('aria-label', label); button.title = label;
405
+ button.setAttribute('aria-pressed', String(fitted));
406
+ 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>`;
407
+ changed();
408
+ poster.dispatchEvent(new Event('poster-view-change'));
409
+ }
410
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
411
+ new ResizeObserver(schedule).observe(poster);
412
+ addEventListener('resize', schedule);
413
+ document.fonts.ready.then(schedule);
414
+ update();
415
+ return {
416
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
417
+ dimensions() {
418
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
419
+ const css = getComputedStyle(poster);
420
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
421
+ },
422
+ };
423
+ }
364
424
  function addPosterDownloads() {
365
425
  document.querySelectorAll('.poster').forEach((poster, index) => {
366
426
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
367
427
  const tools = navigation || document.createElement('div');
368
428
  if (tools.querySelector('.poster-download')) return;
429
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
369
430
  if (!navigation) {
370
431
  tools.className = 'poster-download-tools';
371
432
  poster.before(tools);
372
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
373
433
  new ResizeObserver(align).observe(poster);
374
434
  align();
375
435
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -387,27 +447,43 @@ function addPosterDownloads() {
387
447
  button.setAttribute('aria-label', 'Download poster as PNG');
388
448
  button.title = 'Download poster as PNG';
389
449
  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>';
450
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
451
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
452
+ if (!poster.id) {
453
+ let id = `rhp-poster-${index + 1}`;
454
+ while (document.getElementById(id)) id += '-view';
455
+ poster.id = id;
456
+ }
457
+ fitButton.setAttribute('aria-controls', poster.id);
458
+ fitButton.setAttribute('data-poster-view-toggle', '');
459
+ const fit = addPosterFit(poster, fitButton, align);
460
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
461
+ actions.append(fitButton, button);
390
462
  const error = document.createElement('p');
391
463
  error.className = 'poster-download-error';
392
464
  error.setAttribute('role', 'alert');
393
465
  error.hidden = true;
394
466
  tools.after(error);
395
467
  // Download is part of the poster interaction, even though its control sits outside the image.
396
- button.addEventListener('pointerdown', event => {
468
+ actions.addEventListener('pointerdown', event => {
397
469
  event.stopPropagation();
398
470
  });
399
- button.addEventListener('mousedown', event => {
471
+ actions.addEventListener('mousedown', event => {
400
472
  // Pointer activation downloads without taking focus from the selected chart item.
401
473
  event.preventDefault();
402
474
  event.stopPropagation();
403
475
  });
404
476
  poster.addEventListener('focusout', event => {
405
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
477
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
406
478
  }, true);
407
479
  poster.addEventListener('pointerleave', event => {
408
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
480
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
409
481
  }, true);
410
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
482
+ poster.addEventListener('pointermove', event => {
483
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
484
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
485
+ }, true);
486
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
411
487
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
412
488
  });
413
489
  let exporter, retries = 0, busy = false;
@@ -417,6 +493,7 @@ function addPosterDownloads() {
417
493
  busy = true;
418
494
  button.setAttribute('aria-disabled', 'true');
419
495
  button.setAttribute('aria-busy', 'true');
496
+ fitButton.setAttribute('aria-disabled', 'true');
420
497
  button.setAttribute('aria-label', 'Preparing poster PNG');
421
498
  error.hidden = true;
422
499
  let url;
@@ -424,35 +501,53 @@ function addPosterDownloads() {
424
501
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
425
502
  const { domToBlob } = await exporter;
426
503
  await document.fonts.ready;
427
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
428
- const autoMargins = new Set();
429
- const marginRules = rules => [...rules].map(rule => {
504
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
505
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
506
+ const autoMargins = new Set(), positioned = new Set();
507
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
508
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
509
+ const layoutRules = rules => [...rules].map(rule => {
430
510
  if (rule.selectorText && rule.style) {
431
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
511
+ const margins = [...rule.style].filter(isMargin);
432
512
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
433
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
513
+ const insets = [...rule.style].filter(isInset);
514
+ if (insets.length) positioned.add(rule.selectorText);
515
+ const layout = [...margins, ...insets];
516
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
434
517
  }
435
- if (!rule.cssRules) return '';
436
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
437
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
438
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
518
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
519
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
520
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
521
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
439
522
  }).join('\n');
440
- const margins = [...document.styleSheets].map(sheet => {
441
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
523
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
524
+ const layout = sheets.map(sheet => {
525
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
442
526
  }).join('\n');
443
527
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
444
528
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
445
- const restoreMargins = (source, clone) => {
446
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
447
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
448
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
529
+ const restoreLayout = (source, clone) => {
530
+ if (clone.style && source !== poster) {
531
+ const margins = [...autoMargins].some(selector => source.matches(selector));
532
+ const computed = getComputedStyle(source);
533
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
534
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
535
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
536
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
537
+ if (insets) {
538
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
539
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
540
+ const axis = source.getAttribute('data-rhp-o');
541
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
542
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
543
+ }
449
544
  }
450
545
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
451
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
546
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
452
547
  };
453
548
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
454
549
  const fontFiles = new Map();
455
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
550
+ const fontRules = await Promise.all(sheets.map(async sheet => {
456
551
  let css;
457
552
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
458
553
  catch (cause) {
@@ -503,12 +598,12 @@ function addPosterDownloads() {
503
598
  node.style.backgroundImage = background;
504
599
  };
505
600
  const blob = await domToBlob(poster, {
506
- scale: 2, style: { margin: '0' }, filter: includeNode,
601
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
507
602
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
508
603
  onCloneNode: clone => {
509
- restoreMargins(poster, clone);
604
+ restoreLayout(poster, clone);
510
605
  const style = document.createElement('style');
511
- style.textContent = margins;
606
+ style.textContent = layout;
512
607
  clone.prepend(style);
513
608
  },
514
609
  // CSS content's alternative text follows a slash and must not be painted.
@@ -536,10 +631,11 @@ function addPosterDownloads() {
536
631
  busy = false;
537
632
  button.removeAttribute('aria-disabled');
538
633
  button.removeAttribute('aria-busy');
634
+ fitButton.removeAttribute('aria-disabled');
539
635
  button.setAttribute('aria-label', 'Download poster as PNG');
540
636
  }
541
637
  });
542
- tools.append(button);
638
+ tools.append(actions);
543
639
  });
544
640
  }
545
641
  // A chart module may await fonts or data after DOMContentLoaded has already fired.