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