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