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