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