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