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