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