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