@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/src/check/inpage.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/check/interact.js
CHANGED
|
@@ -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
|
|
package/src/check/report.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
+
}
|