@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
@@ -491,24 +491,84 @@ render(() => html`<${BitcoinDecade} />`, document.getElementById("chart"));
491
491
  </script>
492
492
  <style id="poster-download-styles">
493
493
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
494
- .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; }
495
- .poster-download svg { display: block; width: 20px; height: 20px; }
496
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
497
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
498
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
494
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
495
+ .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; }
496
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
497
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
498
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
499
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
500
+ .poster-fit-frame, .poster-fit-content { display: contents; }
501
+ .poster-fit-frame { max-height: 95svh; }
502
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
503
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
499
504
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
500
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
505
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
501
506
  </style>
502
507
  <script type="module" id="poster-download-script">
508
+ function addPosterFit(poster, button, changed) {
509
+ const frame = document.createElement('div'), content = document.createElement('div');
510
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
511
+ poster.before(frame); frame.append(content); content.append(poster);
512
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
513
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
514
+ let fitted = false, scale = 1, request = 0, layout = null;
515
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
516
+ const layoutKey = () => {
517
+ const css = getComputedStyle(poster);
518
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
519
+ };
520
+ const restore = () => original.forEach(([name, value, priority]) => {
521
+ if (value) poster.style.setProperty(name, value, priority);
522
+ else poster.style.removeProperty(name);
523
+ });
524
+ function update() {
525
+ request = 0;
526
+ if (fitted && layout === layoutKey()) return;
527
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
528
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
529
+ if (fitted) {
530
+ const box = poster.getBoundingClientRect();
531
+ scale = Math.min(1, availableHeight() / box.height);
532
+ poster.style.setProperty('box-sizing', 'border-box');
533
+ poster.style.setProperty('width', `${box.width}px`);
534
+ poster.style.setProperty('max-width', 'none');
535
+ poster.style.setProperty('margin', '0');
536
+ content.style.width = `${box.width}px`;
537
+ content.style.transform = `scale(${scale})`;
538
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
539
+ frame.classList.add('is-fitted');
540
+ }
541
+ layout = layoutKey();
542
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
543
+ button.setAttribute('aria-label', label); button.title = label;
544
+ button.setAttribute('aria-pressed', String(fitted));
545
+ 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>`;
546
+ changed();
547
+ poster.dispatchEvent(new Event('poster-view-change'));
548
+ }
549
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
550
+ new ResizeObserver(schedule).observe(poster);
551
+ addEventListener('resize', schedule);
552
+ document.fonts.ready.then(schedule);
553
+ update();
554
+ return {
555
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
556
+ dimensions() {
557
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
558
+ const css = getComputedStyle(poster);
559
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
560
+ },
561
+ };
562
+ }
503
563
  function addPosterDownloads() {
504
564
  document.querySelectorAll('.poster').forEach((poster, index) => {
505
565
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
506
566
  const tools = navigation || document.createElement('div');
507
567
  if (tools.querySelector('.poster-download')) return;
568
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
508
569
  if (!navigation) {
509
570
  tools.className = 'poster-download-tools';
510
571
  poster.before(tools);
511
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
512
572
  new ResizeObserver(align).observe(poster);
513
573
  align();
514
574
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -526,27 +586,43 @@ function addPosterDownloads() {
526
586
  button.setAttribute('aria-label', 'Download poster as PNG');
527
587
  button.title = 'Download poster as PNG';
528
588
  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>';
589
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
590
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
591
+ if (!poster.id) {
592
+ let id = `rhp-poster-${index + 1}`;
593
+ while (document.getElementById(id)) id += '-view';
594
+ poster.id = id;
595
+ }
596
+ fitButton.setAttribute('aria-controls', poster.id);
597
+ fitButton.setAttribute('data-poster-view-toggle', '');
598
+ const fit = addPosterFit(poster, fitButton, align);
599
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
600
+ actions.append(fitButton, button);
529
601
  const error = document.createElement('p');
530
602
  error.className = 'poster-download-error';
531
603
  error.setAttribute('role', 'alert');
532
604
  error.hidden = true;
533
605
  tools.after(error);
534
606
  // Download is part of the poster interaction, even though its control sits outside the image.
535
- button.addEventListener('pointerdown', event => {
607
+ actions.addEventListener('pointerdown', event => {
536
608
  event.stopPropagation();
537
609
  });
538
- button.addEventListener('mousedown', event => {
610
+ actions.addEventListener('mousedown', event => {
539
611
  // Pointer activation downloads without taking focus from the selected chart item.
540
612
  event.preventDefault();
541
613
  event.stopPropagation();
542
614
  });
543
615
  poster.addEventListener('focusout', event => {
544
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
616
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
545
617
  }, true);
546
618
  poster.addEventListener('pointerleave', event => {
547
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
619
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
548
620
  }, true);
549
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
621
+ poster.addEventListener('pointermove', event => {
622
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
623
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
624
+ }, true);
625
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
550
626
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
551
627
  });
552
628
  let exporter, retries = 0, busy = false;
@@ -556,6 +632,7 @@ function addPosterDownloads() {
556
632
  busy = true;
557
633
  button.setAttribute('aria-disabled', 'true');
558
634
  button.setAttribute('aria-busy', 'true');
635
+ fitButton.setAttribute('aria-disabled', 'true');
559
636
  button.setAttribute('aria-label', 'Preparing poster PNG');
560
637
  error.hidden = true;
561
638
  let url;
@@ -563,35 +640,53 @@ function addPosterDownloads() {
563
640
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
564
641
  const { domToBlob } = await exporter;
565
642
  await document.fonts.ready;
566
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
567
- const autoMargins = new Set();
568
- const marginRules = rules => [...rules].map(rule => {
643
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
644
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
645
+ const autoMargins = new Set(), positioned = new Set();
646
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
647
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
648
+ const layoutRules = rules => [...rules].map(rule => {
569
649
  if (rule.selectorText && rule.style) {
570
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
650
+ const margins = [...rule.style].filter(isMargin);
571
651
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
572
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
652
+ const insets = [...rule.style].filter(isInset);
653
+ if (insets.length) positioned.add(rule.selectorText);
654
+ const layout = [...margins, ...insets];
655
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
573
656
  }
574
- if (!rule.cssRules) return '';
575
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
576
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
577
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
657
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
658
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
659
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
660
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
578
661
  }).join('\n');
579
- const margins = [...document.styleSheets].map(sheet => {
580
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
662
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
663
+ const layout = sheets.map(sheet => {
664
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
581
665
  }).join('\n');
582
666
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
583
667
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
584
- const restoreMargins = (source, clone) => {
585
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
586
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
587
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
668
+ const restoreLayout = (source, clone) => {
669
+ if (clone.style && source !== poster) {
670
+ const margins = [...autoMargins].some(selector => source.matches(selector));
671
+ const computed = getComputedStyle(source);
672
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
673
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
674
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
675
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
676
+ if (insets) {
677
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
678
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
679
+ const axis = source.getAttribute('data-rhp-o');
680
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
681
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
682
+ }
588
683
  }
589
684
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
590
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
685
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
591
686
  };
592
687
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
593
688
  const fontFiles = new Map();
594
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
689
+ const fontRules = await Promise.all(sheets.map(async sheet => {
595
690
  let css;
596
691
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
597
692
  catch (cause) {
@@ -642,12 +737,12 @@ function addPosterDownloads() {
642
737
  node.style.backgroundImage = background;
643
738
  };
644
739
  const blob = await domToBlob(poster, {
645
- scale: 2, style: { margin: '0' }, filter: includeNode,
740
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
646
741
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
647
742
  onCloneNode: clone => {
648
- restoreMargins(poster, clone);
743
+ restoreLayout(poster, clone);
649
744
  const style = document.createElement('style');
650
- style.textContent = margins;
745
+ style.textContent = layout;
651
746
  clone.prepend(style);
652
747
  },
653
748
  // CSS content's alternative text follows a slash and must not be painted.
@@ -675,10 +770,11 @@ function addPosterDownloads() {
675
770
  busy = false;
676
771
  button.removeAttribute('aria-disabled');
677
772
  button.removeAttribute('aria-busy');
773
+ fitButton.removeAttribute('aria-disabled');
678
774
  button.setAttribute('aria-label', 'Download poster as PNG');
679
775
  }
680
776
  });
681
- tools.append(button);
777
+ tools.append(actions);
682
778
  });
683
779
  }
684
780
  // A chart module may await fonts or data after DOMContentLoaded has already fired.
@@ -150,24 +150,84 @@ render(() => html`<${BigMacs} />`, document.getElementById("chart"));
150
150
  </script>
151
151
  <style id="poster-download-styles">
152
152
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
153
- .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; }
154
- .poster-download svg { display: block; width: 20px; height: 20px; }
155
- .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
156
- .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
157
- .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
153
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
154
+ .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; }
155
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
156
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
157
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
158
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
159
+ .poster-fit-frame, .poster-fit-content { display: contents; }
160
+ .poster-fit-frame { max-height: 95svh; }
161
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
162
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
158
163
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
159
- @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
164
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
160
165
  </style>
161
166
  <script type="module" id="poster-download-script">
167
+ function addPosterFit(poster, button, changed) {
168
+ const frame = document.createElement('div'), content = document.createElement('div');
169
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
170
+ poster.before(frame); frame.append(content); content.append(poster);
171
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
172
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
173
+ let fitted = false, scale = 1, request = 0, layout = null;
174
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
175
+ const layoutKey = () => {
176
+ const css = getComputedStyle(poster);
177
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
178
+ };
179
+ const restore = () => original.forEach(([name, value, priority]) => {
180
+ if (value) poster.style.setProperty(name, value, priority);
181
+ else poster.style.removeProperty(name);
182
+ });
183
+ function update() {
184
+ request = 0;
185
+ if (fitted && layout === layoutKey()) return;
186
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
187
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
188
+ if (fitted) {
189
+ const box = poster.getBoundingClientRect();
190
+ scale = Math.min(1, availableHeight() / box.height);
191
+ poster.style.setProperty('box-sizing', 'border-box');
192
+ poster.style.setProperty('width', `${box.width}px`);
193
+ poster.style.setProperty('max-width', 'none');
194
+ poster.style.setProperty('margin', '0');
195
+ content.style.width = `${box.width}px`;
196
+ content.style.transform = `scale(${scale})`;
197
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
198
+ frame.classList.add('is-fitted');
199
+ }
200
+ layout = layoutKey();
201
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
202
+ button.setAttribute('aria-label', label); button.title = label;
203
+ button.setAttribute('aria-pressed', String(fitted));
204
+ 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>`;
205
+ changed();
206
+ poster.dispatchEvent(new Event('poster-view-change'));
207
+ }
208
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
209
+ new ResizeObserver(schedule).observe(poster);
210
+ addEventListener('resize', schedule);
211
+ document.fonts.ready.then(schedule);
212
+ update();
213
+ return {
214
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
215
+ dimensions() {
216
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
217
+ const css = getComputedStyle(poster);
218
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
219
+ },
220
+ };
221
+ }
162
222
  function addPosterDownloads() {
163
223
  document.querySelectorAll('.poster').forEach((poster, index) => {
164
224
  const navigation = index === 0 && document.querySelector('.recipe-navigation');
165
225
  const tools = navigation || document.createElement('div');
166
226
  if (tools.querySelector('.poster-download')) return;
227
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
167
228
  if (!navigation) {
168
229
  tools.className = 'poster-download-tools';
169
230
  poster.before(tools);
170
- const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
171
231
  new ResizeObserver(align).observe(poster);
172
232
  align();
173
233
  const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
@@ -185,27 +245,43 @@ function addPosterDownloads() {
185
245
  button.setAttribute('aria-label', 'Download poster as PNG');
186
246
  button.title = 'Download poster as PNG';
187
247
  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>';
248
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
249
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
250
+ if (!poster.id) {
251
+ let id = `rhp-poster-${index + 1}`;
252
+ while (document.getElementById(id)) id += '-view';
253
+ poster.id = id;
254
+ }
255
+ fitButton.setAttribute('aria-controls', poster.id);
256
+ fitButton.setAttribute('data-poster-view-toggle', '');
257
+ const fit = addPosterFit(poster, fitButton, align);
258
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
259
+ actions.append(fitButton, button);
188
260
  const error = document.createElement('p');
189
261
  error.className = 'poster-download-error';
190
262
  error.setAttribute('role', 'alert');
191
263
  error.hidden = true;
192
264
  tools.after(error);
193
265
  // Download is part of the poster interaction, even though its control sits outside the image.
194
- button.addEventListener('pointerdown', event => {
266
+ actions.addEventListener('pointerdown', event => {
195
267
  event.stopPropagation();
196
268
  });
197
- button.addEventListener('mousedown', event => {
269
+ actions.addEventListener('mousedown', event => {
198
270
  // Pointer activation downloads without taking focus from the selected chart item.
199
271
  event.preventDefault();
200
272
  event.stopPropagation();
201
273
  });
202
274
  poster.addEventListener('focusout', event => {
203
- if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
275
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
204
276
  }, true);
205
277
  poster.addEventListener('pointerleave', event => {
206
- if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
278
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
279
+ }, true);
280
+ poster.addEventListener('pointermove', event => {
281
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
282
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
207
283
  }, true);
208
- for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
284
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
209
285
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
210
286
  });
211
287
  let exporter, retries = 0, busy = false;
@@ -215,6 +291,7 @@ function addPosterDownloads() {
215
291
  busy = true;
216
292
  button.setAttribute('aria-disabled', 'true');
217
293
  button.setAttribute('aria-busy', 'true');
294
+ fitButton.setAttribute('aria-disabled', 'true');
218
295
  button.setAttribute('aria-label', 'Preparing poster PNG');
219
296
  error.hidden = true;
220
297
  let url;
@@ -222,35 +299,53 @@ function addPosterDownloads() {
222
299
  exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
223
300
  const { domToBlob } = await exporter;
224
301
  await document.fonts.ready;
225
- // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
226
- const autoMargins = new Set();
227
- const marginRules = rules => [...rules].map(rule => {
302
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
303
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
304
+ const autoMargins = new Set(), positioned = new Set();
305
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
306
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
307
+ const layoutRules = rules => [...rules].map(rule => {
228
308
  if (rule.selectorText && rule.style) {
229
- const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
309
+ const margins = [...rule.style].filter(isMargin);
230
310
  if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
231
- return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
311
+ const insets = [...rule.style].filter(isInset);
312
+ if (insets.length) positioned.add(rule.selectorText);
313
+ const layout = [...margins, ...insets];
314
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
232
315
  }
233
- if (!rule.cssRules) return '';
234
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
235
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
236
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
316
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
317
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
318
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
319
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
237
320
  }).join('\n');
238
- const margins = [...document.styleSheets].map(sheet => {
239
- try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
321
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
322
+ const layout = sheets.map(sheet => {
323
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
240
324
  }).join('\n');
241
325
  // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
242
326
  const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
243
- const restoreMargins = (source, clone) => {
244
- if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
245
- [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
246
- [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
327
+ const restoreLayout = (source, clone) => {
328
+ if (clone.style && source !== poster) {
329
+ const margins = [...autoMargins].some(selector => source.matches(selector));
330
+ const computed = getComputedStyle(source);
331
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
332
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
333
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
334
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
335
+ if (insets) {
336
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
337
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
338
+ const axis = source.getAttribute('data-rhp-o');
339
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
340
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
341
+ }
247
342
  }
248
343
  const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
249
- [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
344
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
250
345
  };
251
346
  // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
252
347
  const fontFiles = new Map();
253
- const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
348
+ const fontRules = await Promise.all(sheets.map(async sheet => {
254
349
  let css;
255
350
  try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
256
351
  catch (cause) {
@@ -301,12 +396,12 @@ function addPosterDownloads() {
301
396
  node.style.backgroundImage = background;
302
397
  };
303
398
  const blob = await domToBlob(poster, {
304
- scale: 2, style: { margin: '0' }, filter: includeNode,
399
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
305
400
  font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
306
401
  onCloneNode: clone => {
307
- restoreMargins(poster, clone);
402
+ restoreLayout(poster, clone);
308
403
  const style = document.createElement('style');
309
- style.textContent = margins;
404
+ style.textContent = layout;
310
405
  clone.prepend(style);
311
406
  },
312
407
  // CSS content's alternative text follows a slash and must not be painted.
@@ -334,10 +429,11 @@ function addPosterDownloads() {
334
429
  busy = false;
335
430
  button.removeAttribute('aria-disabled');
336
431
  button.removeAttribute('aria-busy');
432
+ fitButton.removeAttribute('aria-disabled');
337
433
  button.setAttribute('aria-label', 'Download poster as PNG');
338
434
  }
339
435
  });
340
- tools.append(button);
436
+ tools.append(actions);
341
437
  });
342
438
  }
343
439
  // A chart module may await fonts or data after DOMContentLoaded has already fired.