@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
@@ -1045,7 +1045,8 @@ export function install() {
1045
1045
  const buttons = [...document.querySelectorAll("button, [role=button], [role=switch], [role=tab], [role=radio], [role=checkbox], input[type=checkbox], input[type=radio], summary")]
1046
1046
  .filter((b) => usable(b) && !(b.type === "submit" && b.form) && !b.closest("a[href]"));
1047
1047
  for (const b of buttons.slice(0, 12)) {
1048
- out.buttons.push({ label: label(b), role: b.getAttribute("role") ?? b.type ?? b.localName, target: keep(b) });
1048
+ out.buttons.push({ label: label(b), role: b.getAttribute("role") ?? b.type ?? b.localName, target: keep(b),
1049
+ view: b.hasAttribute("data-poster-view-toggle") ? b.getAttribute("aria-controls") : null });
1049
1050
  }
1050
1051
 
1051
1052
  for (const r of [...document.querySelectorAll("input[type=range]")].filter(usable).slice(0, 6)) {
@@ -1123,7 +1124,8 @@ export function install() {
1123
1124
  if (hit) return hit;
1124
1125
 
1125
1126
  const r = slat.getBoundingClientRect();
1126
- const { x, y } = marks.length ? pointsOn(marks[0])[0] : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1127
+ const { x, y } = marks.length ? pointsOn(marks[0])[0] : { x: r.left + r.width * across, y: r.top + r.height / 2 };
1128
+ if (reaches({ x, y })) return { x, y };
1127
1129
  const takes = (e) => getComputedStyle(e).pointerEvents !== "none" && e.checkVisibility({ visibilityProperty: true }) && e.getBoundingClientRect().width * e.getBoundingClientRect().height > 0;
1128
1130
  if ([slat, ...slat.querySelectorAll("*")].some(takes)) {
1129
1131
  const on = document.elementFromPoint(x, y);
@@ -1141,8 +1143,13 @@ export function install() {
1141
1143
  // moves with scrolling, so it is left out.
1142
1144
  let placed = [];
1143
1145
  let sections = [];
1146
+ let viewRoot = null;
1144
1147
  const at = (el) => {
1145
1148
  const r = el.getBoundingClientRect();
1149
+ if (viewRoot?.contains(el)) {
1150
+ const root = viewRoot.getBoundingClientRect(), scale = root.width / viewRoot.offsetWidth;
1151
+ return {el,x:(r.left-root.left)/scale,y:(r.top-root.top)/scale};
1152
+ }
1146
1153
  return { el, x: r.left + scrollX, y: r.top + scrollY };
1147
1154
  };
1148
1155
  // The room "auto" gives names, in px: the chart body's first and last grid tracks (columns, or rows when vertical)
@@ -1155,7 +1162,12 @@ export function install() {
1155
1162
  if (tracks.length < 3) return null;
1156
1163
  return o === "h" ? { o, start: tracks[0], end: tracks.at(-1) } : { o, start: tracks.at(-1), end: tracks[0] };
1157
1164
  };
1158
- function place() {
1165
+ function place(view = null) {
1166
+
1167
+ // A view toggle deliberately scales the poster and moves the flow below it.
1168
+ // Its contents must still keep the same layout in the poster's own coordinates.
1169
+ const controlled = view ? document.getElementById(view) : null;
1170
+ viewRoot = controlled?.matches(".poster") ? controlled : null;
1159
1171
 
1160
1172
  const pinned = (el) => {
1161
1173
  for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
@@ -1164,7 +1176,7 @@ export function install() {
1164
1176
  return false;
1165
1177
  };
1166
1178
  const charts = all().filter((c) => !c.parentElement?.closest(".rhp-chart"));
1167
- placed = [...document.querySelectorAll(CONTROLS), ...charts].filter((el) => visible(el) && !pinned(el)).map((el) => {
1179
+ placed = [...document.querySelectorAll(CONTROLS), ...charts].filter((el) => visible(el) && !pinned(el) && (!viewRoot || viewRoot.contains(el))).map((el) => {
1168
1180
  const chart = charts.includes(el);
1169
1181
  const slat = chart ? null : el.closest(".rhp-plot > *");
1170
1182
  return { ...at(el), chart, slat: slat && at(slat), gutters: chart ? gutters(el) : null };
@@ -1176,8 +1188,56 @@ export function install() {
1176
1188
  while (a.parentElement && a.parentElement !== document.body && a.parentElement !== document.documentElement) a = a.parentElement;
1177
1189
  return a;
1178
1190
  }))].map((el) => ({ el, end: el.getBoundingClientRect().bottom + scrollY }));
1191
+ // the texts around the charts, each placed in the box it floats in (see words), and the boxes with a background or
1192
+ // a border around them that hold no chart (a readout's card, a legend's panel), with their size
1193
+ words = [];
1194
+ boxes = [];
1195
+ for (const { el: root } of sections) {
1196
+ for (const el of [root, ...root.querySelectorAll("*")]) {
1197
+ if (words.length >= 600) break;
1198
+ if (el.closest(".rhp-chart") || el.closest(CONTROLS) || !visible(el) || (viewRoot && !viewRoot.contains(el))) continue;
1199
+ const text = ownText(el);
1200
+ if (text && el.getBoundingClientRect().width > 0) {
1201
+ const frame = frameOf(el);
1202
+ words.push({ ...within(el, frame), text, frame });
1203
+ }
1204
+ if (boxes.length < 60 && framed(el) && !el.querySelector(".rhp-chart") && !/fixed|absolute/.test(getComputedStyle(el).position)) {
1205
+ const r = el.getBoundingClientRect();
1206
+ const scale = scaleOf(el);
1207
+ if (r.width >= 24 && r.height >= 16 && el.textContent.trim()) boxes.push({ el, w: r.width / scale, h: r.height / scale, name: snippet(seenText(el)) });
1208
+ }
1209
+ }
1210
+ }
1179
1211
  }
1180
1212
 
1213
+ // A text is placed in the box it floats in when it floats (fixed, sticky or absolute: a readout pinned to the
1214
+ // window, a tooltip that follows the pointer), else on the page: the box may move as a whole, and only a move inside
1215
+ // it counts. Its own text is its text nodes', not its children's, so a label beside a value that changed keeps its own.
1216
+ let words = [];
1217
+ let boxes = [];
1218
+ const frameOf = (el) => {
1219
+ for (let a = el; a && a.nodeType === 1 && a !== document.body; a = a.parentElement) {
1220
+ if (/fixed|sticky|absolute/.test(getComputedStyle(a).position)) return a;
1221
+ }
1222
+ return null;
1223
+ };
1224
+ const ownText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.data).join(" ").replace(/\s+/g, " ").trim();
1225
+ const scaleOf = (el) => (viewRoot?.contains(el) ? viewRoot.getBoundingClientRect().width / viewRoot.offsetWidth : 1);
1226
+ const within = (el, frame) => {
1227
+ if (!frame) return at(el);
1228
+ const r = el.getBoundingClientRect();
1229
+ const f = frame.getBoundingClientRect();
1230
+ const scale = scaleOf(el);
1231
+ return { el, x: (r.left - f.left) / scale, y: (r.top - f.top) / scale };
1232
+ };
1233
+ // a box the reader sees the edges of: a background, a border or a shadow
1234
+ const framed = (el) => {
1235
+ const s = getComputedStyle(el);
1236
+ const paint = (c) => c && c !== "transparent" && !/rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
1237
+ const border = ["Top", "Right", "Bottom", "Left"].some((side) => parseFloat(s[`border${side}Width`]) >= 1 && paint(s[`border${side}Color`]) && s[`border${side}Style`] !== "none");
1238
+ return paint(s.backgroundColor) || border || s.boxShadow !== "none";
1239
+ };
1240
+
1181
1241
  function moved(n, lit = false) {
1182
1242
 
1183
1243
  const used = window.__rhpTargets[n];
@@ -1214,6 +1274,28 @@ export function install() {
1214
1274
  const grew = Math.round(el.getBoundingClientRect().bottom + scrollY - end);
1215
1275
  if (el.isConnected && far(grew)) out.push({ what: "the end of the chart's section", dx: 0, dy: grew });
1216
1276
  }
1277
+ // lighting a slat leaves a readout's card or a legend's panel its size: one that grows or shrinks with what it says
1278
+ // shows its edges jumping
1279
+ for (const b of lit ? boxes : []) {
1280
+ if (!b.el.isConnected || !visible(b.el)) continue;
1281
+ const r = b.el.getBoundingClientRect();
1282
+ const scale = scaleOf(b.el);
1283
+ const dw = Math.round(r.width / scale - b.w);
1284
+ const dh = Math.round(r.height / scale - b.h);
1285
+ if (far(dw) || far(dh)) out.push({ what: b.name ? `the box of "${b.name}"` : "a box", dx: 0, dy: 0, dw, dh, box: true });
1286
+ }
1287
+ // a text that still says the same thing stays where it was in its box: one that moves because a value beside it got
1288
+ // wider, or because a line above it wrapped, makes the reader lose their place (text-shift)
1289
+ for (const w of words) {
1290
+ if (!w.el.isConnected || !visible(w.el) || ownText(w.el) !== w.text || frameOf(w.el) !== w.frame) continue;
1291
+ const now = within(w.el, w.frame);
1292
+ const dx = Math.round(now.x - w.x);
1293
+ const dy = Math.round(now.y - w.y);
1294
+ // (a phrase of three words or more that only slides along its line, after a name or a value that changed, is a
1295
+ // sentence that reads on: "4.1% of people on Earth speak Hindi". A label of a word or two that slides is not)
1296
+ if (!far(dy) && (w.text.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3) continue;
1297
+ if (far(dx) || far(dy)) out.push({ what: `"${snippet(w.text)}"`, dx, dy, text: true });
1298
+ }
1217
1299
 
1218
1300
  return out;
1219
1301
  }
@@ -1328,5 +1410,210 @@ export function install() {
1328
1410
  return { what: focusName(el), clip: box };
1329
1411
  }
1330
1412
 
1331
- window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, sweepTargets, breakpoints, pointAt, place, moved, record, stop, unseen, focused, focusStop };
1413
+ // Three pairs of neighboring slats of the main Plot (the first two, two in the middle, the last two), for glide() in
1414
+ // index.js
1415
+ function neighbors() {
1416
+
1417
+ const slats = mainSlats();
1418
+ if (slats.length < 3) return [];
1419
+ const list = (window.__rhpTargets ??= []);
1420
+ const one = (i) => ({ index: i + 1, of: slats.length, name: snippet([...slats[i].querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? ""), target: list.push(slats[i]) - 1 });
1421
+
1422
+ return [...new Set([0, Math.floor((slats.length - 1) / 2), slats.length - 2])].map((i) => [one(i), one(i + 1)]);
1423
+ }
1424
+
1425
+ // The middle of a slat's largest mark (a target), in the window: where its own pick shows, for the glide. A slat with
1426
+ // marks of its own (a box and its outlier dots) may show several things; its largest mark shows the slat's own.
1427
+ function markCenter(n, scroll = true) {
1428
+
1429
+ const slat = window.__rhpTargets[n];
1430
+ if (scroll) slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1431
+ const area = (r) => r.width * r.height;
1432
+ const mark = [...slat.querySelectorAll(MARKS)].filter(visible).map((m) => m.getBoundingClientRect()).filter((r) => area(r) > 0).sort((a, b) => area(b) - area(a))[0];
1433
+ if (!mark) return null;
1434
+
1435
+ return { x: mark.left + mark.width / 2, y: mark.top + mark.height / 2 };
1436
+ }
1437
+
1438
+ // What the reader sees of a pick, as two short hashes: the page's visible text, and how the main Plot's slats are
1439
+ // drawn (the classes and inline styles of everything in them)
1440
+ function state() {
1441
+
1442
+ const hash = (s) => {
1443
+ let h = 5381;
1444
+ for (let i = 0; i < s.length; i++) {
1445
+ h = (h * 33 + s.charCodeAt(i)) | 0;
1446
+ }
1447
+ return h;
1448
+ };
1449
+ const look = mainSlats().map((s) => [s, ...s.querySelectorAll("*")].map((e) => (e.getAttribute("class") ?? "") + (e.getAttribute("style") ?? "")).join("")).join("|");
1450
+
1451
+ return { text: hash(document.body.innerText), look: hash(look) };
1452
+ }
1453
+
1454
+ // The elements around the charts whose text a pick changes (the readout), noted while the sweep picks every slat
1455
+ let watcher = null;
1456
+ const readout = new Set();
1457
+ function watch(on) {
1458
+
1459
+ watcher?.disconnect();
1460
+ watcher = null;
1461
+ if (!on) return readout.size;
1462
+ watcher = new MutationObserver((list) => {
1463
+ for (const m of list) {
1464
+ const el = m.type === "characterData" ? m.target.parentElement : m.target;
1465
+ if (el?.nodeType === 1 && el !== document.body && !el.closest(".rhp-chart")) readout.add(el);
1466
+ }
1467
+ });
1468
+ watcher.observe(document.body, { subtree: true, childList: true, characterData: true });
1469
+ return 0;
1470
+ }
1471
+
1472
+ // While the main chart is taller than three quarters of the window (height: a phone's is shorter than the page's),
1473
+ // scrolled so that its middle is in the middle of the window: its controls (in its section, outside its slats, not
1474
+ // the poster's fit and download icons) and its readout (what the sweep's picks wrote), when all of them are out of
1475
+ // view. null when they are in view, or there are none.
1476
+ function scrollsAway(height = innerHeight) {
1477
+
1478
+ const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
1479
+ const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
1480
+ if (!chart || chart.getBoundingClientRect().height < height * 0.75) return null;
1481
+ let section = chart;
1482
+ while (section.parentElement && section.parentElement !== document.body && section.parentElement !== document.documentElement) section = section.parentElement;
1483
+ const utility = /\b(fit|download|save|export|png|svg|share|print|full ?screen|restore|enlarge|shrink)\b/i;
1484
+ const controls = [...section.querySelectorAll(CONTROLS)].filter((el) => visible(el) && !el.closest(".rhp-chart") && el.getBoundingClientRect().width > 0 && !utility.test(nameOf(el) || el.textContent));
1485
+ const said = [...readout].filter((el) => el.isConnected && visible(el) && section.contains(el) && seenText(el));
1486
+ if (!controls.length && !said.length) return null;
1487
+
1488
+ const y = scrollY;
1489
+ const r = chart.getBoundingClientRect();
1490
+ scrollTo({ left: scrollX, top: scrollY + r.top + r.height / 2 - height / 2, behavior: "instant" });
1491
+ const inView = (el) => {
1492
+ const b = el.getBoundingClientRect();
1493
+ return b.width * b.height > 0 && b.bottom > 0 && b.top < height && b.right > 0 && b.left < innerWidth;
1494
+ };
1495
+ const out = {
1496
+ controls: controls.some(inView) ? [] : controls.slice(0, 3).map((el) => focusName(el)),
1497
+ readout: said.length && !said.some(inView) ? snippet(seenText(said[0])) : null,
1498
+ };
1499
+ scrollTo({ left: scrollX, top: y, behavior: "instant" });
1500
+
1501
+ return out.controls.length || out.readout ? out : null;
1502
+ }
1503
+
1504
+ // The points the scrub moves the pointer through (see scrub() in index.js), when the reader moves along the chart:
1505
+ // the main chart draws a line or an area (read day by day along its scale), or force (the sweep cannot point at its
1506
+ // slats, or there are one or two). steps points through the middle of the plot, from just inside one end to just
1507
+ // inside the other, with the plot scrolled to the middle of the window: along the scale of the plot that draws the
1508
+ // line or the area, else along the main Plot's scale when it has one or two slats, else across its slats (columns
1509
+ // side by side, or rows one under the other). Points outside the window are left out. { points, target: the plot,
1510
+ // for moved() }
1511
+ function scrubPath(force = false, steps = 40) {
1512
+
1513
+ const slats = mainSlats();
1514
+ const main = slats[0]?.parentElement;
1515
+ const chart = main?.closest(".rhp-chart");
1516
+ const mark = chart ? [...chart.querySelectorAll(".rhp-line, .rhp-area")].find(visible) : null;
1517
+ if (!main || (!mark && !force)) return { points: [] };
1518
+ const plot = mark?.closest(".rhp-plot") ?? main;
1519
+ plot.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1520
+ const r = plot.getBoundingClientRect();
1521
+ const vertical = plot.dataset.rhpO === "v";
1522
+ const across = mark || slats.length <= 2 ? !vertical : vertical;
1523
+ const points = [];
1524
+ for (let k = 0; k <= steps; k++) {
1525
+ const f = 0.02 + (0.96 * k) / steps;
1526
+ const p = across ? { x: r.left + r.width * f, y: r.top + r.height / 2 } : { x: r.left + r.width / 2, y: r.top + r.height * f };
1527
+ if (p.x >= 0 && p.y >= 0 && p.x < innerWidth && p.y < innerHeight) points.push(p);
1528
+ }
1529
+
1530
+ return { points, target: (window.__rhpTargets ??= []).push(plot) - 1 };
1531
+ }
1532
+
1533
+ // The lines of text around the charts (outside them and their controls), for the scrub: each text node's first line
1534
+ // box, in the box it floats in (see frameOf), keyed by where the node sits in the page so that one written again
1535
+ // counts as the same. placeLines() notes them before the pointer moves; slides() then says which moved: a text that
1536
+ // says the same thing and moved, or one whose words changed and that kept none of its edges (its start, its end or,
1537
+ // on one line, its middle) where it was. A number at the start of its line may grow; a label after it that slides as
1538
+ // it grows jitters under the reader's eyes at every step, and so does a whole sentence, on a chart whose readout
1539
+ // changes at every step (a line read day by day). Over separate marks the readout changes once per mark, as a pick
1540
+ // does, and only what a pick counts counts (see moved()): what is strict is left out there, a slide along its line of
1541
+ // a text whose words changed, or of a sentence of three words or more. A text that moves up or down jumps, whatever
1542
+ // it says and however often the readout changes. A text first seen after placeLines() is measured against where it
1543
+ // was first seen.
1544
+ let lines = new Map();
1545
+ const keyOf = (node) => {
1546
+ const path = [];
1547
+ for (let n = node; n && n !== document.body && n.parentNode; n = n.parentNode) path.push([...n.parentNode.childNodes].indexOf(n));
1548
+ return path.join(".");
1549
+ };
1550
+ const textNodes = () => {
1551
+ const out = [];
1552
+ for (const { el: root } of sections) {
1553
+ if (!root.isConnected) continue;
1554
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1555
+ for (let node = walker.nextNode(); node && out.length < 800; node = walker.nextNode()) {
1556
+ const el = node.parentElement;
1557
+ if (!el || !node.data.trim() || el.closest(".rhp-chart") || el.closest(CONTROLS) || !visible(el) || (viewRoot && !viewRoot.contains(el))) continue;
1558
+ out.push(node);
1559
+ }
1560
+ }
1561
+ return out;
1562
+ };
1563
+ const lineOf = (node) => {
1564
+ const range = document.createRange();
1565
+ range.selectNodeContents(node);
1566
+ const rects = [...range.getClientRects()].filter((q) => q.width > 0 && q.height > 0);
1567
+ if (!rects.length) return null;
1568
+ const el = node.parentElement;
1569
+ const frame = frameOf(el);
1570
+ const f = frame ? frame.getBoundingClientRect() : { left: -scrollX, top: -scrollY };
1571
+ const scale = scaleOf(el);
1572
+ const left = (rects[0].left - f.left) / scale;
1573
+ const right = (rects.at(-1).right - f.left) / scale;
1574
+ // (the line it is part of: its nearest block, which a finding names when it holds more than this text)
1575
+ let block = el;
1576
+ while (block.parentElement && /^(inline|contents)$/.test(getComputedStyle(block).display)) block = block.parentElement;
1577
+ return { text: node.data.replace(/\s+/g, " ").trim(), frame, left, right, mid: (left + right) / 2, top: (rects[0].top - f.top) / scale, one: rects.length === 1, line: snippet(seenText(block)) };
1578
+ };
1579
+ function placeLines() {
1580
+
1581
+ lines = new Map();
1582
+ for (const node of textNodes()) {
1583
+ const line = lineOf(node);
1584
+ if (line) lines.set(keyOf(node), line);
1585
+ }
1586
+ return lines.size;
1587
+ }
1588
+ function slides() {
1589
+
1590
+ const out = [];
1591
+ for (const node of textNodes()) {
1592
+ const now = lineOf(node);
1593
+ if (!now) continue;
1594
+ const key = keyOf(node);
1595
+ const was = lines.get(key);
1596
+ if (!was) {
1597
+ lines.set(key, now);
1598
+ continue;
1599
+ }
1600
+ if (was.frame !== now.frame) continue;
1601
+ const by = (k) => Math.round(now[k] - was[k]);
1602
+ const dy = by("top");
1603
+ let dx = by("left");
1604
+ // (a line that moves up or down jumps whatever it says: only a slide along the line can be strict)
1605
+ const strict = Math.abs(dy) <= 2 && (now.text !== was.text || (was.text.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3);
1606
+ if (now.text !== was.text) {
1607
+ const edges = [by("left"), by("right"), ...(now.one && was.one ? [by("mid")] : [])];
1608
+ dx = edges.some((v) => Math.abs(v) <= 2) ? 0 : edges.reduce((a, b) => (Math.abs(b) < Math.abs(a) ? b : a));
1609
+ }
1610
+ // (a comma or a dot moves with the words around it, and is not named apart from them)
1611
+ if ((Math.abs(dx) > 2 || Math.abs(dy) > 2) && /[\p{L}\p{N}]/u.test(was.text)) out.push({ what: `"${snippet(was.text)}"`, line: was.line !== snippet(was.text) ? was.line : null, dx, dy, text: true, strict });
1612
+ if (out.length >= 40) break;
1613
+ }
1614
+
1615
+ return out;
1616
+ }
1617
+
1618
+ window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, sweepTargets, breakpoints, pointAt, place, moved, record, stop, unseen, focused, focusStop, neighbors, markCenter, state, watch, scrollsAway, scrubPath, placeLines, slides };
1332
1619
  }
@@ -77,7 +77,7 @@ export async function interact(page, { events, capture, shoot, taps = false }) {
77
77
 
78
78
  // ready runs before the layout is noted (it scrolls the target into view), act is the interaction itself. lit: the
79
79
  // interaction lights a slat (no data changes), so the plot must stay where it is; texts: note the texts it changes
80
- const step = async (kind, target, { ready, act, compareLook = false, used = null, lit = false, texts = false }) => {
80
+ const step = async (kind, target, { ready, act, compareLook = false, used = null, lit = false, texts = false, view = null }) => {
81
81
  const before = events.length;
82
82
  const rest = compareLook ? await look() : null;
83
83
  let error = null;
@@ -89,14 +89,26 @@ export async function interact(page, { events, capture, shoot, taps = false }) {
89
89
  }
90
90
  };
91
91
  await attempt(ready);
92
- await page.evaluate((keep) => {
93
- window.__rhpProbe.place();
92
+ await page.evaluate(({keep,view}) => {
93
+ window.__rhpProbe.place(view);
94
94
  window.__rhpProbe.record(keep);
95
- }, texts);
95
+ }, {keep:texts,view});
96
96
  if (!error) await attempt(act);
97
+ // a control that sets the page moving (Play) is read as it moves too: a text that jitters while the values run
98
+ // may be back in place at the end
99
+ const passing = [];
100
+ for (let k = 0; !lit && !error && k < 5; k++) {
101
+ await page.waitForTimeout(200);
102
+ passing.push(...(await page.evaluate((n) => window.__rhpProbe.moved(n, false), used).catch(() => [])).filter((m) => m.text));
103
+ }
97
104
  await page.evaluate(() => window.__rhpProbe.settle(1500)).catch(() => {});
98
105
  const seen = await page.evaluate(() => window.__rhpProbe.stop()).catch(() => null);
99
106
  const moved = await page.evaluate(([n, l]) => window.__rhpProbe.moved(n, l), [used, lit]).catch(() => []);
107
+ for (const m of passing) {
108
+ const was = moved.find((x) => x.what === m.what);
109
+ if (!was) moved.push(m);
110
+ else if (Math.abs(m.dx) + Math.abs(m.dy) > Math.abs(was.dx) + Math.abs(was.dy)) Object.assign(was, m);
111
+ }
100
112
  // a value a slat read once, and that this interaction changed, is shown stale (the guard reads them again)
101
113
  await page.evaluate(() => globalThis.__rhpCheck?.recheck?.()).catch(() => {});
102
114
  const thrown = events.slice(before).filter((e) => e.kind === "pageerror" || e.kind === "console-error");
@@ -168,13 +180,19 @@ export async function interact(page, { events, capture, shoot, taps = false }) {
168
180
 
169
181
  for (const b of t.buttons) {
170
182
  const el = await handle(b.target);
183
+ const pressed = await el.asElement().getAttribute("aria-pressed");
171
184
  const role = b.role === "checkbox" || b.role === "radio" || b.role === "switch" ? b.role : "button";
172
185
  const entry = await step("click", `${role} ${b.label}`, {
173
186
  ready: () => el.asElement().scrollIntoViewIfNeeded({ timeout: 1500 }),
174
187
  act: () => el.asElement().click({ timeout: 1500 }),
175
188
  used: b.target,
189
+ view: b.view,
176
190
  });
177
191
  if (entry.could) entry.how = `could not be clicked (${/intercepts pointer|covered|not visible|outside of the viewport/.test(entry.could) ? "covered or out of view" : entry.could})`;
192
+ if (b.view && !entry.could && pressed !== await el.asElement().getAttribute("aria-pressed")) {
193
+ const label = await el.asElement().getAttribute("aria-label");
194
+ await step("click", `button "${label}"`, {act:()=>el.asElement().click({timeout:1500}),used:b.target,view:b.view});
195
+ }
178
196
  }
179
197
 
180
198
  for (const r of t.ranges) {
package/src/check/png.js CHANGED
@@ -99,7 +99,7 @@ export function pixelsIn(image, { left, top, right, bottom }) {
99
99
 
100
100
  // How many pixels of two screenshots of one box differ enough to see (their channels by more than 24 in all: a
101
101
  // tint of 6% of the ink over the paper shows, drawing one page twice changes nothing)
102
- export function seenPixels(a, b) {
102
+ export function seenPixels(a, b, ignore = []) {
103
103
 
104
104
  if (a.equals(b)) return 0;
105
105
 
@@ -109,6 +109,11 @@ export function seenPixels(a, b) {
109
109
 
110
110
  let n = 0;
111
111
  for (let i = 0; i < x.data.length; i += 4) {
112
+ if (ignore.length) {
113
+ const px = (i / 4) % x.width;
114
+ const py = Math.floor(i / 4 / x.width);
115
+ if (ignore.some((r) => px >= r.left && px < r.right && py >= r.top && py < r.bottom)) continue;
116
+ }
112
117
  if (Math.abs(x.data[i] - y.data[i]) + Math.abs(x.data[i + 1] - y.data[i + 1]) + Math.abs(x.data[i + 2] - y.data[i + 2]) > 24) n++;
113
118
  }
114
119
 
@@ -44,6 +44,7 @@ export function report(r) {
44
44
  list.forEach((f, i) => {
45
45
  lines.push(`${i + 1}. [${f.code}] ${f.message}${widthsOf(f)}`);
46
46
  if (f.fix) lines.push(` Fix: ${f.fix}`);
47
+ if (f.see) lines.push(` See: ${f.see}`);
47
48
  });
48
49
  };
49
50
 
@@ -88,9 +89,15 @@ export function report(r) {
88
89
  if (quiet.includes(x.kind)) continue;
89
90
  lines.push(`- ${x.kind} ${x.target}${x.width !== r.interactedAt ? ` at ${x.width}px` : ""}: ${x.how}${x.error ? `, and it threw: ${x.error}` : ""}`);
90
91
  }
91
- // the sweep: one line, every width at which every slat was picked in turn
92
+ // the sweep: a line for the pointer moved along the chart, where it was, and one for every slat picked in turn, at
93
+ // every width at which it was (none when no slat could be reached: nothing was picked)
92
94
  const swept = (r.swept ?? []).filter((x) => x.slats);
93
- if (swept.length) lines.push(`- every slat picked in turn (${swept[0].slats}) at ${swept.map((x) => `${x.width}px`).join(", ")}: ${swept.some((x) => x.jumps) ? "the layout jumped (see the warnings)" : "nothing else moved"}`);
95
+ const at = (list) => list.map((x) => `${x.width}px`).join(", ");
96
+ const scrubbed = swept.filter((x) => x.scrubbed);
97
+ if (scrubbed.length) lines.push(`- pointer moved along the chart (${scrubbed[0].scrubbed.points} steps) at ${at(scrubbed)}: ${scrubbed.some((x) => x.scrubbed.moved) ? "the layout or the readout moved (see the warnings)" : "nothing else moved"}`);
98
+ const picked = swept.filter((x) => x.reached ?? x.slats);
99
+ if (picked.length) lines.push(`- every slat picked in turn (${picked[0].reached < picked[0].slats ? `${picked[0].reached} of ${picked[0].slats} reached` : picked[0].slats}) at ${at(picked)}: ${picked.some((x) => x.jumps) ? "the layout jumped (see the warnings)" : "nothing else moved"}`);
100
+ else if (swept.length) lines.push(`- no slat picked in turn: something covers the slats' marks at ${at(swept)}`);
94
101
  } else if (r.charts?.length && r.interactedAt) {
95
102
  lines.push("", "Interactions: nothing to interact with (no slats to point at, no buttons, ranges or selects).");
96
103
  }
@@ -0,0 +1,105 @@
1
+ // A control qualifies only when two of its values reproduce two different chart hover states.
2
+ // This distinguishes a selection input from an unrelated filter without reading the chart's signals.
3
+ import { seenPixels } from "./png.js";
4
+
5
+ export async function controlSelection(page, { slats, off, look, away, settle }) {
6
+ const tested = [];
7
+ const findings = [];
8
+ const controls = await page.evaluate((target) => {
9
+ const poster = window.__rhpTargets[target]?.closest(".poster, figure");
10
+ const usable = (el) => {
11
+ const r = el.getBoundingClientRect();
12
+ return !el.closest(".rhp-chart") && !el.disabled && !el.readOnly && el.getAttribute("aria-disabled") !== "true"
13
+ && el.checkVisibility({ visibilityProperty: true }) && r.width > 0 && r.height > 0
14
+ && r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth;
15
+ };
16
+ window.__rhpSelectionControls = [...(poster?.querySelectorAll('input[type="range"], input[type="number"], select:not([multiple]), [role="slider"]') ?? [])].filter(usable).slice(0, 6);
17
+ return window.__rhpSelectionControls.map((el, index) => {
18
+ const kind = el.localName === "select" ? "select" : el.type === "number" ? "number" : "slider";
19
+ const name = el.getAttribute("aria-label") || [...(el.labels ?? [])].map((l) => l.textContent.trim()).join(" ") || el.id || kind;
20
+ const values = kind === "select" ? [...el.options].filter((o) => !o.disabled && !o.parentElement.disabled).map((o) => o.value)
21
+ : kind === "number" && el.min !== "" && el.max !== "" ? [el.min, el.max] : [];
22
+ return { index, kind, name, values };
23
+ });
24
+ }, slats[0].target);
25
+ if (!controls.length) return { tested, findings };
26
+ // Ignore native thumbs and their focus rings, while still comparing the chart and its readout.
27
+ const boxes = await page.evaluate(() => window.__rhpSelectionControls.map((el) => {
28
+ const r = el.getBoundingClientRect();
29
+ return { left: r.left - 8, right: r.right + 8, top: r.top - 8, bottom: r.bottom + 8 };
30
+ }));
31
+ const same = (a, b) => seenPixels(a, b, boxes) < 20;
32
+ const position = await page.evaluate(() => [scrollX, scrollY]);
33
+ const stablePosition = async () => (await page.evaluate(() => [scrollX, scrollY])).every((v, i) => v === position[i]);
34
+ const points = [];
35
+ // Include both ends of a single continuous hit surface as well as separate categorical marks.
36
+ for (const s of [slats[0], slats[Math.floor(slats.length / 2)], slats.at(-1)]) {
37
+ for (const across of slats.length === 1 ? [0.001, 0.5, 0.999] : [0.5]) {
38
+ const p = await page.evaluate(({ target, across }) => window.__rhpProbe.pointAt(target, across, false), { target: s.target, across });
39
+ if (!p.covered && !points.some((q) => Math.hypot(p.x - q.x, p.y - q.y) < 2)) points.push(p);
40
+ }
41
+ }
42
+ if (points.length < 2) return { tested, findings };
43
+ for (const control of controls) {
44
+ const handle = (await page.evaluateHandle((i) => window.__rhpSelectionControls[i], control.index)).asElement();
45
+ try {
46
+ const value = () => handle.evaluate((el) => el.value ?? el.getAttribute("aria-valuenow"));
47
+ const choose = async (end) => {
48
+ await handle.focus();
49
+ if (control.kind === "select") await handle.selectOption(control.values.at(end ? -1 : 0));
50
+ else if (control.kind === "number") await handle.fill(control.values[end]);
51
+ else await handle.press(end ? "End" : "Home");
52
+ await settle();
53
+ };
54
+ if (control.kind !== "slider" && control.values.length < 2) continue;
55
+ await handle.focus();
56
+ const previews = [];
57
+ for (const point of points) {
58
+ await page.mouse.move(point.x, point.y);
59
+ const image = await look();
60
+ if (!previews.some((p) => same(p.image, image))) previews.push({ point, image });
61
+ }
62
+ if (previews.length < 2) continue;
63
+ const states = [];
64
+ await away();
65
+ for (const end of [0, 1]) {
66
+ await choose(end);
67
+ const image = await look();
68
+ const preview = previews.find((p) => same(p.image, image));
69
+ if (preview) states.push({ end, value: await value(), image, preview });
70
+ }
71
+ if (states.length !== 2 || states[0].value === states[1].value || same(states[0].image, states[1].image)) continue;
72
+ if (!await stablePosition()) return { tested, findings };
73
+ const chosen = states[1];
74
+ const preview = states[0].preview;
75
+ await choose(chosen.end);
76
+ const pinned = await look();
77
+ if (!same(pinned, chosen.image) || !same(pinned, await look())) continue;
78
+ let when = null;
79
+ await page.mouse.move(preview.point.x, preview.point.y);
80
+ await settle();
81
+ // A thumb may follow the hover preview. It must return to the committed choice when preview ends.
82
+ await page.mouse.move(off.x, off.y);
83
+ const beside = await look();
84
+ if (await value() !== chosen.value || !same(pinned, beside)) when = "moving off the chart after hover";
85
+ await away();
86
+ const left = await look();
87
+ if (!when && (await value() !== chosen.value || !same(pinned, left))) when = "leaving the poster";
88
+ // Re-select before testing blur so a hover failure cannot disguise a separate focus reset.
89
+ await choose(chosen.end);
90
+ await page.mouse.click(off.x, off.y);
91
+ await away();
92
+ const blurred = await look();
93
+ if (!when && (await value() !== chosen.value || !same(pinned, blurred))) when = "clicking noninteractive poster text or margin";
94
+ if (!await stablePosition() || !same(blurred, await look())) return { tested, findings };
95
+ tested.push({ control: control.name, selectionPersists: !when, ...(when ? { failedAt: when } : {}) });
96
+ if (when) findings.push({ code: "selection-control-reset", control: control.name, when });
97
+ // Leave a committed selection for the click audit's baseline.
98
+ await choose(chosen.end);
99
+ await away();
100
+ } finally {
101
+ await handle.dispose();
102
+ }
103
+ }
104
+ return { tested, findings };
105
+ }