@bezda/rhp-mcp 0.1.6 → 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 +1 -1
- package/skill/SKILL.md +108 -18
- package/skill/recipes/tape.html +578 -0
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- 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 +18 -2
- package/src/check/findings.js +92 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +276 -1
- package/src/check/interact.js +12 -0
- package/src/check/report.js +9 -2
- package/src/check/selection.js +16 -1
- package/src/server.js +4 -4
package/src/check/inpage.js
CHANGED
|
@@ -1188,8 +1188,56 @@ export function install() {
|
|
|
1188
1188
|
while (a.parentElement && a.parentElement !== document.body && a.parentElement !== document.documentElement) a = a.parentElement;
|
|
1189
1189
|
return a;
|
|
1190
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
|
+
}
|
|
1191
1211
|
}
|
|
1192
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
|
+
|
|
1193
1241
|
function moved(n, lit = false) {
|
|
1194
1242
|
|
|
1195
1243
|
const used = window.__rhpTargets[n];
|
|
@@ -1226,6 +1274,28 @@ export function install() {
|
|
|
1226
1274
|
const grew = Math.round(el.getBoundingClientRect().bottom + scrollY - end);
|
|
1227
1275
|
if (el.isConnected && far(grew)) out.push({ what: "the end of the chart's section", dx: 0, dy: grew });
|
|
1228
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
|
+
}
|
|
1229
1299
|
|
|
1230
1300
|
return out;
|
|
1231
1301
|
}
|
|
@@ -1340,5 +1410,210 @@ export function install() {
|
|
|
1340
1410
|
return { what: focusName(el), clip: box };
|
|
1341
1411
|
}
|
|
1342
1412
|
|
|
1343
|
-
|
|
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 };
|
|
1344
1619
|
}
|
package/src/check/interact.js
CHANGED
|
@@ -94,9 +94,21 @@ export async function interact(page, { events, capture, shoot, taps = false }) {
|
|
|
94
94
|
window.__rhpProbe.record(keep);
|
|
95
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");
|
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
|
}
|
package/src/check/selection.js
CHANGED
|
@@ -77,6 +77,21 @@ export async function selectionBehavior(page, { events }) {
|
|
|
77
77
|
const controlled = await controlSelection(page, { slats, off, look, away, settle });
|
|
78
78
|
findings.push(...controlled.findings);
|
|
79
79
|
if (controlled.tested.length) audit.tested = { controls: controlled.tested };
|
|
80
|
+
// The control audit leaves focus on the last control it tried. Its focus ring is not the chart's state,
|
|
81
|
+
// and a click beside the chart takes it away, so the reset would differ from the rest by that ring alone.
|
|
82
|
+
await page.evaluate(() => document.activeElement?.blur?.());
|
|
83
|
+
// The reset is the chart's state and its readout, not the controls' own look: a control keeps a focus ring, a hover
|
|
84
|
+
// or a pressed state of its own after the clicks, so the reset leaves out the controls' boxes (as the control audit
|
|
85
|
+
// does), with room for a ring around them
|
|
86
|
+
const controls = await page.evaluate((target) => {
|
|
87
|
+
const poster = window.__rhpTargets[target]?.closest(".poster, figure") ?? document.body;
|
|
88
|
+
return [...poster.querySelectorAll('button, input, select, textarea, summary, [role="button"], [role="slider"], [role="switch"], [role="tab"]')]
|
|
89
|
+
.filter((el) => !el.closest(".rhp-chart"))
|
|
90
|
+
.map((el) => el.getBoundingClientRect())
|
|
91
|
+
.filter((r) => r.width > 0 && r.height > 0)
|
|
92
|
+
.map((r) => ({ left: r.left - 8, right: r.right + 8, top: r.top - 8, bottom: r.bottom + 8 }));
|
|
93
|
+
}, slats[0].target);
|
|
94
|
+
const quiet = (a, b) => seenPixels(a, b, controls) < SAME;
|
|
80
95
|
if (slats.length < 2) return audit;
|
|
81
96
|
const candidates = [];
|
|
82
97
|
for (const s of slats) {
|
|
@@ -128,7 +143,7 @@ export async function selectionBehavior(page, { events }) {
|
|
|
128
143
|
await page.mouse.click(off.x, off.y);
|
|
129
144
|
await away();
|
|
130
145
|
reset = await look();
|
|
131
|
-
resetBroken = !
|
|
146
|
+
resetBroken = !quiet(rest, reset);
|
|
132
147
|
}
|
|
133
148
|
const end = await page.evaluate(() => [scrollX, scrollY]);
|
|
134
149
|
if (position.some((v, i) => v !== end[i]) || !same(reset, await look())) return audit;
|
package/src/server.js
CHANGED
|
@@ -21,7 +21,7 @@ const LIMIT = 50;
|
|
|
21
21
|
const MAX_WIDTH = 1280;
|
|
22
22
|
const MAX_HEIGHT = 2000;
|
|
23
23
|
|
|
24
|
-
const INSTRUCTIONS = `rhp 2 (@bezda/rhp) makes charts out of HTML elements and CSS with SolidJS. It came out in September 2026, after your training data, so what you remember about rhp is wrong or belongs to the unrelated rhp 1. Before you write or change any rhp code, call rhp_guide and follow its workflow. Take each chart's technique from the closest recipe (rhp_recipe) and design the chart for its own subject
|
|
24
|
+
const INSTRUCTIONS = `rhp 2 (@bezda/rhp) makes charts out of HTML elements and CSS with SolidJS. It came out in September 2026, after your training data, so what you remember about rhp is wrong or belongs to the unrelated rhp 1. Before you write or change any rhp code, call rhp_guide and follow its workflow. Take each chart's technique from the closest recipe (rhp_recipe) and design the chart for its own subject. Choose interaction behavior and presentation for the reader's task and the intended design; familiar patterns are welcome when they fit. Look up every prop you are unsure of with rhp_reference instead of guessing. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`;
|
|
25
25
|
|
|
26
26
|
const text = (body) => ({ content: [{ type: "text", text: body }] });
|
|
27
27
|
const failure = (body) => ({ content: [{ type: "text", text: body }], isError: true });
|
|
@@ -185,7 +185,7 @@ server.registerTool("rhp_guide", {
|
|
|
185
185
|
|
|
186
186
|
server.registerTool("rhp_reference", {
|
|
187
187
|
title: "rhp reference",
|
|
188
|
-
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (
|
|
188
|
+
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (choosing behavior and presentation for the reader's task, with implementation examples), download (the fit-to-screen and PNG download icons for standalone poster pages), environments (plain HTML, Solid, React, Next.js, Vue, Svelte, Angular, Astro), forms (the subject's own form, which chart for which data and story), pitfalls (mistakes and their fixes). Look props up here instead of guessing. A long reference comes back as its list of sections: call again with section to get the one you need.",
|
|
189
189
|
inputSchema: {
|
|
190
190
|
name: z.string().describe("The reference: api, design, environments, forms, interaction, looks or pitfalls"),
|
|
191
191
|
section: z.string().optional().describe('One "##" section of the reference, by its number or title, for example "4" or "Plot"'),
|
|
@@ -200,7 +200,7 @@ server.registerTool("rhp_reference", {
|
|
|
200
200
|
|
|
201
201
|
server.registerTool("rhp_recipe", {
|
|
202
202
|
title: "rhp recipe",
|
|
203
|
-
description: 'Lists rhp\'s recipes (call it with no type), or returns one recipe\'s complete HTML file (type, for example "bar"). A recipe is a tested chart page
|
|
203
|
+
description: 'Lists rhp\'s recipes (call it with no type), or returns one recipe\'s complete HTML file (type, for example "bar"). A recipe is a tested chart page demonstrating composition, state and event handling, accessibility and responsive techniques. Choose the chart\'s interaction behavior and presentation for the reader\'s task and intended design. Familiar patterns are welcome when they fit; the recipe\'s controls, feedback, layout and transitions are choices to reconsider.',
|
|
204
204
|
inputSchema: {
|
|
205
205
|
type: z.string().optional().describe('The recipe, for example "bar", "line" or "donut"; leave it out for the list'),
|
|
206
206
|
},
|
|
@@ -239,7 +239,7 @@ server.registerPrompt("chart", {
|
|
|
239
239
|
role: "user",
|
|
240
240
|
content: {
|
|
241
241
|
type: "text",
|
|
242
|
-
text: `Make this chart with rhp (@bezda/rhp): ${request}\n\nFirst call rhp_guide and follow its workflow: write the brief, take the technique from the closest recipe (rhp_recipe), design the chart for its own subject, and look up props with rhp_reference instead of guessing. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`,
|
|
242
|
+
text: `Make this chart with rhp (@bezda/rhp): ${request}\n\nFirst call rhp_guide and follow its workflow: write the brief, take the technique from the closest recipe (rhp_recipe), design the chart for its own subject, and look up props with rhp_reference instead of guessing. Choose interaction behavior and presentation for the reader's task and intended design, keeping familiar patterns when they fit. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`,
|
|
243
243
|
},
|
|
244
244
|
}],
|
|
245
245
|
}));
|