@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.
- package/package.json +6 -1
- package/skill/SKILL.md +113 -22
- package/skill/recipes/area.html +129 -33
- package/skill/recipes/bar.html +129 -33
- package/skill/recipes/box-plot.html +129 -33
- package/skill/recipes/bubble.html +129 -33
- package/skill/recipes/bullet.html +129 -33
- package/skill/recipes/candlestick.html +129 -33
- package/skill/recipes/column.html +129 -33
- package/skill/recipes/dial.html +129 -33
- package/skill/recipes/diverging-bars.html +129 -33
- package/skill/recipes/donut.html +129 -33
- package/skill/recipes/dumbbell.html +129 -33
- package/skill/recipes/gantt.html +129 -33
- package/skill/recipes/grouped-bars.html +129 -33
- package/skill/recipes/heatmap.html +129 -33
- package/skill/recipes/histogram.html +129 -33
- package/skill/recipes/line.html +129 -33
- package/skill/recipes/live.html +129 -33
- package/skill/recipes/log-line.html +129 -33
- package/skill/recipes/lollipop.html +129 -33
- package/skill/recipes/multi-line.html +129 -33
- package/skill/recipes/pyramid.html +129 -33
- package/skill/recipes/race.html +129 -33
- package/skill/recipes/radial-bars.html +129 -33
- package/skill/recipes/scatter.html +129 -33
- package/skill/recipes/silhouettes.html +129 -33
- package/skill/recipes/slope.html +129 -33
- package/skill/recipes/sparklines.html +129 -33
- package/skill/recipes/stacked-100.html +129 -33
- package/skill/recipes/stacked-bars.html +129 -33
- package/skill/recipes/strip.html +129 -33
- package/skill/recipes/tape.html +578 -0
- package/skill/recipes/unit-stack.html +129 -33
- package/skill/recipes/violin.html +129 -33
- package/skill/recipes/waffle.html +129 -33
- package/skill/recipes/waterfall.html +129 -33
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/download.md +135 -35
- package/skill/references/forms.md +3 -2
- package/skill/references/interaction.md +8 -4
- package/skill/references/pitfalls.md +3 -2
- package/skill/scripts/add-download.mjs +33 -0
- package/src/check/README.md +29 -5
- package/src/check/build.js +4 -3
- package/src/check/colors.js +4 -2
- package/src/check/findings.js +99 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +292 -5
- package/src/check/interact.js +22 -4
- package/src/check/png.js +6 -1
- package/src/check/report.js +9 -2
- package/src/check/selection-controls.js +105 -0
- package/src/check/selection.js +54 -12
- package/src/check/source.js +21 -0
- package/src/server.js +4 -4
package/skill/recipes/strip.html
CHANGED
|
@@ -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-
|
|
225
|
-
.poster-download
|
|
226
|
-
.poster-
|
|
227
|
-
.poster-
|
|
228
|
-
.poster-
|
|
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-
|
|
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
|
-
|
|
337
|
+
actions.addEventListener('pointerdown', event => {
|
|
266
338
|
event.stopPropagation();
|
|
267
339
|
});
|
|
268
|
-
|
|
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 (
|
|
346
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
275
347
|
}, true);
|
|
276
348
|
poster.addEventListener('pointerleave', event => {
|
|
277
|
-
if (
|
|
349
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
278
350
|
}, true);
|
|
279
|
-
|
|
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
|
-
//
|
|
297
|
-
|
|
298
|
-
const
|
|
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(
|
|
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
|
-
|
|
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 ?
|
|
306
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
307
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
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
|
|
310
|
-
|
|
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
|
|
315
|
-
if (clone.style && source !== poster
|
|
316
|
-
[...
|
|
317
|
-
|
|
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])
|
|
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(
|
|
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
|
-
|
|
473
|
+
restoreLayout(poster, clone);
|
|
379
474
|
const style = document.createElement('style');
|
|
380
|
-
style.textContent =
|
|
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(
|
|
507
|
+
tools.append(actions);
|
|
412
508
|
});
|
|
413
509
|
}
|
|
414
510
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|