@bezda/rhp-mcp 0.0.0-stage → 0.1.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -2
  3. package/bin/rhp-mcp.js +125 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +363 -0
  6. package/skill/recipes/area.html +150 -0
  7. package/skill/recipes/bar.html +139 -0
  8. package/skill/recipes/box-plot.html +158 -0
  9. package/skill/recipes/bubble.html +182 -0
  10. package/skill/recipes/bullet.html +132 -0
  11. package/skill/recipes/candlestick.html +173 -0
  12. package/skill/recipes/column.html +125 -0
  13. package/skill/recipes/diverging-bars.html +144 -0
  14. package/skill/recipes/donut.html +225 -0
  15. package/skill/recipes/dumbbell.html +131 -0
  16. package/skill/recipes/gantt.html +186 -0
  17. package/skill/recipes/grouped-bars.html +136 -0
  18. package/skill/recipes/heatmap.html +177 -0
  19. package/skill/recipes/histogram.html +193 -0
  20. package/skill/recipes/line.html +164 -0
  21. package/skill/recipes/live.html +249 -0
  22. package/skill/recipes/lollipop.html +146 -0
  23. package/skill/recipes/multi-line.html +223 -0
  24. package/skill/recipes/pyramid.html +156 -0
  25. package/skill/recipes/race.html +272 -0
  26. package/skill/recipes/radial-bars.html +151 -0
  27. package/skill/recipes/scatter.html +193 -0
  28. package/skill/recipes/slope.html +193 -0
  29. package/skill/recipes/sparklines.html +169 -0
  30. package/skill/recipes/stacked-100.html +152 -0
  31. package/skill/recipes/stacked-bars.html +144 -0
  32. package/skill/recipes/strip.html +215 -0
  33. package/skill/recipes/violin.html +163 -0
  34. package/skill/recipes/waffle.html +159 -0
  35. package/skill/recipes/waterfall.html +121 -0
  36. package/skill/references/api.md +1556 -0
  37. package/skill/references/design.md +763 -0
  38. package/skill/references/environments.md +1570 -0
  39. package/skill/references/forms.md +160 -0
  40. package/skill/references/interaction.md +792 -0
  41. package/skill/references/pitfalls.md +157 -0
  42. package/src/check/README.md +163 -0
  43. package/src/check/browser.js +92 -0
  44. package/src/check/build.js +439 -0
  45. package/src/check/colors.js +324 -0
  46. package/src/check/findings.js +247 -0
  47. package/src/check/index.js +419 -0
  48. package/src/check/inpage.js +1268 -0
  49. package/src/check/interact.js +266 -0
  50. package/src/check/png.js +98 -0
  51. package/src/check/report.js +103 -0
  52. package/src/check/source.js +387 -0
  53. package/src/check/sourcemap.js +75 -0
  54. package/src/guard/note.js +30 -0
  55. package/src/guard/render.js +17 -0
  56. package/src/guard/rhp.js +608 -0
  57. package/src/guard/rules.js +125 -0
  58. package/src/server.js +265 -0
  59. package/src/skill.js +140 -0
@@ -0,0 +1,1268 @@
1
+ // The code that runs in the checked page. install() is serialized by Playwright and run there, so it uses nothing from
2
+ // outside its own body. It puts window.__rhpProbe in the page: settle(), measure(), hiding text and marks for the
3
+ // screenshots the colors are read from, and what the interaction pass needs (its targets, a change recorder, where the
4
+ // controls are).
5
+ export function install() {
6
+
7
+ if (window.__rhpProbe) return;
8
+
9
+ const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
10
+ const BLOCK = { "rhp-bar": "Bar", "rhp-dot": "Dot", "rhp-tick": "Tick", "rhp-label": "Label", "rhp-cell": "Cell", "rhp-place": "Place", "rhp-area": "Area", "rhp-line": "Line" };
11
+ const MARKS = ".rhp-bar, .rhp-dot, .rhp-tick, .rhp-area, .rhp-line";
12
+ const CONTROLS = "button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=switch], [role=tab], [role=radio], [role=checkbox], [role=slider]";
13
+ const SKIP_TEXT = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TITLE", "HEAD", "OPTION", "TEXTAREA"]);
14
+ const GENERIC = /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-serif|ui-sans-serif|ui-monospace|ui-rounded|emoji|math|fangsong|-apple-system|blinkmacsystemfont|inherit|initial)$/i;
15
+
16
+ let mutations = 0;
17
+ const quiet = new MutationObserver((list) => {
18
+ mutations += list.length;
19
+ });
20
+ quiet.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
21
+
22
+ // Waits for fonts, two frames, the animations that end (CSS transitions, a slat's own animations) and a DOM that
23
+ // stays still for two frames (the JS version moves numbers on its own clock), up to max ms. Says whether the page
24
+ // came to rest.
25
+ async function settle(max = 2000) {
26
+
27
+ const start = performance.now();
28
+ const left = () => max - (performance.now() - start);
29
+
30
+ await Promise.race([document.fonts?.ready, new Promise((r) => setTimeout(r, Math.max(0, left())))]);
31
+ await frame();
32
+ await frame();
33
+
34
+ let still = 0;
35
+ while (left() > 0 && still < 2) {
36
+ const before = mutations;
37
+ const moving = document.getAnimations().some((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime ?? Infinity));
38
+ await frame();
39
+ still = !moving && mutations === before ? still + 1 : 0;
40
+ }
41
+
42
+ return { ms: Math.round(performance.now() - start), rest: still >= 2 };
43
+ }
44
+
45
+ // A full-page screenshot lays the page out at another size for a moment (Chromium does, to draw what is past the
46
+ // window), so the page's resize and matchMedia handlers run: a chart whose orientation follows the width turns and
47
+ // turns back. calm(since) waits for the page to come to rest again when that made it change, since resized() said
48
+ // since.
49
+ let resizes = 0;
50
+ addEventListener("resize", () => resizes++);
51
+ async function calm([r, m]) {
52
+
53
+ await frame();
54
+ const moving = document.getAnimations().some((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime ?? Infinity));
55
+ if (resizes !== r && (mutations !== m || moving)) await settle(500);
56
+ }
57
+
58
+ const visible = (el) => !!el && el.checkVisibility?.({ opacityProperty: true, visibilityProperty: true }) !== false;
59
+
60
+ // The part of a box that its clipping ancestors leave visible, up to (not including) stop, and the first ancestor
61
+ // that cut it along each axis (and whether that one scrolls along it)
62
+ function clipped(box, from, stop) {
63
+
64
+ let { left, top, right, bottom } = box;
65
+ let byX = null;
66
+ let byY = null;
67
+
68
+ for (let a = from; a && a !== stop && a.nodeType === 1; a = a.parentElement) {
69
+ const s = getComputedStyle(a);
70
+ const paint = s.clipPath !== "none" || /paint|strict|content/.test(s.contain);
71
+ if (s.overflowX === "visible" && s.overflowY === "visible" && !paint) continue;
72
+ // overflow on the root or the body clips at the edges of the page, not at the body's box
73
+ const root = a === document.documentElement || a === document.body;
74
+ const k = root ? { left: -scrollX, top: -scrollY, right: document.documentElement.clientWidth - scrollX, bottom: document.documentElement.scrollHeight - scrollY } : a.getBoundingClientRect();
75
+ if (s.overflowX !== "visible" || paint) {
76
+ const [l, r] = [Math.max(left, k.left), Math.min(right, k.right)];
77
+ if (!byX && (l > left + 0.01 || r < right - 0.01)) byX = { el: a, scrolls: /auto|scroll/.test(s.overflowX) };
78
+ left = l;
79
+ right = r;
80
+ }
81
+ if (s.overflowY !== "visible" || paint) {
82
+ const [t, b] = [Math.max(top, k.top), Math.min(bottom, k.bottom)];
83
+ if (!byY && (t > top + 0.01 || b < bottom - 0.01)) byY = { el: a, scrolls: /auto|scroll/.test(s.overflowY) };
84
+ top = t;
85
+ bottom = b;
86
+ }
87
+ }
88
+
89
+ return { left, top, right, bottom, byX, byY, empty: right - left < 0.5 || bottom - top < 0.5 };
90
+ }
91
+
92
+ // Whether what clipping leaves of a text's box shows it (2px or more both ways): screen-reader text, cut to 1px or to
93
+ // nothing by its own box, does not
94
+ const shows = (v) => !v.empty && v.right - v.left >= 2 && v.bottom - v.top >= 2;
95
+
96
+ const describe = (el) => {
97
+ if (!el || el.nodeType !== 1) return "";
98
+ const cls = [...el.classList].filter((c) => !/^rhp-s/.test(c)).slice(0, 3).map((c) => "." + c).join("");
99
+ const block = [...el.classList].map((c) => BLOCK[c]).find(Boolean);
100
+ return block ? `${block}${cls ? ` (${cls})` : ""}` : `<${el.localName}${el.id ? "#" + el.id : ""}>${cls ? ` (${cls})` : ""}`;
101
+ };
102
+
103
+ const matches = (el, selector) => {
104
+ try {
105
+ return el.matches(selector);
106
+ } catch {
107
+ return false;
108
+ }
109
+ };
110
+
111
+ // The style rules of some sheets that apply now (their @media and @supports conditions met), with the cascade layer
112
+ // each is in and the properties it declares (custom properties left out)
113
+ function styleRules(sheets) {
114
+
115
+ const out = [];
116
+ const walk = (rules, layer) => {
117
+ for (const r of rules) {
118
+ if (r instanceof CSSLayerBlockRule) walk(r.cssRules, layer ? layer + "." + r.name : r.name);
119
+ else if (r instanceof CSSMediaRule && matchMedia(r.media.mediaText).matches) walk(r.cssRules, layer);
120
+ else if (r instanceof CSSSupportsRule && CSS.supports(r.conditionText)) walk(r.cssRules, layer);
121
+ else if (r instanceof CSSStyleRule) {
122
+ const props = [];
123
+ for (let i = 0; i < r.style.length; i++) {
124
+ if (!r.style[i].startsWith("--")) props.push(r.style[i]);
125
+ }
126
+ out.push({ rule: r, selector: r.selectorText, layer: layer ?? "", props });
127
+ }
128
+ }
129
+ };
130
+
131
+ for (const sheet of sheets) {
132
+ try {
133
+ walk(sheet.cssRules, null);
134
+ } catch {
135
+ // a sheet from another origin can't be read
136
+ }
137
+ }
138
+
139
+ return out;
140
+ }
141
+
142
+ // rhp's own rules: its sheets adopted by the document, each rule in one of its layers (rhp.core, rhp.slat, rhp.place).
143
+ // Every declaration in them is !important, so no page rule beats them.
144
+ const ownRules = () => styleRules(document.adoptedStyleSheets ?? []).filter((r) => /^rhp(\.|$)/.test(r.layer));
145
+
146
+ // The text of an element that a reader sees, with a space between its parts ("English" and "Anglais" in two spans
147
+ // read "English Anglais"): not what is hidden (display: none, a phone's short name beside the long one) nor
148
+ // screen-reader text (cut to 1px by its own box)
149
+ const seenText = (el) => {
150
+ const parts = [];
151
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
152
+ const range = document.createRange();
153
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
154
+ if (!node.data.trim() || !visible(node.parentElement)) continue;
155
+ range.selectNodeContents(node);
156
+ if ([...range.getClientRects()].some((r) => shows(clipped(r, node.parentElement, null)))) parts.push(node.data.trim());
157
+ }
158
+ return parts.join(" ");
159
+ };
160
+
161
+ const snippet = (text) => {
162
+ const t = text.replace(/\s+/g, " ").trim();
163
+ return t.length > 40 ? t.slice(0, 39) + "…" : t;
164
+ };
165
+
166
+ // The text a screen reader reads in an element: its text and its images' alt, but not what is hidden from it
167
+ // (aria-hidden, hidden, display: none) nor the controls inside a label; an element with an aria-label reads as that,
168
+ // and parts that are not inline are read apart
169
+ function spoken(root) {
170
+
171
+ const parts = [];
172
+ const walk = (node) => {
173
+ if (node.nodeType === 3) parts.push(node.data);
174
+ if (node.nodeType !== 1) return;
175
+ if (node !== root && (node.getAttribute("aria-hidden") === "true" || /^(input|select|textarea|button)$/.test(node.localName) || node.checkVisibility?.({ visibilityProperty: true }) === false)) return;
176
+ const label = node !== root && node.getAttribute("aria-label")?.trim();
177
+ const apart = node !== root && !/^inline/.test(getComputedStyle(node).display) ? " " : "";
178
+ parts.push(apart);
179
+ if (label || node.localName === "img") parts.push(" ", label || node.alt || "", " ");
180
+ else node.childNodes.forEach(walk);
181
+ parts.push(apart);
182
+ };
183
+
184
+ walk(root);
185
+
186
+ return parts.join("").replace(/\s+/g, " ").trim();
187
+ }
188
+
189
+ // A control's accessible name, as a screen reader gets it (simplified): aria-labelledby, aria-label, its labels, its
190
+ // own text (a button), its value (an input button), title, placeholder
191
+ function nameOf(el) {
192
+
193
+ const ids = (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).map((id) => id && document.getElementById(id)).filter(Boolean);
194
+ const own = /^(button|summary)$/.test(el.localName) || /^(button|switch|tab|radio|checkbox)$/.test(el.getAttribute("role") ?? "");
195
+ const names = [
196
+ ids.map(spoken).join(" "),
197
+ el.getAttribute("aria-label"),
198
+ [...(el.labels ?? [])].map(spoken).join(" "),
199
+ own ? spoken(el) : "",
200
+ el.localName === "input" && /^(button|submit|reset)$/.test(el.type) ? el.value : "",
201
+ el.localName === "input" && el.type === "image" ? el.alt : "",
202
+ el.getAttribute("title"),
203
+ el.getAttribute("placeholder"),
204
+ ];
205
+
206
+ return names.map((n) => (n ?? "").replace(/\s+/g, " ").trim()).find(Boolean) ?? "";
207
+ }
208
+
209
+ // A CSS color as [r, g, b, a] in sRGB (0 to 255, a 0 to 1), through a canvas so every color space works
210
+ const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
211
+ const colors = new Map();
212
+ function rgba(css) {
213
+
214
+ if (colors.has(css)) return colors.get(css);
215
+
216
+ ink.clearRect(0, 0, 1, 1);
217
+ ink.fillStyle = "#000";
218
+ ink.fillStyle = css;
219
+ ink.fillRect(0, 0, 1, 1);
220
+ // (ImageData is not premultiplied: its channels are the color's own)
221
+ const [r, g, b, a] = ink.getImageData(0, 0, 1, 1).data;
222
+ const out = a ? [r, g, b, a / 255] : [0, 0, 0, 0];
223
+ colors.set(css, out);
224
+
225
+ return out;
226
+ }
227
+
228
+ const opacityOf = (el) => {
229
+ let o = 1;
230
+ for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
231
+ o *= parseFloat(getComputedStyle(a).opacity) || 0;
232
+ }
233
+ return o;
234
+ };
235
+
236
+ // How tall a font's line box and its glyphs are, from canvas metrics (cached per font and text)
237
+ const metrics = new Map();
238
+ function fontBox(style, text) {
239
+
240
+ const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
241
+ const key = font + "\n" + text;
242
+ if (metrics.has(key)) return metrics.get(key);
243
+
244
+ ink.font = font;
245
+ const m = ink.measureText(text);
246
+ const out = { ascent: m.fontBoundingBoxAscent, descent: m.fontBoundingBoxDescent, inkAscent: m.actualBoundingBoxAscent, inkDescent: m.actualBoundingBoxDescent };
247
+ metrics.set(key, out);
248
+
249
+ return out;
250
+ }
251
+
252
+ // How an element is turned and scaled by its own and its ancestors' transforms (kept per element)
253
+ const turns = new Map();
254
+ function turnOf(el) {
255
+
256
+ if (!el || el.nodeType !== 1) return { turned: false, scale: 1 };
257
+ if (turns.has(el)) return turns.get(el);
258
+
259
+ const up = turnOf(el.parentElement);
260
+ const s = getComputedStyle(el);
261
+ let turned = up.turned || /vertical|sideways/.test(s.writingMode);
262
+ let scale = up.scale;
263
+
264
+ if (s.transform !== "none") {
265
+ const m = new DOMMatrixReadOnly(s.transform);
266
+ turned ||= Math.abs(m.b) > 1e-3 || Math.abs(m.c) > 1e-3;
267
+ scale *= Math.hypot(m.a, m.b);
268
+ }
269
+ if (s.rotate !== "none" && !/^0(deg|rad|grad|turn)?$/.test(s.rotate)) turned = true;
270
+ if (s.scale !== "none") scale *= parseFloat(s.scale) || 1;
271
+
272
+ const out = { turned, scale };
273
+ turns.set(el, out);
274
+
275
+ return out;
276
+ }
277
+
278
+ // The box of each character of a text (a turned text's bounding boxes are much larger than its glyphs)
279
+ function characters(node) {
280
+
281
+ const out = [];
282
+ const range = document.createRange();
283
+
284
+ for (let i = 0; i < Math.min(node.data.length, 300); i++) {
285
+ if (!node.data[i].trim()) continue;
286
+ range.setStart(node, i);
287
+ range.setEnd(node, i + 1);
288
+ const r = range.getBoundingClientRect();
289
+ if (r.width > 0 && r.height > 0) out.push(r);
290
+ }
291
+
292
+ return out;
293
+ }
294
+
295
+ // Every visible text in the page: its fragments (one per line, or one per character when it is turned), the part of
296
+ // each its clipping ancestors leave, and the box its glyphs fill
297
+ function texts() {
298
+
299
+ const out = [];
300
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
301
+
302
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
303
+ const text = node.data;
304
+ if (!text.trim()) continue;
305
+ const el = node.parentElement;
306
+ if (!el || SKIP_TEXT.has(el.tagName) || el.closest("svg, select, textarea")) continue;
307
+ if (!visible(el)) continue;
308
+
309
+ const range = document.createRange();
310
+ range.selectNodeContents(node);
311
+ const style = getComputedStyle(el);
312
+ const size = parseFloat(style.fontSize);
313
+ const fb = fontBox(style, text.trim());
314
+ const content = fb.ascent + fb.descent;
315
+ const turn = turnOf(el);
316
+ const fragments = [];
317
+
318
+ for (const r of turn.turned ? characters(node) : range.getClientRects()) {
319
+ if (r.width < 0.5 || r.height < 0.5) continue;
320
+ const v = clipped(r, el, null);
321
+ const scale = turn.turned || !(content > 0) ? turn.scale : r.height / content;
322
+ const w = r.right - r.left;
323
+ const h = r.bottom - r.top;
324
+ const glyphs = turn.turned
325
+ ? { left: r.left + w * 0.15, right: r.right - w * 0.15, top: r.top + h * 0.15, bottom: r.bottom - h * 0.15 }
326
+ : { left: r.left, right: r.right, top: r.top + Math.max(0, fb.ascent - fb.inkAscent) * scale, bottom: Math.min(r.bottom, r.top + (fb.ascent + fb.inkDescent) * scale) };
327
+ fragments.push({ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, visible: v, glyphs, scale });
328
+ }
329
+
330
+ if (!fragments.length) continue;
331
+ const shown = fragments.filter((f) => shows(f.visible));
332
+ out.push({ node, el, text: snippet(text), size, effective: size * Math.min(...fragments.map((f) => f.scale)), fragments, shown, style, turned: turn.turned });
333
+ }
334
+
335
+ return out;
336
+ }
337
+
338
+ const inside = (a, b) => a === b || a.contains(b) || b.contains(a);
339
+
340
+ // What the page shows at this width: the charts, and every problem the probes can see in the DOM
341
+ function measure() {
342
+
343
+ const found = [];
344
+ const add = (f) => found.push(f);
345
+ const doc = document.documentElement;
346
+ const all = [...document.querySelectorAll(".rhp-chart")];
347
+ const charts = all.filter((c) => !c.parentElement?.closest(".rhp-chart"));
348
+
349
+ const summary = charts.map((c) => {
350
+ const box = c.getBoundingClientRect();
351
+ const plots = [...c.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale"));
352
+ const blocks = {};
353
+ for (const el of c.querySelectorAll(Object.keys(BLOCK).map((k) => "." + k).join(","))) {
354
+ if (el.closest(".rhp-scale") || el.closest("[hidden]")) continue;
355
+ const name = BLOCK[[...el.classList].find((k) => BLOCK[k])].toLowerCase();
356
+ blocks[name] = (blocks[name] ?? 0) + 1;
357
+ }
358
+ const slats = plots.reduce((n, p) => n + [...p.children].filter((s) => !s.hidden).length, 0);
359
+ return { slats, blocks, box: { width: Math.round(box.width), height: Math.round(box.height) }, orientation: c.dataset.rhpO === "v" ? "vertical" : "horizontal" };
360
+ });
361
+
362
+ // Plots drawn outside any Chart get no scale and none of rhp's CSS
363
+ const lost = [...document.querySelectorAll(".rhp-plot")].filter((p) => !p.closest(".rhp-chart"));
364
+ if (lost.length) add({ code: "plot-outside-chart", count: lost.length });
365
+ if (!charts.length && !lost.length) add({ code: "no-chart" });
366
+
367
+ // A chart in a box that shrinks to its content (a flex item, an inline-block, a float, width: fit-content) is only
368
+ // as wide as its room, with nothing left for the plot. What is drawn in it then is no use to measure.
369
+ const collapsed = new Set();
370
+ for (const c of charts) {
371
+ const plots = [...c.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale"));
372
+ if (!plots.length || !plots.every((p) => p.getBoundingClientRect().width < 2)) continue;
373
+ collapsed.add(c);
374
+ // the box that shrinks to fit: an item of a flex row, an inline-block or a float
375
+ let why = "";
376
+ for (let a = c; a && a !== document.body && !why; a = a.parentElement) {
377
+ const s = getComputedStyle(a);
378
+ if (/flex/.test(getComputedStyle(a.parentElement ?? a).display)) why = a === c ? `it is an item of the flex row ${describe(a.parentElement)}` : `it sits in ${describe(a)}, an item of a flex row`;
379
+ else if (/inline/.test(s.display)) why = `it sits in ${describe(a)}, an inline-block`;
380
+ else if (s.float !== "none") why = `it sits in ${describe(a)}, a float`;
381
+ }
382
+ add({ code: "collapsed-chart", width: Math.round(c.getBoundingClientRect().width), why });
383
+ }
384
+
385
+ for (const c of charts) {
386
+ if (collapsed.has(c)) continue;
387
+ const plots = [...c.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale"));
388
+ // an overlap Plot may be empty (a pointer's readout, drawn only while pointing); a chart with nothing in any Plot,
389
+ // or a Plot of slats with none, draws no data
390
+ const empty = (p) => ![...p.children].some((s) => !s.hidden);
391
+ if (plots.length && (plots.every(empty) || plots.some((p) => empty(p) && !p.hasAttribute("data-rhp-overlap")))) add({ code: "empty-plot", chart: describe(c) });
392
+ for (const p of plots) {
393
+ const shown = [...p.children].filter((s) => !s.hidden);
394
+ if (!shown.length) continue;
395
+ const r = p.getBoundingClientRect();
396
+ const vertical = p.dataset.rhpO === "v";
397
+ const length = vertical ? r.height : r.width;
398
+ const first = shown[0].getBoundingClientRect();
399
+ const band = p.hasAttribute("data-rhp-overlap") ? Infinity : vertical ? first.width : first.height;
400
+ // (slats as thin as 3px are fine: a histogram's many bins, or the per-year slats a line chart is pointed through)
401
+ if (length < 40 || band < 2) {
402
+ add({ code: "small-plot", vertical, thin: band < 2, length: Math.round(length), band: Math.round(band * 10) / 10, chart: describe(c) });
403
+ } else if (!vertical && band < 18 && shown.filter((s) => [...s.querySelectorAll(".rhp-label")].some((l) => visible(l) && l.textContent.trim())).length * 2 > shown.length) {
404
+ // a horizontal chart whose slats are too thin for the text most of them hold (one tag on one slat fits)
405
+ add({ code: "cramped", vertical: false, band: Math.round(band * 10) / 10, chart: describe(c) });
406
+ }
407
+ }
408
+ }
409
+
410
+ // CSS variables that came out as NaN or undefined, and values past the Chart's scale
411
+ const VALUE = { "--rhp-from": "from", "--rhp-to": "to", "--rhp-at": "at", "--rhp-value": "value", "--rhp-size": "size", "--rhp-across": "across", "--rhp-cross": "cross", "--rhp-min": "scale", "--rhp-max": "scale", "--rhp-thick": "thick" };
412
+ for (const c of all) {
413
+ for (const el of [c, ...c.querySelectorAll("[style]")]) {
414
+ for (let i = 0; i < el.style.length; i++) {
415
+ const name = el.style[i];
416
+ if (!name.startsWith("--rhp-")) continue;
417
+ const value = el.style.getPropertyValue(name).trim();
418
+ if (/(^|[^\w-])(NaN|undefined|Infinity)([^\w-]|$)/.test(value)) {
419
+ const block = el === c ? "Chart" : [...el.classList].map((k) => BLOCK[k]).find(Boolean) ?? describe(el);
420
+ add({ code: "nan-value", block, prop: VALUE[name] ?? name, value });
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ // Values outside the Chart's scale (or cross scale), Labels placed with at included. They are compared whatever
427
+ // the block's size: a Bar the scale cuts to nothing has none. Left out: a block with no box (display: none), and
428
+ // one with a size that clipping hides (a slat may show a window of its marks, and clip the rest).
429
+ const past = new Map();
430
+ const outside = (key, f) => {
431
+ const off = (p) => Math.abs(p.value - (p.value > p.max ? p.max : p.min));
432
+ const had = past.get(key);
433
+ past.set(key, { ...(had && off(had) >= off(f) ? had : f), count: (had?.count ?? 0) + 1 });
434
+ };
435
+ for (const el of document.querySelectorAll(".rhp-bar, .rhp-dot, .rhp-tick, .rhp-place, .rhp-area, .rhp-line, .rhp-label[data-rhp-at]")) {
436
+ if (el.closest("[hidden], .rhp-scale") || !el.getClientRects().length) continue;
437
+ const c = el.closest(".rhp-chart");
438
+ if (!c) continue;
439
+ const box = el.getBoundingClientRect();
440
+ if (box.width * box.height > 0 && clipped(box, el.parentElement, null).empty) continue;
441
+ const num = (e, k) => parseFloat(e.style.getPropertyValue(k));
442
+ const block = BLOCK[[...el.classList].find((k) => BLOCK[k])];
443
+ const vertical = el.dataset.rhpO === "v";
444
+ // (a reversed or equal scale is reported as bad-scale, and no value is inside it)
445
+ const min = num(c, "--rhp-min");
446
+ const max = num(c, "--rhp-max");
447
+ if (!(min < max)) continue;
448
+ const eps = (max - min) * 1e-6;
449
+ // (a Bar wholly past one end of the scale is cut to nothing; a Label is kept at the end a horizontal chart ends
450
+ // at, or a vertical one starts at, and runs out of the plot at the other)
451
+ const ends = [num(el, "--rhp-from"), num(el, "--rhp-to")];
452
+ const gone = block === "Bar" && (Math.max(...ends) <= min + eps || Math.min(...ends) >= max - eps);
453
+ for (const v of ["--rhp-from", "--rhp-to", "--rhp-at"].map((k) => num(el, k)).filter(Number.isFinite)) {
454
+ if (v < min - eps || v > max + eps) outside(block, { block, value: v, min, max, gone, kept: block === "Label" && (vertical ? v < min : v > max) });
455
+ }
456
+ const cmin = num(c, "--rhp-cross-min");
457
+ const cmax = num(c, "--rhp-cross-max");
458
+ if (cmin < cmax) {
459
+ const ceps = (cmax - cmin) * 1e-6;
460
+ for (const v of ["--rhp-cross", "--rhp-cross-from", "--rhp-cross-to"].map((k) => num(el, k)).filter(Number.isFinite)) {
461
+ if (v < cmin - ceps || v > cmax + ceps) outside(block + " cross", { block, value: v, min: cmin, max: cmax, cross: true, kept: block === "Label" && (vertical ? v > cmax : v < cmin) });
462
+ }
463
+ }
464
+ }
465
+ // (an Area or a Line is squeezed into the scale rather than cut, so it has a code of its own)
466
+ for (const p of past.values()) {
467
+ add({ code: /Area|Line/.test(p.block) ? "points-past-scale" : "value-past-scale", ...p });
468
+ }
469
+
470
+ const list = texts();
471
+ const inCollapsed = (el) => [...collapsed].some((c) => c.contains(el));
472
+
473
+ // Marks and labels outside their chart's box (only the part clipping leaves visible counts), and every text in the
474
+ // chart by its glyphs (axis numbers included), since a text can run past its own element
475
+ for (const c of charts) {
476
+ if (collapsed.has(c)) continue;
477
+ const box = c.getBoundingClientRect();
478
+ let worst = null;
479
+ const out = new Set();
480
+ const beyond = (v, el, text) => {
481
+ const by = { right: v.right - box.right, left: box.left - v.left, bottom: v.bottom - box.bottom, top: box.top - v.top };
482
+ const side = Object.keys(by).reduce((a, k) => (by[k] > by[a] ? k : a));
483
+ if (by[side] <= 1) return false;
484
+ out.add(el);
485
+ if (!worst || by[side] > worst.by) worst = { by: Math.round(by[side] * 10) / 10, side, what: describe(el), text: snippet(text), axis: !!el.closest(".rhp-axis"), label: !!el.closest(".rhp-label"), edge: !!el.closest(".rhp-label[data-rhp-edge]"), vertical: c.dataset.rhpO === "v" };
486
+ return true;
487
+ };
488
+ for (const e of c.querySelectorAll("*")) {
489
+ const b = e.getBoundingClientRect();
490
+ if (!b.width && !b.height) continue;
491
+ if (!visible(e) || getComputedStyle(e).position === "fixed") continue;
492
+ const v = clipped(b, e.parentElement, c);
493
+ if (v.right < v.left || v.bottom < v.top) continue;
494
+ beyond(v, e, e.textContent ?? "");
495
+ }
496
+ for (const t of list) {
497
+ if (!c.contains(t.el)) continue;
498
+ for (const f of t.shown) {
499
+ const v = clipped(f.rect, t.el, c);
500
+ const g = { left: Math.max(f.glyphs.left, v.left), right: Math.min(f.glyphs.right, v.right), top: Math.max(f.glyphs.top, v.top), bottom: Math.min(f.glyphs.bottom, v.bottom) };
501
+ if (g.right - g.left >= 0.5 && g.bottom - g.top >= 0.5 && beyond(g, t.el, t.node.data)) break;
502
+ }
503
+ }
504
+ if (worst) add({ code: "sticks-out", ...worst, count: out.size, chart: describe(c) });
505
+ }
506
+
507
+ // NaN, undefined or Infinity printed in a chart: a value that was missing, or worked out from nothing
508
+ for (const t of list) {
509
+ if (t.el.closest(".rhp-chart") && /(^|[^\w$])(NaN|undefined|Infinity)(?![\w$])/.test(t.node.data)) add({ code: "nan-value", printed: true, text: t.text, what: describe(t.el) });
510
+ }
511
+
512
+ // Text outside its poster
513
+ for (const poster of document.querySelectorAll(".poster")) {
514
+ const box = poster.getBoundingClientRect();
515
+ for (const t of list) {
516
+ if (!poster.contains(t.el)) continue;
517
+ for (const f of t.fragments) {
518
+ const v = clipped(f.rect, t.el, poster);
519
+ if (v.empty) continue;
520
+ const by = Math.max(v.right - box.right, box.left - v.left, v.bottom - box.bottom, box.top - v.top);
521
+ if (by > 1) {
522
+ add({ code: "text-outside-poster", text: t.text, by: Math.round(by), what: describe(t.el) });
523
+ break;
524
+ }
525
+ }
526
+ }
527
+ }
528
+
529
+ // Text cut off by an ancestor's overflow (an ellipsis, a box too small): not by a box that scrolls that way, and
530
+ // not the little a tight line-height takes off the space above and below the glyphs. With text-overflow: ellipsis
531
+ // any overflow cuts letters (the browser draws the ellipsis over them), and the line comes as two fragments, the
532
+ // whole and what is shown.
533
+ // (text with no part shown is hidden on purpose, for screen readers or until it is needed: not cut off)
534
+ for (const t of list) {
535
+ if (inCollapsed(t.el) || !t.shown.length) continue;
536
+ for (const f of t.fragments) {
537
+ const v = f.visible;
538
+ const ellipsis = !!v.byX && getComputedStyle(v.byX.el).textOverflow === "ellipsis";
539
+ const lostX = v.byX && !v.byX.scrolls ? Math.max(0, v.left - f.rect.left) + Math.max(0, f.rect.right - v.right) : 0;
540
+ const lostY = v.byY && !v.byY.scrolls ? Math.max(0, v.top - f.glyphs.top) + Math.max(0, f.glyphs.bottom - v.bottom) : 0;
541
+ if (lostX > (ellipsis ? 0.05 : 1) || lostY > Math.max(1.5, t.size * 0.15)) {
542
+ const by = lostX > (ellipsis ? 0.05 : 1) ? v.byX.el : v.byY.el;
543
+ const width = f.rect.right - f.rect.left;
544
+ const shownWidth = ellipsis && t.fragments.length > 1 ? Math.min(...t.fragments.map((g) => g.rect.right - g.rect.left)) : width - lostX;
545
+ const lost = by === v.byX?.el ? width - shownWidth : lostY;
546
+ add({ code: "text-cut-off", text: t.text, lost: Math.max(1, Math.round(lost)), ellipsis: ellipsis && by === v.byX.el, by: describe(by), edge: !!by.closest(".rhp-label[data-rhp-edge]"), label: !!by.closest(".rhp-label"), vertical: by.closest("[data-rhp-o]")?.dataset.rhpO === "v" });
547
+ break;
548
+ }
549
+ }
550
+ }
551
+
552
+ // The block a text's lines belong to: its nearest ancestor that is not inline (a heading, a paragraph, a label). Two
553
+ // texts of one block are its lines, which a tight line-height brings close without the letters touching.
554
+ const blocks = new Map();
555
+ const blockOf = (el) => {
556
+ if (!blocks.has(el)) blocks.set(el, !el.parentElement || !/^(inline|ruby|contents)/.test(getComputedStyle(el).display) ? el : blockOf(el.parentElement));
557
+ return blocks.get(el);
558
+ };
559
+ const apart = (a, b) => a !== b && !inside(a, b) && blockOf(a) !== blockOf(b);
560
+
561
+ // Text over other text: the glyph boxes of two blocks' texts
562
+ const boxes = [];
563
+ for (const t of list) {
564
+ for (const f of t.shown) {
565
+ const g = { left: Math.max(f.glyphs.left, f.visible.left), right: Math.min(f.glyphs.right, f.visible.right), top: Math.max(f.glyphs.top, f.visible.top), bottom: Math.min(f.glyphs.bottom, f.visible.bottom) };
566
+ if (g.right - g.left > 0.5 && g.bottom - g.top > 0.5) boxes.push({ t, g });
567
+ }
568
+ }
569
+ boxes.sort((a, b) => a.g.left - b.g.left);
570
+ const pairs = new Set();
571
+ for (let i = 0; i < boxes.length; i++) {
572
+ const a = boxes[i];
573
+ for (let j = i + 1; j < boxes.length && boxes[j].g.left < a.g.right; j++) {
574
+ const b = boxes[j];
575
+ if (!apart(a.t.el, b.t.el) || inCollapsed(a.t.el) || inCollapsed(b.t.el)) continue;
576
+ const x = Math.min(a.g.right, b.g.right) - Math.max(a.g.left, b.g.left);
577
+ const y = Math.min(a.g.bottom, b.g.bottom) - Math.max(a.g.top, b.g.top);
578
+ if (x <= 1 || y <= 1) continue;
579
+ const key = [a.t.text, b.t.text].sort().join("\n");
580
+ if (pairs.has(key)) continue;
581
+ pairs.add(key);
582
+ add({ code: "text-overlap", a: a.t.text, b: b.t.text, x: Math.round(x), y: Math.round(y), whatA: describe(a.t.el), whatB: describe(b.t.el) });
583
+ }
584
+ }
585
+
586
+ // Names in an overlap Plot on a cross axis (a scatter, a bubble chart) that a reader may take for another slat's: a
587
+ // Label's text drawn over another slat's Dot by more than 1px, or another slat's Dot nearer the middle line of the
588
+ // text than the Label's own Dot is to the text (a Dot beside the text on its line is read as what it names; one
589
+ // past a corner of the text is a neighbor)
590
+ const gap = (g, d) => Math.hypot(Math.max(g.left, Math.min(d.x, g.right)) - d.x, Math.max(g.top, Math.min(d.y, g.bottom)) - d.y) - d.r;
591
+ const fromLine = (g, d) => Math.hypot(Math.max(g.left, Math.min(d.x, g.right)) - d.x, (g.top + g.bottom) / 2 - d.y) - d.r;
592
+ for (const c of charts) {
593
+ if (collapsed.has(c) || !(parseFloat(c.style.getPropertyValue("--rhp-cross-min")) < parseFloat(c.style.getPropertyValue("--rhp-cross-max")))) continue;
594
+ let worst = null;
595
+ let count = 0;
596
+ for (const p of c.querySelectorAll(":scope > .rhp-body > .rhp-plot[data-rhp-overlap]:not(.rhp-scale)")) {
597
+ const slats = [...p.children].filter((s) => !s.hidden);
598
+ const dots = slats.flatMap((s) => [...s.querySelectorAll(".rhp-dot")].filter(visible).map((el) => {
599
+ const r = el.getBoundingClientRect();
600
+ return { s, x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2, r: Math.min(r.width, r.height) / 2 };
601
+ })).filter((d) => d.r > 0);
602
+ for (const s of slats) {
603
+ const own = dots.filter((d) => d.s === s);
604
+ if (!own.length) continue;
605
+ for (const label of s.querySelectorAll(".rhp-label")) {
606
+ const glyphs = boxes.filter((x) => label.contains(x.t.el)).map((x) => x.g);
607
+ if (!glyphs.length) continue;
608
+ const mine = Math.min(...glyphs.flatMap((g) => own.map((d) => gap(g, d))));
609
+ let found = null;
610
+ for (const d of dots) {
611
+ if (d.s === s) continue;
612
+ const over = -Math.min(...glyphs.map((g) => gap(g, d)));
613
+ const near = Math.min(...glyphs.map((g) => fromLine(g, d)));
614
+ // drawn over it first, the deepest; then the nearest past its own
615
+ const score = over > 1 ? 1000 + over : near < mine ? mine - near : -1;
616
+ if (score > (found?.score ?? 0)) found = { score, over: over > 1, d, near };
617
+ }
618
+ if (!found) continue;
619
+ count++;
620
+ if (!worst || found.score > worst.score) worst = { ...found, text: snippet(seenText(label)), other: snippet(seenText([...found.d.s.querySelectorAll(".rhp-label")].find((l) => seenText(l)) ?? found.d.s)), mine };
621
+ }
622
+ }
623
+ }
624
+ if (worst) add({ code: "ambiguous-label", text: worst.text, other: worst.other, over: worst.over, near: Math.max(0, Math.round(worst.near)), own: Math.round(worst.mine), count });
625
+ }
626
+
627
+ // A vertical chart left at the default height (240px) whose slats' labels crowd each other
628
+ for (const c of charts) {
629
+ if (collapsed.has(c) || c.dataset.rhpO !== "v" || c.hasAttribute("data-rhp-aspect") || c.style.getPropertyValue("--rhp-height").trim() !== "240px") continue;
630
+ // the labels' glyph boxes, each with the slat of the chart's own Plot it is in (stacked parts are slats of a Plot
631
+ // inside it)
632
+ const labels = boxes.filter((x) => c.contains(x.t.el) && x.t.el.closest(".rhp-label")).map((x) => ({ ...x, slat: x.t.el.closest(".rhp-body > .rhp-plot > *") }));
633
+ const crowded = labels.some((a, i) => labels.some((b, j) => j > i && a.slat && a.slat === b.slat && apart(a.t.el, b.t.el)
634
+ && Math.min(a.g.right, b.g.right) - Math.max(a.g.left, b.g.left) > 1 && Math.min(a.g.bottom, b.g.bottom) - Math.max(a.g.top, b.g.top) > 1));
635
+ if (crowded) add({ code: "cramped", vertical: true, chart: describe(c) });
636
+ }
637
+
638
+ const own = ownRules();
639
+
640
+ // Colors series-n past the theme's series: rhp has no color for them, and they fall back to series-1
641
+ for (const c of charts) {
642
+ const cs = getComputedStyle(c);
643
+ let have = 0;
644
+ while (cs.getPropertyValue(`--rhp-series-${have + 1}`).trim()) {
645
+ have++;
646
+ }
647
+ const past = new Map();
648
+ for (const el of c.querySelectorAll("[style*='--rhp-color']")) {
649
+ const n = +(el.style.getPropertyValue("--rhp-color").match(/var\(--rhp-series-(\d+)\)/)?.[1] ?? 0);
650
+ if (n > have && !getComputedStyle(el).getPropertyValue(`--rhp-series-${n}`).trim()) past.set(n, (past.get(n) ?? 0) + 1);
651
+ }
652
+ if (past.size) add({ code: "series-past-theme", keys: [...past.keys()].sort((a, b) => a - b), count: [...past.values()].reduce((a, b) => a + b, 0), have });
653
+ }
654
+
655
+ // Page CSS that reaches into a chart and sets what rhp's own CSS sets there: it loses, so it changes nothing
656
+ const byProp = new Map();
657
+ for (const r of own) {
658
+ for (const p of r.props) {
659
+ if (!byProp.has(p)) byProp.set(p, []);
660
+ byProp.get(p).push(r.selector);
661
+ }
662
+ }
663
+ const pageSheets = [...document.styleSheets].filter((s) => !s.ownerNode?.matches?.("[data-rhp-layers], [data-rhp-server]"));
664
+ for (const r of styleRules(pageSheets)) {
665
+ if (/^rhp(\.|$)/.test(r.layer) || !r.props.length) continue;
666
+ let matched = [];
667
+ try {
668
+ matched = [...document.querySelectorAll(r.selector)];
669
+ } catch {
670
+ continue;
671
+ }
672
+ const reached = matched.filter((el) => el.closest(".rhp-chart")).slice(0, 60);
673
+ if (!reached.length) continue;
674
+ // only a rule aimed at the chart: one that names rhp's classes, or one with a class, an id or an attribute on its
675
+ // subject that reaches only elements in charts (a reset such as svg { display: block } is not aimed at it)
676
+ const subjects = r.selector.split(/,(?![^(]*\))/).map((x) => x.trim().split(/\s*[\s>+~]\s*(?![^(]*\))/).pop());
677
+ if (!/\.rhp-/.test(r.selector) && !(reached.length === matched.length && subjects.every((x) => /[.#[]/.test(x)))) continue;
678
+ const dead = r.props.filter((p) => byProp.has(p) && reached.every((el) => byProp.get(p).some((sel) => matches(el, sel))));
679
+ if (!dead.length) continue;
680
+ const family = (p) => p.match(/^(background|border|margin|padding|font|outline|text-decoration|transition|animation|inset|overflow|grid|flex|columns?|list-style)/)?.[1] ?? p;
681
+ add({ code: "page-css-ignored", selector: snippet(r.selector), props: [...new Set(dead.map(family))], what: describe(reached[0]), count: reached.length });
682
+ }
683
+
684
+ // Text too small to read
685
+ let tiny = null;
686
+ let tinyCount = 0;
687
+ for (const t of list) {
688
+ if (!t.shown.length || t.effective >= 9) continue;
689
+ tinyCount++;
690
+ if (!tiny || t.effective < tiny.size) tiny = { text: t.text, size: Math.round(t.effective * 10) / 10, what: describe(t.el) };
691
+ }
692
+ if (tiny) add({ code: "tiny-text", ...tiny, count: tinyCount });
693
+
694
+ // The page scrolling sideways, and the outermost element that reaches furthest past it
695
+ const overflow = doc.scrollWidth - doc.clientWidth;
696
+ if (overflow > 1) {
697
+ let widest = null;
698
+ for (const e of document.body.querySelectorAll("*")) {
699
+ const r = e.getBoundingClientRect();
700
+ if (r.right > doc.clientWidth + 1 && visible(e) && (!widest || r.right > widest.right + 0.5)) widest = { el: e, right: r.right, width: r.width };
701
+ }
702
+ add({ code: "page-scrolls-sideways", by: Math.round(overflow), what: widest ? describe(widest.el) : "", width: widest ? Math.round(widest.width) : 0, viewport: doc.clientWidth });
703
+ }
704
+
705
+ // Controls a screen reader can only call "button" or "slider": no text, aria-label, aria-labelledby or label
706
+ const unnamed = [...document.querySelectorAll(CONTROLS)].filter((el) => visible(el) && !nameOf(el));
707
+ if (unnamed.length) add({ code: "unnamed-control", what: describe(unnamed[0]), count: unnamed.length });
708
+
709
+ // Fonts the page asks for that are not there: a web font none of whose faces loaded (a family split by
710
+ // unicode-range, as Google Fonts serves CJK ones, loads only the faces its text needs, and document.fonts.check()
711
+ // says false while another face is unused), or a font named in a stack that this machine doesn't have (it measures
712
+ // like the fallbacks)
713
+ const familyOf = (f) => f.family.replace(/^["']|["']$/g, "");
714
+ const faces = [...(document.fonts ?? [])];
715
+ const declared = new Set(faces.map(familyOf));
716
+ const loaded = new Set(faces.filter((f) => f.status === "loaded").map(familyOf));
717
+ const missing = new Map();
718
+ const tried = new Set();
719
+ for (const t of list) {
720
+ const family = t.style.fontFamily.split(",")[0].trim().replace(/^["']|["']$/g, "");
721
+ if (!family || GENERIC.test(family) || loaded.has(family)) continue;
722
+ const font = `${t.style.fontStyle} ${t.style.fontWeight} 16px "${family}"`;
723
+ if (tried.has(font)) continue;
724
+ tried.add(font);
725
+ if (declared.has(family)) {
726
+ missing.set(family, faces.some((f) => familyOf(f) === family && f.status === "error"));
727
+ continue;
728
+ }
729
+ const probe = "mmmmmmmmmmlli1WW@#";
730
+ const absent = ["monospace", "serif"].every((fallback) => {
731
+ ink.font = `${t.style.fontStyle} ${t.style.fontWeight} 72px ${fallback}`;
732
+ const base = ink.measureText(probe).width;
733
+ ink.font = `${t.style.fontStyle} ${t.style.fontWeight} 72px "${family}", ${fallback}`;
734
+ return ink.measureText(probe).width === base;
735
+ });
736
+ if (absent) missing.set(family, false);
737
+ }
738
+ for (const [family, failed] of missing) {
739
+ add({ code: "font-not-loaded", family, failed });
740
+ }
741
+
742
+ return { found, charts: summary };
743
+ }
744
+
745
+ // A transition the slat's CSS sets on a block itself replaces rhp's (left and width, or bottom and height, or an
746
+ // outline's d), so the block jumps to new values. Not in the JS version (nothing is transitioned there), not with
747
+ // transition: none (a choice), and not when the slat's CSS places the block itself (a radial layout). It is read
748
+ // with motion on: with reduced motion, rhp turns every block's transition off.
749
+ function transitions() {
750
+
751
+ const found = [];
752
+ const own = ownRules();
753
+ const PLACE = /^(left|right|top|bottom|width|height|inset)/;
754
+ const jumps = new Map();
755
+
756
+ for (const el of document.querySelectorAll(".rhp-bar, .rhp-dot, .rhp-tick, .rhp-label[data-rhp-at], .rhp-place, .rhp-area, .rhp-line, .rhp-cell, .rhp-area path, .rhp-line path")) {
757
+ if (!el.closest(".rhp-chart") || el.closest("[data-rhp-animate='js'], [data-rhp-turning], .rhp-scale, [hidden]")) continue;
758
+ const s = getComputedStyle(el);
759
+ const names = s.transitionProperty.split(/\s*,\s*/);
760
+ const times = s.transitionDuration.split(/\s*,\s*/).map((x) => parseFloat(x) || 0);
761
+ const live = names.filter((n, i) => n !== "none" && times[i % times.length] > 0);
762
+ if (!live.length || live.includes("all")) continue;
763
+ const path = el.localName === "path";
764
+ const block = BLOCK[[...(path ? el.closest(".rhp-area, .rhp-line") : el).classList].find((k) => BLOCK[k])];
765
+ const vertical = el.closest("[data-rhp-o]")?.dataset.rhpO === "v";
766
+ const span = block === "Bar" || block === "Area" || block === "Line";
767
+ const needs = path ? ["d"] : block === "Cell" ? ["background-color"] : vertical ? (span ? ["bottom", "height"] : ["bottom"]) : span ? ["left", "width"] : ["left"];
768
+ const dropped = needs.filter((p) => !live.includes(p));
769
+ if (!dropped.length) continue;
770
+ if (!path && own.some((r) => r.layer === "rhp.slat" && r.props.some((p) => PLACE.test(p)) && matches(el, r.selector))) continue;
771
+ const key = block + (path ? " path" : "") + "\n" + s.transitionProperty;
772
+ jumps.set(key, { block, path, set: live.join(", "), dropped, what: describe(path ? el.parentElement : el), count: (jumps.get(key)?.count ?? 0) + 1 });
773
+ }
774
+
775
+ for (const j of jumps.values()) {
776
+ found.push({ code: "block-transition", ...j });
777
+ }
778
+
779
+ return found;
780
+ }
781
+
782
+ // A box-shadow's layers that reach out of the box (not inset), with their colors
783
+ function shadows(value) {
784
+
785
+ const out = [];
786
+ if (!value || value === "none") return out;
787
+
788
+ for (const layer of value.split(/,(?![^(]*\))/)) {
789
+ if (/\binset\b/.test(layer)) continue;
790
+ const color = layer.match(/(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)|#[0-9a-f]{3,8}\b/i)?.[0];
791
+ const [x = 0, y = 0, blur = 0, spread = 0] = [...layer.replace(color ?? "", "").matchAll(/-?[\d.]+px/g)].map((m) => parseFloat(m[0]));
792
+ if (color && Math.max(Math.abs(x), Math.abs(y)) + spread + blur / 2 >= 1) out.push({ kind: "shadow", color: rgba(color) });
793
+ }
794
+
795
+ return out;
796
+ }
797
+
798
+ // What draws a mark's edge: its borders, its outline and its box-shadows
799
+ function edges(s) {
800
+
801
+ const out = [];
802
+
803
+ for (const side of ["Top", "Right", "Bottom", "Left"]) {
804
+ const style = s[`border${side}Style`];
805
+ if (parseFloat(s[`border${side}Width`]) >= 1 && style !== "none" && style !== "hidden") out.push({ kind: "border", color: rgba(s[`border${side}Color`]) });
806
+ }
807
+ if (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) >= 1) out.push({ kind: "outline", color: rgba(s.outlineColor) });
808
+
809
+ return [...out, ...shadows(s.boxShadow)];
810
+ }
811
+
812
+ // The texts and marks whose colors are read from screenshots, in page px. The text's own color is kept here; the
813
+ // screenshot with the glyphs hidden gives what is behind each.
814
+ function colorItems() {
815
+
816
+ const items = [];
817
+ const slats = new Map();
818
+
819
+ for (const t of texts()) {
820
+ if (!t.shown.length || t.el.closest(":disabled, [aria-disabled='true'], [inert]")) continue;
821
+ const color = rgba(t.style.webkitTextFillColor || t.style.color);
822
+ if (color[3] === 0) continue;
823
+ const weight = parseInt(t.style.fontWeight, 10) || 400;
824
+ items.push({
825
+ kind: "text",
826
+ text: t.text,
827
+ what: describe(t.el),
828
+ color,
829
+ opacity: opacityOf(t.el),
830
+ size: t.effective,
831
+ bold: weight >= 700,
832
+ boxes: t.shown.map((f) => ({ left: Math.max(f.glyphs.left, f.visible.left), top: Math.max(f.glyphs.top, f.visible.top), right: Math.min(f.glyphs.right, f.visible.right), bottom: Math.min(f.glyphs.bottom, f.visible.bottom) })),
833
+ });
834
+ }
835
+
836
+ for (const el of document.querySelectorAll(MARKS)) {
837
+ if (el.closest("[hidden], .rhp-scale") || !visible(el)) continue;
838
+ const c = el.closest(".rhp-chart");
839
+ const r = el.getBoundingClientRect();
840
+ const v = clipped(r, el.parentElement, null);
841
+ if (v.empty || r.width * r.height < 4) continue;
842
+ const block = BLOCK[[...el.classList].find((k) => BLOCK[k])];
843
+ const s = getComputedStyle(el);
844
+ let fill = s.backgroundColor;
845
+ if (block === "Area") fill = s.stroke;
846
+ if (block === "Line") fill = getComputedStyle(el.querySelector(".rhp-stroke") ?? el).stroke;
847
+ const color = /^(rgb|color|oklch|oklab|lab|lch|hsl|hwb|#)/.test(fill) ? rgba(fill) : [0, 0, 0, 0];
848
+ // a Bar across the whole scale is a track behind the values, which is meant to be faint
849
+ const plot = el.closest(".rhp-plot");
850
+ const pr = plot?.getBoundingClientRect();
851
+ const vertical = el.dataset.rhpO === "v";
852
+ const track = block === "Bar" && pr && (vertical ? r.height >= pr.height * 0.99 : r.width >= pr.width * 0.99);
853
+ // the labels that name what a mark shows: words (not numbers) in the slat it is drawn in, as the reader sees them
854
+ // (screen-reader text names nothing on screen)
855
+ const slat = el.parentElement?.closest(".rhp-plot > *");
856
+ const names = slat && slat !== el
857
+ ? [...slat.querySelectorAll(".rhp-label")].filter((l) => !l.contains(el) && visible(l)).map(seenText).filter((x) => x && !/^[\s\d.,%+\-\u2212\u2013$\u20ac\u00a3\u00a5:/()kKMmBb]*$/.test(x))
858
+ : [];
859
+ if (slat && !slats.has(slat)) slats.set(slat, slats.size);
860
+ items.push({
861
+ kind: "mark", block, what: describe(el), color, opacity: opacityOf(el), track, names, chart: all().indexOf(c), slat: slat ? slats.get(slat) : null,
862
+ edges: /Area|Line/.test(block) ? [] : edges(s), boxes: [{ left: v.left, top: v.top, right: v.right, bottom: v.bottom }],
863
+ });
864
+ }
865
+
866
+ return items;
867
+ }
868
+
869
+ const all = () => [...document.querySelectorAll(".rhp-chart")];
870
+
871
+ // Hiding the glyphs (for the screenshot of what is behind text) or the marks (for what is behind marks), and
872
+ // putting them back. Inline !important beats rhp's layered rules; transitions this starts are finished at once.
873
+ // Only the properties set here are put back, so what the page changed in the meantime stays.
874
+ let undo = [];
875
+ const sheet = document.createElement("style");
876
+ const set = (el, name, value) => {
877
+ const had = el.hasAttribute("style");
878
+ const was = el.style.getPropertyValue(name);
879
+ const priority = el.style.getPropertyPriority(name);
880
+ el.style.setProperty(name, value, "important");
881
+ undo.push(() => {
882
+ if (was) el.style.setProperty(name, was, priority);
883
+ else el.style.removeProperty(name);
884
+ if (!had && !el.getAttribute("style")) el.removeAttribute("style");
885
+ });
886
+ };
887
+ function hide(what) {
888
+
889
+ show();
890
+
891
+ if (what === "text") {
892
+ // a text's shadow stays: a halo is what the glyphs are read against
893
+ sheet.textContent = "*, *::before, *::after { -webkit-text-stroke-color: transparent !important; text-decoration-color: transparent !important; caret-color: transparent !important; } ::marker { -webkit-text-fill-color: transparent !important; color: transparent !important; }";
894
+ document.head.append(sheet);
895
+ undo.push(() => sheet.remove());
896
+ // on each element that holds text (rhp's chart body resets what it would inherit), and on the root for the rest
897
+ const holders = new Set([document.documentElement, ...document.querySelectorAll(".rhp-body")]);
898
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
899
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
900
+ if (node.data.trim() && node.parentElement) holders.add(node.parentElement);
901
+ }
902
+ for (const el of holders) {
903
+ set(el, "-webkit-text-fill-color", "transparent");
904
+ set(el, "-webkit-text-stroke-color", "transparent");
905
+ set(el, "text-decoration-color", "transparent");
906
+ }
907
+ }
908
+
909
+ if (what === "marks") {
910
+ for (const el of document.querySelectorAll(MARKS)) {
911
+ set(el, "visibility", "hidden");
912
+ }
913
+ }
914
+
915
+ finish();
916
+ }
917
+
918
+ function show() {
919
+
920
+ for (const f of undo.splice(0).reverse()) {
921
+ f();
922
+ }
923
+
924
+ finish();
925
+ }
926
+
927
+ const finish = () => {
928
+ for (const a of document.getAnimations()) {
929
+ if (a instanceof CSSTransition && /text-fill-color|visibility|color|text-shadow|text-decoration/.test(a.transitionProperty)) {
930
+ try {
931
+ a.finish();
932
+ } catch {
933
+ // an animation that can't finish is left to run
934
+ }
935
+ }
936
+ }
937
+ };
938
+
939
+ // The content's box, for the screenshots (what is drawn, not the empty page around it)
940
+ function contentBox() {
941
+
942
+ let left = Infinity;
943
+ let top = Infinity;
944
+ let right = -Infinity;
945
+ let bottom = -Infinity;
946
+
947
+ for (const e of document.body.querySelectorAll("*")) {
948
+ const r = e.getBoundingClientRect();
949
+ if (r.width < 1 || r.height < 1 || !visible(e)) continue;
950
+ left = Math.min(left, r.left);
951
+ top = Math.min(top, r.top);
952
+ right = Math.max(right, r.right);
953
+ bottom = Math.max(bottom, r.bottom);
954
+ }
955
+
956
+ if (!Number.isFinite(left)) return null;
957
+
958
+ return { left: left + scrollX, top: top + scrollY, right: right + scrollX, bottom: bottom + scrollY };
959
+ }
960
+
961
+ // What the interaction pass works on: slats to point at (the first, the middle and the last of the largest chart, and
962
+ // the one whose edge Label is widest: lit, a name that gets bolder can make room: "auto" measure again), buttons and
963
+ // toggles, ranges, selects, a slider for the arrow keys, Plots with keyboard. Controls go by their accessible name.
964
+ function targets() {
965
+
966
+ const list = (window.__rhpTargets = []);
967
+ const keep = (el) => list.push(el) - 1;
968
+ const out = { slats: [], widest: null, buttons: [], ranges: [], selects: [], slider: null, keyboard: null };
969
+ const label = (el) => (nameOf(el) ? `"${snippet(nameOf(el))}"` : describe(el));
970
+
971
+ // the largest chart (a size key drawn as a chart of its own comes before the chart it explains)
972
+ const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
973
+ const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
974
+ // its first Plot with slats that takes the pointer (an overlay of guides or a readout takes none)
975
+ const plots = chart ? [...chart.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale") && [...p.children].some((s) => !s.hidden)) : [];
976
+ const plot = plots.find((p) => getComputedStyle(p).pointerEvents !== "none") ?? plots[0];
977
+ if (plot) {
978
+ const vertical = plot.dataset.rhpO === "v";
979
+ const slats = [...plot.children].filter((s) => !s.hidden && visible(s));
980
+ slats.sort((a, b) => (vertical ? a.getBoundingClientRect().left - b.getBoundingClientRect().left : a.getBoundingClientRect().top - b.getBoundingClientRect().top));
981
+ const range = document.createRange();
982
+ const nameWidth = (s) => Math.max(0, ...[...s.querySelectorAll(".rhp-label[data-rhp-edge]")].map((l) => {
983
+ range.selectNodeContents(l);
984
+ return range.getBoundingClientRect().width;
985
+ }));
986
+ const slatOf = (i) => ({ index: i + 1, of: slats.length, name: snippet([...slats[i].querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? ""), target: keep(slats[i]) });
987
+ const picks = [...new Set([0, Math.floor((slats.length - 1) / 2), slats.length - 1])].filter((i) => slats[i]);
988
+ out.slats = picks.map(slatOf);
989
+ const widths = slats.map(nameWidth);
990
+ const widest = widths.indexOf(Math.max(...widths));
991
+ if (widths[widest] > Math.max(...picks.map((i) => widths[i])) + 0.5) out.widest = slatOf(widest);
992
+ }
993
+
994
+ const usable = (el) => visible(el) && !el.disabled && el.getAttribute("aria-disabled") !== "true";
995
+ const buttons = [...document.querySelectorAll("button, [role=button], [role=switch], [role=tab], [role=radio], [role=checkbox], input[type=checkbox], input[type=radio], summary")]
996
+ .filter((b) => usable(b) && !(b.type === "submit" && b.form) && !b.closest("a[href]"));
997
+ for (const b of buttons.slice(0, 12)) {
998
+ out.buttons.push({ label: label(b), role: b.getAttribute("role") ?? b.type ?? b.localName, target: keep(b) });
999
+ }
1000
+
1001
+ for (const r of [...document.querySelectorAll("input[type=range]")].filter(usable).slice(0, 6)) {
1002
+ const min = parseFloat(r.min || "0");
1003
+ const max = parseFloat(r.max || "100");
1004
+ const now = parseFloat(r.value);
1005
+ out.ranges.push({ label: label(r), value: now <= (min + max) / 2 ? max : min, target: keep(r) });
1006
+ }
1007
+
1008
+ for (const s of [...document.querySelectorAll("select")].filter(usable).slice(0, 6)) {
1009
+ if (s.options.length > 1) out.selects.push({ label: label(s), value: s.options[(s.selectedIndex + 1) % s.options.length].value, target: keep(s) });
1010
+ }
1011
+
1012
+ // a slider of the page's own (role=slider) first: nothing else tries it
1013
+ const slider = [...document.querySelectorAll("[role=slider]"), ...document.querySelectorAll("input[type=range]")].find(usable);
1014
+ if (slider) out.slider = { label: label(slider), target: keep(slider) };
1015
+
1016
+ const focusable = [...document.querySelectorAll(".rhp-plot > [tabindex]")].filter(visible);
1017
+ if (focusable.length) out.keyboard = { count: focusable.length, vertical: focusable[0].parentElement.dataset.rhpO === "v" };
1018
+
1019
+ return out;
1020
+ }
1021
+
1022
+ // A point in the window on a mark of a slat (a target), where a reader would point or tap. The slat is scrolled into
1023
+ // view, and the point is the first of a few that reaches the slat: on its marks (a bubble sits away from the slat's
1024
+ // center), inside an SVG shape in it (a donut's wedge fills a small part of its box), then a Label's center. The
1025
+ // marks with a pointer cursor come first, in the page's order (the one shown at rest is often drawn larger), then the
1026
+ // largest. The first point on a mark is across it (a share of its width: the middle, or one side so that two taps in
1027
+ // a row land on different values), and on a Line it is on the line itself (a sloped line crosses its box). When no
1028
+ // point reaches it and something in the slat takes the pointer, something covers it: { covered } says what (another
1029
+ // slat of its Plot, or an element). When nothing in it takes the pointer (the page reads the pointer around the
1030
+ // chart), the first point on its first mark, or its center.
1031
+ function pointAt(n, across = 0.5) {
1032
+
1033
+ const slat = window.__rhpTargets[n];
1034
+ slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1035
+ const marks = [...slat.querySelectorAll(MARKS + ", .rhp-cell")].filter(visible)
1036
+ .map((m) => ({ m, r: m.getBoundingClientRect(), pointer: getComputedStyle(m).cursor === "pointer" }))
1037
+ .filter(({ r }) => r.width * r.height > 0);
1038
+ marks.sort((a, b) => b.pointer - a.pointer || (a.pointer ? 0 : b.r.width * b.r.height - a.r.width * a.r.height));
1039
+ const steps = [0.1, 0.3, 0.5, 0.7, 0.9];
1040
+ const grid = steps.flatMap((x) => steps.map((y) => [x, y]));
1041
+ const pointsOn = ({ m, r }) => {
1042
+ const line = m.matches(".rhp-line") && m.querySelector(".rhp-stroke");
1043
+ if (line) {
1044
+ const length = line.getTotalLength();
1045
+ return [across, 0.5, 0.1, 0.3, 0.7, 0.9].map((f) => line.getPointAtLength(length * f).matrixTransform(line.getScreenCTM()));
1046
+ }
1047
+ return [[across, 0.5], ...grid].map(([fx, fy]) => ({ x: r.left + r.width * fx, y: r.top + r.height * fy }));
1048
+ };
1049
+ // the points of a finer grid on an SVG shape's box that are inside its fill or on its stroke
1050
+ const inShape = (g) => {
1051
+ const r = g.getBoundingClientRect();
1052
+ const toShape = g.getScreenCTM()?.inverse();
1053
+ if (!toShape || r.width * r.height === 0) return [];
1054
+ const fine = [across, 0.5, 0.2, 0.8, 0.05, 0.35, 0.65, 0.95];
1055
+ return fine.flatMap((fx) => fine.map((fy) => ({ x: r.left + r.width * fx, y: r.top + r.height * fy }))).filter(({ x, y }) => {
1056
+ const p = new DOMPoint(x, y).matrixTransform(toShape);
1057
+ return g.isPointInFill(p) || g.isPointInStroke(p);
1058
+ });
1059
+ };
1060
+ const shapes = [...slat.querySelectorAll("path, circle, ellipse, rect, polygon, polyline, line")].filter((g) => g instanceof SVGGeometryElement && visible(g));
1061
+ const labels = [...slat.querySelectorAll(".rhp-label")].map((l) => l.getBoundingClientRect()).filter((r) => r.width * r.height > 0);
1062
+ const reaches = ({ x, y }) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && slat.contains(document.elementFromPoint(x, y));
1063
+
1064
+ for (const mark of marks.slice(0, 3)) {
1065
+ const hit = pointsOn(mark).find(reaches);
1066
+ if (hit) return hit;
1067
+ }
1068
+ for (const g of shapes.slice(0, 3)) {
1069
+ const hit = inShape(g).find(reaches);
1070
+ if (hit) return hit;
1071
+ }
1072
+ const hit = labels.map((r) => ({ x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 })).find(reaches);
1073
+ if (hit) return hit;
1074
+
1075
+ const r = slat.getBoundingClientRect();
1076
+ const { x, y } = marks.length ? pointsOn(marks[0])[0] : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1077
+ const takes = (e) => getComputedStyle(e).pointerEvents !== "none" && e.checkVisibility({ visibilityProperty: true }) && e.getBoundingClientRect().width * e.getBoundingClientRect().height > 0;
1078
+ if ([slat, ...slat.querySelectorAll("*")].some(takes)) {
1079
+ const on = document.elementFromPoint(x, y);
1080
+ return { covered: on && slat.parentElement.contains(on) && !slat.contains(on) ? "slat" : on ? describe(on) : "nothing in the window" };
1081
+ }
1082
+
1083
+ return { x, y };
1084
+ }
1085
+
1086
+ // Where the controls and the charts are on the page (in page px), before an interaction. moved(n, lit) then says
1087
+ // which of them the interaction moved by more than 2px: a control other than the one it used (a target), or a
1088
+ // chart's top; and when it lit a slat (lit: pointing, a tap, the keyboard, which change no data), the plot inside a
1089
+ // chart whose room "auto" for names changed (a lit name that gets bolder is measured again). A control in a slat
1090
+ // moves with it (rows sorted or removed), so only what it moves apart from its slat counts. What is fixed or sticky
1091
+ // moves with scrolling, so it is left out.
1092
+ let placed = [];
1093
+ const at = (el) => {
1094
+ const r = el.getBoundingClientRect();
1095
+ return { el, x: r.left + scrollX, y: r.top + scrollY };
1096
+ };
1097
+ // The room "auto" gives names, in px: the chart body's first and last grid tracks (columns, or rows when vertical)
1098
+ const gutters = (chart) => {
1099
+ const o = chart.dataset.rhpGutters;
1100
+ const body = chart.querySelector(":scope > .rhp-body");
1101
+ if (!o || !body) return null;
1102
+ const s = getComputedStyle(body);
1103
+ const tracks = (o === "h" ? s.gridTemplateColumns : s.gridTemplateRows).split(/\s+/).map(parseFloat).filter(Number.isFinite);
1104
+ if (tracks.length < 3) return null;
1105
+ return o === "h" ? { o, start: tracks[0], end: tracks.at(-1) } : { o, start: tracks.at(-1), end: tracks[0] };
1106
+ };
1107
+ function place() {
1108
+
1109
+ const pinned = (el) => {
1110
+ for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
1111
+ if (/fixed|sticky/.test(getComputedStyle(a).position)) return true;
1112
+ }
1113
+ return false;
1114
+ };
1115
+ const charts = all().filter((c) => !c.parentElement?.closest(".rhp-chart"));
1116
+ placed = [...document.querySelectorAll(CONTROLS), ...charts].filter((el) => visible(el) && !pinned(el)).map((el) => {
1117
+ const chart = charts.includes(el);
1118
+ const slat = chart ? null : el.closest(".rhp-plot > *");
1119
+ return { ...at(el), chart, slat: slat && at(slat), gutters: chart ? gutters(el) : null };
1120
+ });
1121
+ }
1122
+
1123
+ function moved(n, lit = false) {
1124
+
1125
+ const used = window.__rhpTargets[n];
1126
+ const out = [];
1127
+ const shift = (p) => {
1128
+ const now = at(p.el);
1129
+ return { dx: now.x - p.x, dy: now.y - p.y };
1130
+ };
1131
+ const far = (v) => Math.abs(v) > 2;
1132
+
1133
+ for (const p of placed) {
1134
+ if (p.el === used || !p.el.isConnected || !visible(p.el)) continue;
1135
+ const d = shift(p);
1136
+ const by = p.slat?.el.isConnected ? shift(p.slat) : { dx: 0, dy: 0 };
1137
+ const dx = p.chart ? 0 : Math.round(d.dx - by.dx);
1138
+ const dy = Math.round(d.dy - by.dy);
1139
+ if (far(dx) || far(dy)) {
1140
+ const name = p.chart ? "" : snippet(nameOf(p.el));
1141
+ out.push({ what: p.chart ? "the chart" : name ? `"${name}"` : describe(p.el), dx, dy });
1142
+ }
1143
+ // the plot's edge at a side whose room for names grew or shrank
1144
+ const now = lit && p.gutters ? gutters(p.el) : null;
1145
+ if (!now || now.o !== p.gutters.o) continue;
1146
+ const start = Math.round(now.start - p.gutters.start);
1147
+ const end = Math.round(now.end - p.gutters.end);
1148
+ if (now.o === "h" && far(start)) out.push({ what: "the plot's left edge", dx: start, dy: 0, plot: true });
1149
+ if (now.o === "h" && far(end)) out.push({ what: "the plot's right edge", dx: -end, dy: 0, plot: true });
1150
+ if (now.o === "v" && far(start)) out.push({ what: "the plot's bottom edge", dx: 0, dy: -start, plot: true });
1151
+ if (now.o === "v" && far(end)) out.push({ what: "the plot's top edge", dx: 0, dy: end, plot: true });
1152
+ }
1153
+
1154
+ return out;
1155
+ }
1156
+
1157
+ // A change recorder for one interaction: what changed in the DOM (text, attributes, inline styles, elements), and,
1158
+ // when it is a tap (texts), the texts it wrote and the texts shown before it (kept until the next interaction, for
1159
+ // unseen())
1160
+ let recorder = null;
1161
+ let wrote = new Set();
1162
+ let before = null;
1163
+ function record(keepTexts = false) {
1164
+
1165
+ const seen = { text: 0, attributes: 0, styles: 0, elements: 0 };
1166
+ wrote = new Set();
1167
+ before = keepTexts ? new Set(texts().filter((t) => t.shown.length).map((t) => t.node)) : null;
1168
+ const count = (m) => {
1169
+ if (m.type === "characterData") {
1170
+ seen.text++;
1171
+ wrote.add(m.target);
1172
+ } else if (m.type === "childList") {
1173
+ seen.elements += m.addedNodes.length + m.removedNodes.length;
1174
+ } else if (m.attributeName === "style") {
1175
+ seen.styles++;
1176
+ } else {
1177
+ seen.attributes++;
1178
+ }
1179
+ };
1180
+ recorder = new MutationObserver((list) => list.forEach(count));
1181
+ recorder.observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true });
1182
+ recorder.seen = seen;
1183
+ recorder.count = count;
1184
+ }
1185
+
1186
+ function stop() {
1187
+
1188
+ if (!recorder) return null;
1189
+
1190
+ recorder.takeRecords().forEach(recorder.count);
1191
+ recorder.disconnect();
1192
+ const seen = recorder.seen;
1193
+ recorder = null;
1194
+
1195
+ return seen;
1196
+ }
1197
+
1198
+ // After a tap on a slat (a target) that is in the window: when the tap changed text the reader can see (wrote it, or
1199
+ // showed it), but all of that text lies outside the window, the nearest of it and how far it is; else null. A style
1200
+ // the tap changed in view (the tapped bar lit) does not say what a readout out of view says.
1201
+ function unseen(n) {
1202
+
1203
+ const slat = window.__rhpTargets[n];
1204
+ const inView = (r) => r.right > 0 && r.bottom > 0 && r.left < innerWidth && r.top < innerHeight;
1205
+ if (!before || !slat?.isConnected || !inView(slat.getBoundingClientRect())) return null;
1206
+
1207
+ let nearest = null;
1208
+
1209
+ for (const t of texts()) {
1210
+ if (before.has(t.node) && !wrote.has(t.node)) continue;
1211
+ for (const { visible: r } of t.shown) {
1212
+ if (inView(r)) return null;
1213
+ const away = [[-r.bottom, "above"], [r.top - innerHeight, "below"], [-r.right, "left of"], [r.left - innerWidth, "right of"]].find(([by]) => by >= 0);
1214
+ if (away && (!nearest || away[0] < nearest.by)) nearest = { what: `"${t.text}"`, by: Math.round(away[0]), side: away[1] };
1215
+ }
1216
+ }
1217
+
1218
+ return nearest;
1219
+ }
1220
+
1221
+ // The name of a focused element: a slat's (its Label's text) or a control's
1222
+ const focusName = (el) => {
1223
+ const plot = el.parentElement?.classList.contains("rhp-plot") ? el.parentElement : null;
1224
+ if (plot) {
1225
+ const name = [...el.querySelectorAll(".rhp-label")].map(seenText).find((x) => x);
1226
+ return `slat ${[...plot.children].indexOf(el) + 1}${name ? ` (${snippet(name)})` : ""}`;
1227
+ }
1228
+ const role = el.getAttribute("role") ?? (el.localName === "a" ? "link" : el.localName === "input" ? el.type : el.localName);
1229
+ const name = nameOf(el) || (el.localName === "a" ? seenText(el) : "");
1230
+ return name ? `${role} "${snippet(name)}"` : describe(el);
1231
+ };
1232
+
1233
+ // Where the focus is: which slat of which Plot, if it is on one
1234
+ function focused() {
1235
+
1236
+ const el = document.activeElement;
1237
+ if (!el || el === document.body) return null;
1238
+ const plot = el.parentElement?.classList.contains("rhp-plot") ? el.parentElement : null;
1239
+ if (!plot) return { slat: false };
1240
+
1241
+ return { slat: true, index: [...plot.children].indexOf(el) + 1, name: snippet([...el.querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? "") };
1242
+ }
1243
+
1244
+ // A stop of Tab through the page, for the focus check: the focused element's name, and its box in the window with
1245
+ // room around it for an outline (none when it has no box in the window); again when Tab came back to an element it
1246
+ // reached before; null when nothing has focus. focusStop(true) starts a new walk.
1247
+ let stops = new Set();
1248
+ function focusStop(fresh = false) {
1249
+
1250
+ if (fresh) stops = new Set();
1251
+ const el = document.activeElement;
1252
+ if (!el || el === document.body || el === document.documentElement) return null;
1253
+ if (stops.has(el)) return { again: true };
1254
+ stops.add(el);
1255
+
1256
+ const room = 12;
1257
+ const r = el.getBoundingClientRect();
1258
+ const left = Math.max(0, Math.floor(r.left - room));
1259
+ const top = Math.max(0, Math.floor(r.top - room));
1260
+ const right = Math.min(innerWidth, Math.ceil(r.right + room));
1261
+ const bottom = Math.min(innerHeight, Math.ceil(r.bottom + room));
1262
+ const box = r.width * r.height > 0 && right - left >= 1 && bottom - top >= 1 ? { x: left, y: top, width: right - left, height: bottom - top } : null;
1263
+
1264
+ return { what: focusName(el), clip: box };
1265
+ }
1266
+
1267
+ window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, pointAt, place, moved, record, stop, unseen, focused, focusStop };
1268
+ }