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