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