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