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