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