@bezda/rhp-mcp 0.1.6 → 0.1.8

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.
@@ -27,8 +27,10 @@
27
27
  // findings [{ level: "error" | "warning" | "info", code, message, fix, width, widths }], errors first
28
28
  // charts [{ width, slats, blocks: { bar: 3, label: 6 }, box: { width, height } }]
29
29
  // interactions [{ width, kind, target, changed, how, error, moved, unseen }]
30
- // swept [{ width, breakpoint, slats, jumps }]: the widths at which every slat was picked in turn (each width
31
- // checked, and the page's own breakpoints between them), how many slats, and how many moved the layout
30
+ // swept [{ width, breakpoint, slats, reached, jumps, scrubbed }]: the widths at which every slat was picked in
31
+ // turn (each width checked, and the page's own breakpoints between them), how many slats, how many the
32
+ // pointer could reach, how many moved the layout, and when the pointer was moved along the chart
33
+ // instead, scrubbed: { points, moved: how many of its steps moved something }
32
34
  // screenshots [{ width, dark, hover, path }]
33
35
  // text the report as plain text (what the CLI prints)
34
36
  import fs from "fs";
@@ -39,7 +41,7 @@ import { pathToFileURL, fileURLToPath } from "url";
39
41
  import { detectFormat, bundle, guardedStandalone, htmlProblems, packages, contentType } from "./build.js";
40
42
  import { launch, NO_BROWSER_FIX } from "./browser.js";
41
43
  import { install } from "./inpage.js";
42
- import { fromProbe, fixForError, placeOf } from "./findings.js";
44
+ import { fromProbe, fixForError, placeOf, SEE } from "./findings.js";
43
45
  import { textContrast, faintMarks, colorBlind } from "./colors.js";
44
46
  import { decode } from "./png.js";
45
47
  import { interact as interactionPass } from "./interact.js";
@@ -94,17 +96,28 @@ function source({ file, code, format }) {
94
96
  // Picks every slat of the page's main Plot in turn, as a reader going from one to the next (a tap on a touch screen,
95
97
  // the mouse elsewhere), on a page at rest with reduced motion asked for, where a pick shows at once; and lists what moved
96
98
  // outside the plot (moved() in inpage.js): a readout that takes another line for one slat, at one width. What a pick
97
- // throws is tagged with it in events, as the interaction pass does.
99
+ // throws is tagged with it in events, as the interaction pass does. When the main chart draws a line or an area, a
100
+ // layer over the plot takes the pointer (the slats cannot be pointed at) or the Plot has one or two slats (a line per
101
+ // series), the reader moves along the chart, and the sweep scrubs it first (scrub()). { slats, reached: the slats it
102
+ // could point at, jumps, scrub }
98
103
  async function sweep(page, touch, events) {
99
104
 
100
105
  const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets());
101
106
  const jumps = [];
102
- if (!touch) await page.mouse.move(0, 0);
103
- await page.evaluate(() => window.__rhpProbe.place());
107
+ await page.mouse.move(0, 0);
108
+ await page.evaluate(() => {
109
+ window.__rhpProbe.place();
110
+ window.__rhpProbe.placeLines();
111
+ window.__rhpProbe.watch(true);
112
+ });
113
+ const first = slats.length ? await page.evaluate((n) => window.__rhpProbe.pointAt(n), slats[0].target).catch(() => null) : null;
114
+ const scrubbed = slats.length ? await scrub(page, events, slats.length <= 2 || !!first?.covered) : null;
115
+ let reached = 0;
104
116
  for (const s of slats) {
105
117
  // (a slat that something covers has no point: pointing there would pick what covers it)
106
118
  const point = await page.evaluate((n) => window.__rhpProbe.pointAt(n), s.target).catch(() => null);
107
119
  if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
120
+ reached++;
108
121
  const name = `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
109
122
  const before = events.length;
110
123
  try {
@@ -120,8 +133,92 @@ async function sweep(page, touch, events) {
120
133
  const moved = await page.evaluate((n) => window.__rhpProbe.moved(n, true), s.target).catch(() => []);
121
134
  if (moved.length) jumps.push({ target: name, moved });
122
135
  }
136
+ await page.evaluate(() => window.__rhpProbe.watch(false)).catch(() => {});
123
137
  if (!touch) await page.mouse.move(0, 0);
124
- return { slats: slats.length, jumps };
138
+ return { slats: slats.length, reached, jumps, scrub: scrubbed };
139
+ }
140
+
141
+ // Moves the mouse along the main chart in small steps (scrubPath() in inpage.js, which says when), as a reader reading
142
+ // a chart that follows the pointer across its plot (a line read day by day), at every width: a finger dragging along a phone's plot
143
+ // shows the same readout. Each step is measured against the page at rest (place() and placeLines() before it): the
144
+ // layout, as for a pick (moved()), and the lines of text around the charts (slides()), which jitter when a label
145
+ // slides as the value before it changes width, or a readout takes another line on some days.
146
+ // { points, steps: [what moved at each step that moved anything] }
147
+ async function scrub(page, events, force) {
148
+
149
+ const path = await page.evaluate((f) => window.__rhpProbe.scrubPath(f), force).catch(() => null);
150
+ if (!path?.points.length) return null;
151
+ const start = events.length;
152
+ const steps = [];
153
+ let said = null;
154
+ let changes = 0;
155
+ try {
156
+ for (const p of path.points) {
157
+ await page.mouse.move(p.x, p.y);
158
+ await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
159
+ const [moved, text] = await page.evaluate((n) => [[...window.__rhpProbe.moved(n, true).filter((m) => !m.text), ...window.__rhpProbe.slides()], window.__rhpProbe.state().text], path.target).catch(() => [[], said]);
160
+ if (said !== null && text !== said) changes++;
161
+ said = text;
162
+ if (moved.length) steps.push(moved);
163
+ }
164
+ } finally {
165
+ await page.mouse.move(0, 0).catch(() => {});
166
+ for (const e of events.slice(start)) {
167
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= "moving the pointer along the chart";
168
+ }
169
+ }
170
+ // (a readout that changes at three steps in four or more follows the pointer along the chart: a sentence that slides
171
+ // there jitters; one that changes less often changes once per mark, and only what a pick counts counts)
172
+ const continuous = changes >= 0.75 * (path.points.length - 1);
173
+ const kept = steps.map((moved) => (continuous ? moved : moved.filter((m) => !m.strict))).filter((moved) => moved.length);
174
+ return { points: path.points.length, continuous, steps: kept };
175
+ }
176
+
177
+ // Moves the mouse from a slat to its neighbor in small steps (three pairs of the main Plot: the first, the middle and
178
+ // the last), on a page at rest with reduced motion asked for, and notes each pair where the page, between two slats
179
+ // that each show something, goes back for a moment to what it shows with the pointer away: the readout, or the lit
180
+ // slat, flickers in the gap.
181
+ async function glide(page, events) {
182
+
183
+ const start = events.length;
184
+ const pairs = await page.evaluate(() => window.__rhpProbe.neighbors()).catch(() => []);
185
+ if (!pairs.length) return [];
186
+ const frame = () => page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
187
+ const state = () => page.evaluate(() => window.__rhpProbe.state());
188
+ const name = (s) => `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
189
+ const out = [];
190
+ try {
191
+ await page.mouse.move(0, 0);
192
+ await frame();
193
+ const rest = await state();
194
+ for (const [a, b] of pairs) {
195
+ // (from the middle of one slat's largest mark to the middle of the next one's: what each slat shows of its own)
196
+ const p = await page.evaluate((n) => window.__rhpProbe.markCenter(n), a.target).catch(() => null);
197
+ const q = await page.evaluate((n) => window.__rhpProbe.markCenter(n, false), b.target).catch(() => null);
198
+ if (![p?.x, p?.y, q?.x, q?.y].every(Number.isFinite)) continue;
199
+ await page.mouse.move(p.x, p.y);
200
+ await frame();
201
+ const from = await state();
202
+ const seen = [];
203
+ for (let k = 1; k <= 12; k++) {
204
+ await page.mouse.move(p.x + ((q.x - p.x) * k) / 12, p.y + ((q.y - p.y) * k) / 12);
205
+ await frame();
206
+ seen.push(await state());
207
+ }
208
+ const to = seen.at(-1);
209
+ // The text, or the look of the slats, blinks when both slats show something other than the rest in it, and a
210
+ // step between them shows the rest (a slat the page lights at rest shows the rest of its own, and does not blink)
211
+ const blinks = (key) => from[key] !== rest[key] && to[key] !== rest[key] && seen.slice(0, -1).some((s) => s[key] === rest[key]);
212
+ const readout = blinks("text");
213
+ if (readout || blinks("look")) out.push({ from: name(a), to: name(b), readout });
214
+ }
215
+ } finally {
216
+ await page.mouse.move(0, 0).catch(() => {});
217
+ for (const e of events.slice(start)) {
218
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= "moving the mouse from a slat to its neighbor";
219
+ }
220
+ }
221
+ return out;
125
222
  }
126
223
 
127
224
  export async function check(options = {}) {
@@ -374,8 +471,17 @@ export async function check(options = {}) {
374
471
  // Every slat picked in turn, here and at the page's own breakpoints between this width and the next narrower
375
472
  // one checked (a page of its own at each), where a layout that only fits at the widths checked jumps
376
473
  const swept = [];
474
+ let flicker = [];
475
+ let away = null;
377
476
  if (wantInteract) {
378
477
  swept.push({ width, ...(await sweep(page, phone, events)) });
478
+ // what a reader scrolling through a tall chart loses sight of, and (with a mouse) a readout that flickers between slats
479
+ const scrolled = events.length;
480
+ away = await page.evaluate((h) => window.__rhpProbe.scrollsAway(h), phone ? PHONE_WINDOW : 900).catch(() => null);
481
+ for (const e of events.slice(scrolled)) {
482
+ if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= "scrolling through the chart";
483
+ }
484
+ if (!phone && i === 0) flicker = await glide(page, events);
379
485
  const next = [...widths].sort((a, b) => b - a).find((w) => w < width);
380
486
  const between = next == null ? [] : (await page.evaluate(() => window.__rhpProbe.breakpoints())).filter((b) => b < width && b > next && !widths.includes(b));
381
487
  for (const b of between.slice(0, MAX_BREAKPOINTS)) {
@@ -427,7 +533,7 @@ export async function check(options = {}) {
427
533
  guarded.push(...(await fresh.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
428
534
  }
429
535
 
430
- results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, selection, swept, ms: Math.round(performance.now() - w0) });
536
+ results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, selection, swept, flicker, away, ms: Math.round(performance.now() - w0) });
431
537
  } catch (error) {
432
538
  if (error.code !== "page-unsettled") throw error;
433
539
  findings.push({ level: "error", code: error.code, message: error.message, fix: "Let finite entry animations finish, honor prefers-reduced-motion for live updates, or increase the check's settleTimeout for a slow machine.", width });
@@ -493,23 +599,58 @@ export async function check(options = {}) {
493
599
  findings.push({ ...f, width: r.width });
494
600
  }
495
601
 
496
- // the layout jumping when an interaction runs: the same things moving for several interactions is one finding
602
+ // the layout jumping when an interaction runs: the same things moving for several interactions is one finding.
603
+ // Texts that moved with what jumped (by the same amount) are that jump; the others are one text-shift finding for
604
+ // the width, with each text at the most it moved, or, as the pointer moves along the chart, one jitter finding.
497
605
  const jumps = new Map();
606
+ const texted = { "text-shift": { found: null, by: new Map() }, jitter: { found: null, by: new Map() } };
607
+ const add = (kind, target, moved, code = "text-shift") => {
608
+ const other = moved.filter((m) => !m.text);
609
+ const texts = moved.filter((m) => m.text && !other.some((o) => Math.abs(o.dx - m.dx) <= 1 && Math.abs(o.dy - m.dy) <= 1));
610
+ if (other.length) {
611
+ const key = other.map((m) => m.what).join("\n");
612
+ if (jumps.has(key)) jumps.get(key).more++;
613
+ else jumps.set(key, { code: "layout-jump", kind, target, moved: other, more: 0 });
614
+ }
615
+ if (!texts.length) return;
616
+ const t = texted[code];
617
+ if (t.found) t.found.more++;
618
+ else t.found = { code, kind, target, more: 0 };
619
+ for (const m of texts) {
620
+ const was = t.by.get(m.what);
621
+ if (!was || Math.abs(m.dx) + Math.abs(m.dy) > Math.abs(was.dx) + Math.abs(was.dy)) t.by.set(m.what, m);
622
+ }
623
+ };
498
624
  for (const x of r.interactions.filter((x) => x.moved?.length)) {
499
- const key = x.moved.map((m) => m.what).join("\n");
500
- if (jumps.has(key)) jumps.get(key).more++;
501
- else jumps.set(key, { code: "layout-jump", kind: x.kind, target: x.target, moved: x.moved, more: 0 });
625
+ add(x.kind, x.target, x.moved);
502
626
  }
503
627
  for (const sw of r.swept) {
628
+ const beside = sw.breakpoint ? ` at ${sw.width}px, beside one of the page's own breakpoints` : "";
629
+ for (const moved of sw.scrub?.steps ?? []) {
630
+ add("scrub", `the chart${beside}`, moved, "jitter");
631
+ }
504
632
  for (const j of sw.jumps) {
505
- const key = j.moved.map((m) => m.what).join("\n");
506
- if (jumps.has(key)) jumps.get(key).more++;
507
- else jumps.set(key, { code: "layout-jump", kind: "pick", target: sw.breakpoint ? `${j.target} at ${sw.width}px, beside one of the page's own breakpoints` : j.target, moved: j.moved, more: 0 });
633
+ add("pick", `${j.target}${beside}`, j.moved);
508
634
  }
509
635
  }
510
636
  for (const j of jumps.values()) {
511
637
  findings.push({ ...fromProbe(j, {}), width: r.width });
512
638
  }
639
+ // (a text the scrub found jittering is that finding: a pick that moved it is one of the steps along the chart)
640
+ for (const what of texted.jitter.by.keys()) {
641
+ texted["text-shift"].by.delete(what);
642
+ }
643
+ for (const { found, by } of Object.values(texted)) {
644
+ if (!found || !by.size) continue;
645
+ const moved = [...by.values()].sort((a, b) => Math.abs(b.dx) + Math.abs(b.dy) - Math.abs(a.dx) - Math.abs(a.dy));
646
+ findings.push({ ...fromProbe({ ...found, moved }, {}), width: r.width });
647
+ }
648
+
649
+ // a readout or a lit slat that goes back to how it is at rest between two slats, as the mouse goes from one to the next
650
+ if (r.flicker.length) findings.push({ ...fromProbe({ code: "flicker", ...r.flicker[0], more: r.flicker.length - 1 }, {}), width: r.width });
651
+
652
+ // the controls or the readout out of view while the reader is in the middle of a tall chart
653
+ if (r.away) findings.push({ ...fromProbe({ code: "scrolls-away", ...r.away, phone: r.width <= PHONE }, {}), width: r.width });
513
654
 
514
655
  // taps whose every change of text lies outside the window: one finding, on the last of them (the furthest slat)
515
656
  const unseen = r.interactions.filter((x) => x.unseen);
@@ -526,7 +667,7 @@ export async function check(options = {}) {
526
667
  const charts = results.flatMap((r) => r.seen.charts.map((c) => ({ width: r.width, ...c })));
527
668
  const selectionAudits = results.flatMap((r) => r.selection.tested ? [{ width: r.width, ...r.selection.tested }] : []);
528
669
  const interactions = results.flatMap((r) => r.interactions);
529
- const swept = results.flatMap((r) => r.swept.map(({ width, breakpoint, slats, jumps }) => ({ width, breakpoint: !!breakpoint, slats, jumps: jumps.length }))).sort((a, b) => b.width - a.width);
670
+ const swept = results.flatMap((r) => r.swept.map(({ width, breakpoint, slats, reached, jumps, scrub }) => ({ width, breakpoint: !!breakpoint, slats, reached, jumps: jumps.length, scrubbed: scrub ? { points: scrub.points, moved: scrub.steps.length } : null }))).sort((a, b) => b.width - a.width);
530
671
  timings.widths = Object.fromEntries(results.map((r) => [r.width, r.ms]));
531
672
 
532
673
  return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, selectionAudits, swept, screenshots, timings, started });
@@ -544,7 +685,7 @@ function finish({ findings, started, timings, ...rest }) {
544
685
  if (f.width != null && !had.widths.includes(f.width)) had.widths.push(f.width);
545
686
  continue;
546
687
  }
547
- merged.set(key, { level: f.level, code: f.code, message: f.message, fix: f.fix, width: f.width ?? null, widths: f.width != null ? [f.width] : [] });
688
+ merged.set(key, { level: f.level, code: f.code, message: f.message, fix: f.fix, see: SEE[f.code] ?? null, width: f.width ?? null, widths: f.width != null ? [f.width] : [] });
548
689
  }
549
690
 
550
691
  const list = [...merged.values()].sort((a, b) => LEVELS[a.level] - LEVELS[b.level]);
@@ -654,16 +654,29 @@ export function install() {
654
654
  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 });
655
655
  }
656
656
 
657
- // Page CSS that reaches into a chart and sets what rhp's own CSS sets there: it loses, so it changes nothing
657
+ // Page CSS that reaches into a chart and sets what rhp's own CSS sets there: it loses, so it changes nothing. rhp's
658
+ // rules are the ones it adopts and, on a page that links rhp.css (linkedCss), the ones in rhp's layers there.
659
+ const pageSheets = [...document.styleSheets].filter((s) => !s.ownerNode?.matches?.("[data-rhp-layers], [data-rhp-server]"));
660
+ const pageRules = styleRules(pageSheets);
661
+ const rhpRules = [...own, ...pageRules.filter((r) => /^rhp(\.|$)/.test(r.layer))];
658
662
  const byProp = new Map();
659
- for (const r of own) {
663
+ for (const r of rhpRules) {
660
664
  for (const p of r.props) {
661
665
  if (!byProp.has(p)) byProp.set(p, []);
662
666
  byProp.get(p).push(r.selector);
663
667
  }
664
668
  }
665
- const pageSheets = [...document.styleSheets].filter((s) => !s.ownerNode?.matches?.("[data-rhp-layers], [data-rhp-server]"));
666
- for (const r of styleRules(pageSheets)) {
669
+ // A chart without the guard (noCssGuard): page CSS reaches what rhp doesn't set itself on the elements rhp makes.
670
+ // The guard is the rhp rule with display: revert; once a chart says noCssGuard it applies only to charts that don't,
671
+ // and with noCssGuard() there is none.
672
+ const guardRule = rhpRules.find((r) => r.rule.style.getPropertyValue("display") === "revert" && r.selector.includes(".rhp-body"));
673
+ const RHP_MADE = ".rhp-body, .rhp-axis, .rhp-gridline, .rhp-gridline > span, .rhp-plot, .rhp-plot > *, .rhp-bar, .rhp-dot, .rhp-tick, .rhp-label, .rhp-cell, .rhp-place, .rhp-area, .rhp-area path, .rhp-line, .rhp-line path";
674
+ const unguarded = (el) => matches(el, RHP_MADE) && (!guardRule || !matches(el, guardRule.selector));
675
+ // what the guard resets (display, float and box-sizing move nothing there), and values that change nothing
676
+ const GUARDED = /^(margin|padding|border|outline|background|box-shadow|overflow|min-width|min-height|max-width|max-height|width$|height$|vertical-align|transition|animation|text-decoration|user-select|touch-action|font|line-height|letter-spacing|word-spacing|text-transform|text-indent|text-align|text-shadow|color$|white-space|text-wrap|word-break|overflow-wrap|hyphens|line-break)/;
677
+ const harmless = (v) => /^(0|0px|0%|0s|none|auto|visible|baseline|normal|initial|unset|inherit|revert|revert-layer|transparent|currentcolor|rgba\(0, 0, 0, 0\))$/i.test(v.trim());
678
+ const family = (p) => p.match(/^(background|border|margin|padding|font|outline|text-decoration|transition|animation|inset|overflow|grid|flex|columns?|list-style)/)?.[1] ?? p;
679
+ for (const r of pageRules) {
667
680
  if (/^rhp(\.|$)/.test(r.layer) || !r.props.length) continue;
668
681
  let matched = [];
669
682
  try {
@@ -673,16 +686,51 @@ export function install() {
673
686
  }
674
687
  const reached = matched.filter((el) => el.closest(".rhp-chart")).slice(0, 60);
675
688
  if (!reached.length) continue;
689
+ const loose = reached.filter(unguarded);
690
+ const live = r.props.filter((p) => GUARDED.test(p) && !harmless(r.rule.style.getPropertyValue(p)) && loose.some((el) => !(byProp.get(p) ?? []).some((sel) => matches(el, sel))));
691
+ if (live.length) {
692
+ const first = loose.find((el) => live.some((p) => !(byProp.get(p) ?? []).some((sel) => matches(el, sel))));
693
+ add({ code: "page-css-reaches", selector: snippet(r.selector), props: [...new Set(live.map(family))], what: first.matches(".rhp-gridline > span") ? "an axis number" : describe(first), count: loose.length });
694
+ }
676
695
  // 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
677
696
  // subject that reaches only elements in charts (a reset such as svg { display: block } is not aimed at it)
678
697
  const subjects = r.selector.split(/,(?![^(]*\))/).map((x) => x.trim().split(/\s*[\s>+~]\s*(?![^(]*\))/).pop());
679
698
  if (!/\.rhp-/.test(r.selector) && !(reached.length === matched.length && subjects.every((x) => /[.#[]/.test(x)))) continue;
680
699
  const dead = r.props.filter((p) => byProp.has(p) && reached.every((el) => byProp.get(p).some((sel) => matches(el, sel))));
681
700
  if (!dead.length) continue;
682
- const family = (p) => p.match(/^(background|border|margin|padding|font|outline|text-decoration|transition|animation|inset|overflow|grid|flex|columns?|list-style)/)?.[1] ?? p;
683
701
  add({ code: "page-css-ignored", selector: snippet(r.selector), props: [...new Set(dead.map(family))], what: describe(reached[0]), count: reached.length });
684
702
  }
685
703
 
704
+ // The browser's own styles for a slat root's element (a button's border, a p's margins, an svg's overflow), which the
705
+ // guard resets: in a chart without it they stay. What no rule (rhp's, a slat's, the page's) and no style attribute
706
+ // sets on the root, and differs from what the guard gives, comes from the browser.
707
+ const setByRule = (el, p) => el.style.getPropertyValue(p) !== "" || (byProp.get(p) ?? []).some((sel) => matches(el, sel))
708
+ || pageRules.some((r) => r.props.includes(p) && matches(el, r.selector));
709
+ const defaults = new Map();
710
+ for (const el of document.querySelectorAll(".rhp-chart .rhp-plot > *")) {
711
+ if (!unguarded(el)) continue;
712
+ const s = getComputedStyle(el);
713
+ const up = getComputedStyle(el.parentElement);
714
+ const sides = ["top", "right", "bottom", "left"];
715
+ const off = [
716
+ ...sides.map((d) => [`margin-${d}`, s.getPropertyValue(`margin-${d}`) !== "0px"]),
717
+ ...sides.map((d) => [`padding-${d}`, s.getPropertyValue(`padding-${d}`) !== "0px"]),
718
+ ...sides.map((d) => [`border-${d}-width`, s.getPropertyValue(`border-${d}-style`) !== "none" && s.getPropertyValue(`border-${d}-width`) !== "0px"]),
719
+ ["text-decoration-line", s.getPropertyValue("text-decoration-line") !== "none"],
720
+ ["background-color", s.getPropertyValue("background-color") !== "rgba(0, 0, 0, 0)"],
721
+ ["overflow-x", el.localName === "svg" && s.getPropertyValue("overflow-x") !== "visible"],
722
+ ...["font-size", "font-weight", "font-family", "color"].map((p) => [p, s.getPropertyValue(p) !== up.getPropertyValue(p)]),
723
+ ].filter(([p, differs]) => differs && !setByRule(el, p)).map(([p]) => family(p.replace(/^border-\w+-width$/, "border").replace(/^overflow-x$/, "overflow")));
724
+ if (!off.length) continue;
725
+ const seen = defaults.get(el.localName) ?? { props: new Set(), count: 0, el };
726
+ off.forEach((p) => seen.props.add(p));
727
+ seen.count++;
728
+ defaults.set(el.localName, seen);
729
+ }
730
+ for (const [tag, d] of defaults) {
731
+ add({ code: "page-css-reaches", browser: tag, props: [...d.props], what: describe(d.el), count: d.count });
732
+ }
733
+
686
734
  // Text too small to read
687
735
  let tiny = null;
688
736
  let tinyCount = 0;
@@ -1188,8 +1236,56 @@ export function install() {
1188
1236
  while (a.parentElement && a.parentElement !== document.body && a.parentElement !== document.documentElement) a = a.parentElement;
1189
1237
  return a;
1190
1238
  }))].map((el) => ({ el, end: el.getBoundingClientRect().bottom + scrollY }));
1239
+ // the texts around the charts, each placed in the box it floats in (see words), and the boxes with a background or
1240
+ // a border around them that hold no chart (a readout's card, a legend's panel), with their size
1241
+ words = [];
1242
+ boxes = [];
1243
+ for (const { el: root } of sections) {
1244
+ for (const el of [root, ...root.querySelectorAll("*")]) {
1245
+ if (words.length >= 600) break;
1246
+ if (el.closest(".rhp-chart") || el.closest(CONTROLS) || !visible(el) || (viewRoot && !viewRoot.contains(el))) continue;
1247
+ const text = ownText(el);
1248
+ if (text && el.getBoundingClientRect().width > 0) {
1249
+ const frame = frameOf(el);
1250
+ words.push({ ...within(el, frame), text, frame });
1251
+ }
1252
+ if (boxes.length < 60 && framed(el) && !el.querySelector(".rhp-chart") && !/fixed|absolute/.test(getComputedStyle(el).position)) {
1253
+ const r = el.getBoundingClientRect();
1254
+ const scale = scaleOf(el);
1255
+ 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)) });
1256
+ }
1257
+ }
1258
+ }
1191
1259
  }
1192
1260
 
1261
+ // A text is placed in the box it floats in when it floats (fixed, sticky or absolute: a readout pinned to the
1262
+ // window, a tooltip that follows the pointer), else on the page: the box may move as a whole, and only a move inside
1263
+ // it counts. Its own text is its text nodes', not its children's, so a label beside a value that changed keeps its own.
1264
+ let words = [];
1265
+ let boxes = [];
1266
+ const frameOf = (el) => {
1267
+ for (let a = el; a && a.nodeType === 1 && a !== document.body; a = a.parentElement) {
1268
+ if (/fixed|sticky|absolute/.test(getComputedStyle(a).position)) return a;
1269
+ }
1270
+ return null;
1271
+ };
1272
+ const ownText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.data).join(" ").replace(/\s+/g, " ").trim();
1273
+ const scaleOf = (el) => (viewRoot?.contains(el) ? viewRoot.getBoundingClientRect().width / viewRoot.offsetWidth : 1);
1274
+ const within = (el, frame) => {
1275
+ if (!frame) return at(el);
1276
+ const r = el.getBoundingClientRect();
1277
+ const f = frame.getBoundingClientRect();
1278
+ const scale = scaleOf(el);
1279
+ return { el, x: (r.left - f.left) / scale, y: (r.top - f.top) / scale };
1280
+ };
1281
+ // a box the reader sees the edges of: a background, a border or a shadow
1282
+ const framed = (el) => {
1283
+ const s = getComputedStyle(el);
1284
+ const paint = (c) => c && c !== "transparent" && !/rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
1285
+ const border = ["Top", "Right", "Bottom", "Left"].some((side) => parseFloat(s[`border${side}Width`]) >= 1 && paint(s[`border${side}Color`]) && s[`border${side}Style`] !== "none");
1286
+ return paint(s.backgroundColor) || border || s.boxShadow !== "none";
1287
+ };
1288
+
1193
1289
  function moved(n, lit = false) {
1194
1290
 
1195
1291
  const used = window.__rhpTargets[n];
@@ -1226,6 +1322,28 @@ export function install() {
1226
1322
  const grew = Math.round(el.getBoundingClientRect().bottom + scrollY - end);
1227
1323
  if (el.isConnected && far(grew)) out.push({ what: "the end of the chart's section", dx: 0, dy: grew });
1228
1324
  }
1325
+ // lighting a slat leaves a readout's card or a legend's panel its size: one that grows or shrinks with what it says
1326
+ // shows its edges jumping
1327
+ for (const b of lit ? boxes : []) {
1328
+ if (!b.el.isConnected || !visible(b.el)) continue;
1329
+ const r = b.el.getBoundingClientRect();
1330
+ const scale = scaleOf(b.el);
1331
+ const dw = Math.round(r.width / scale - b.w);
1332
+ const dh = Math.round(r.height / scale - b.h);
1333
+ 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 });
1334
+ }
1335
+ // a text that still says the same thing stays where it was in its box: one that moves because a value beside it got
1336
+ // wider, or because a line above it wrapped, makes the reader lose their place (text-shift)
1337
+ for (const w of words) {
1338
+ if (!w.el.isConnected || !visible(w.el) || ownText(w.el) !== w.text || frameOf(w.el) !== w.frame) continue;
1339
+ const now = within(w.el, w.frame);
1340
+ const dx = Math.round(now.x - w.x);
1341
+ const dy = Math.round(now.y - w.y);
1342
+ // (a phrase of three words or more that only slides along its line, after a name or a value that changed, is a
1343
+ // sentence that reads on: "4.1% of people on Earth speak Hindi". A label of a word or two that slides is not)
1344
+ if (!far(dy) && (w.text.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3) continue;
1345
+ if (far(dx) || far(dy)) out.push({ what: `"${snippet(w.text)}"`, dx, dy, text: true });
1346
+ }
1229
1347
 
1230
1348
  return out;
1231
1349
  }
@@ -1340,5 +1458,210 @@ export function install() {
1340
1458
  return { what: focusName(el), clip: box };
1341
1459
  }
1342
1460
 
1343
- window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, sweepTargets, breakpoints, pointAt, place, moved, record, stop, unseen, focused, focusStop };
1461
+ // Three pairs of neighboring slats of the main Plot (the first two, two in the middle, the last two), for glide() in
1462
+ // index.js
1463
+ function neighbors() {
1464
+
1465
+ const slats = mainSlats();
1466
+ if (slats.length < 3) return [];
1467
+ const list = (window.__rhpTargets ??= []);
1468
+ 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 });
1469
+
1470
+ return [...new Set([0, Math.floor((slats.length - 1) / 2), slats.length - 2])].map((i) => [one(i), one(i + 1)]);
1471
+ }
1472
+
1473
+ // The middle of a slat's largest mark (a target), in the window: where its own pick shows, for the glide. A slat with
1474
+ // marks of its own (a box and its outlier dots) may show several things; its largest mark shows the slat's own.
1475
+ function markCenter(n, scroll = true) {
1476
+
1477
+ const slat = window.__rhpTargets[n];
1478
+ if (scroll) slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1479
+ const area = (r) => r.width * r.height;
1480
+ const mark = [...slat.querySelectorAll(MARKS)].filter(visible).map((m) => m.getBoundingClientRect()).filter((r) => area(r) > 0).sort((a, b) => area(b) - area(a))[0];
1481
+ if (!mark) return null;
1482
+
1483
+ return { x: mark.left + mark.width / 2, y: mark.top + mark.height / 2 };
1484
+ }
1485
+
1486
+ // What the reader sees of a pick, as two short hashes: the page's visible text, and how the main Plot's slats are
1487
+ // drawn (the classes and inline styles of everything in them)
1488
+ function state() {
1489
+
1490
+ const hash = (s) => {
1491
+ let h = 5381;
1492
+ for (let i = 0; i < s.length; i++) {
1493
+ h = (h * 33 + s.charCodeAt(i)) | 0;
1494
+ }
1495
+ return h;
1496
+ };
1497
+ const look = mainSlats().map((s) => [s, ...s.querySelectorAll("*")].map((e) => (e.getAttribute("class") ?? "") + (e.getAttribute("style") ?? "")).join("")).join("|");
1498
+
1499
+ return { text: hash(document.body.innerText), look: hash(look) };
1500
+ }
1501
+
1502
+ // The elements around the charts whose text a pick changes (the readout), noted while the sweep picks every slat
1503
+ let watcher = null;
1504
+ const readout = new Set();
1505
+ function watch(on) {
1506
+
1507
+ watcher?.disconnect();
1508
+ watcher = null;
1509
+ if (!on) return readout.size;
1510
+ watcher = new MutationObserver((list) => {
1511
+ for (const m of list) {
1512
+ const el = m.type === "characterData" ? m.target.parentElement : m.target;
1513
+ if (el?.nodeType === 1 && el !== document.body && !el.closest(".rhp-chart")) readout.add(el);
1514
+ }
1515
+ });
1516
+ watcher.observe(document.body, { subtree: true, childList: true, characterData: true });
1517
+ return 0;
1518
+ }
1519
+
1520
+ // While the main chart is taller than three quarters of the window (height: a phone's is shorter than the page's),
1521
+ // scrolled so that its middle is in the middle of the window: its controls (in its section, outside its slats, not
1522
+ // the poster's fit and download icons) and its readout (what the sweep's picks wrote), when all of them are out of
1523
+ // view. null when they are in view, or there are none.
1524
+ function scrollsAway(height = innerHeight) {
1525
+
1526
+ const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
1527
+ const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
1528
+ if (!chart || chart.getBoundingClientRect().height < height * 0.75) return null;
1529
+ let section = chart;
1530
+ while (section.parentElement && section.parentElement !== document.body && section.parentElement !== document.documentElement) section = section.parentElement;
1531
+ const utility = /\b(fit|download|save|export|png|svg|share|print|full ?screen|restore|enlarge|shrink)\b/i;
1532
+ const controls = [...section.querySelectorAll(CONTROLS)].filter((el) => visible(el) && !el.closest(".rhp-chart") && el.getBoundingClientRect().width > 0 && !utility.test(nameOf(el) || el.textContent));
1533
+ const said = [...readout].filter((el) => el.isConnected && visible(el) && section.contains(el) && seenText(el));
1534
+ if (!controls.length && !said.length) return null;
1535
+
1536
+ const y = scrollY;
1537
+ const r = chart.getBoundingClientRect();
1538
+ scrollTo({ left: scrollX, top: scrollY + r.top + r.height / 2 - height / 2, behavior: "instant" });
1539
+ const inView = (el) => {
1540
+ const b = el.getBoundingClientRect();
1541
+ return b.width * b.height > 0 && b.bottom > 0 && b.top < height && b.right > 0 && b.left < innerWidth;
1542
+ };
1543
+ const out = {
1544
+ controls: controls.some(inView) ? [] : controls.slice(0, 3).map((el) => focusName(el)),
1545
+ readout: said.length && !said.some(inView) ? snippet(seenText(said[0])) : null,
1546
+ };
1547
+ scrollTo({ left: scrollX, top: y, behavior: "instant" });
1548
+
1549
+ return out.controls.length || out.readout ? out : null;
1550
+ }
1551
+
1552
+ // The points the scrub moves the pointer through (see scrub() in index.js), when the reader moves along the chart:
1553
+ // the main chart draws a line or an area (read day by day along its scale), or force (the sweep cannot point at its
1554
+ // slats, or there are one or two). steps points through the middle of the plot, from just inside one end to just
1555
+ // inside the other, with the plot scrolled to the middle of the window: along the scale of the plot that draws the
1556
+ // line or the area, else along the main Plot's scale when it has one or two slats, else across its slats (columns
1557
+ // side by side, or rows one under the other). Points outside the window are left out. { points, target: the plot,
1558
+ // for moved() }
1559
+ function scrubPath(force = false, steps = 40) {
1560
+
1561
+ const slats = mainSlats();
1562
+ const main = slats[0]?.parentElement;
1563
+ const chart = main?.closest(".rhp-chart");
1564
+ const mark = chart ? [...chart.querySelectorAll(".rhp-line, .rhp-area")].find(visible) : null;
1565
+ if (!main || (!mark && !force)) return { points: [] };
1566
+ const plot = mark?.closest(".rhp-plot") ?? main;
1567
+ plot.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1568
+ const r = plot.getBoundingClientRect();
1569
+ const vertical = plot.dataset.rhpO === "v";
1570
+ const across = mark || slats.length <= 2 ? !vertical : vertical;
1571
+ const points = [];
1572
+ for (let k = 0; k <= steps; k++) {
1573
+ const f = 0.02 + (0.96 * k) / steps;
1574
+ 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 };
1575
+ if (p.x >= 0 && p.y >= 0 && p.x < innerWidth && p.y < innerHeight) points.push(p);
1576
+ }
1577
+
1578
+ return { points, target: (window.__rhpTargets ??= []).push(plot) - 1 };
1579
+ }
1580
+
1581
+ // The lines of text around the charts (outside them and their controls), for the scrub: each text node's first line
1582
+ // box, in the box it floats in (see frameOf), keyed by where the node sits in the page so that one written again
1583
+ // counts as the same. placeLines() notes them before the pointer moves; slides() then says which moved: a text that
1584
+ // says the same thing and moved, or one whose words changed and that kept none of its edges (its start, its end or,
1585
+ // 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
1586
+ // it grows jitters under the reader's eyes at every step, and so does a whole sentence, on a chart whose readout
1587
+ // changes at every step (a line read day by day). Over separate marks the readout changes once per mark, as a pick
1588
+ // does, and only what a pick counts counts (see moved()): what is strict is left out there, a slide along its line of
1589
+ // a text whose words changed, or of a sentence of three words or more. A text that moves up or down jumps, whatever
1590
+ // it says and however often the readout changes. A text first seen after placeLines() is measured against where it
1591
+ // was first seen.
1592
+ let lines = new Map();
1593
+ const keyOf = (node) => {
1594
+ const path = [];
1595
+ for (let n = node; n && n !== document.body && n.parentNode; n = n.parentNode) path.push([...n.parentNode.childNodes].indexOf(n));
1596
+ return path.join(".");
1597
+ };
1598
+ const textNodes = () => {
1599
+ const out = [];
1600
+ for (const { el: root } of sections) {
1601
+ if (!root.isConnected) continue;
1602
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1603
+ for (let node = walker.nextNode(); node && out.length < 800; node = walker.nextNode()) {
1604
+ const el = node.parentElement;
1605
+ if (!el || !node.data.trim() || el.closest(".rhp-chart") || el.closest(CONTROLS) || !visible(el) || (viewRoot && !viewRoot.contains(el))) continue;
1606
+ out.push(node);
1607
+ }
1608
+ }
1609
+ return out;
1610
+ };
1611
+ const lineOf = (node) => {
1612
+ const range = document.createRange();
1613
+ range.selectNodeContents(node);
1614
+ const rects = [...range.getClientRects()].filter((q) => q.width > 0 && q.height > 0);
1615
+ if (!rects.length) return null;
1616
+ const el = node.parentElement;
1617
+ const frame = frameOf(el);
1618
+ const f = frame ? frame.getBoundingClientRect() : { left: -scrollX, top: -scrollY };
1619
+ const scale = scaleOf(el);
1620
+ const left = (rects[0].left - f.left) / scale;
1621
+ const right = (rects.at(-1).right - f.left) / scale;
1622
+ // (the line it is part of: its nearest block, which a finding names when it holds more than this text)
1623
+ let block = el;
1624
+ while (block.parentElement && /^(inline|contents)$/.test(getComputedStyle(block).display)) block = block.parentElement;
1625
+ 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)) };
1626
+ };
1627
+ function placeLines() {
1628
+
1629
+ lines = new Map();
1630
+ for (const node of textNodes()) {
1631
+ const line = lineOf(node);
1632
+ if (line) lines.set(keyOf(node), line);
1633
+ }
1634
+ return lines.size;
1635
+ }
1636
+ function slides() {
1637
+
1638
+ const out = [];
1639
+ for (const node of textNodes()) {
1640
+ const now = lineOf(node);
1641
+ if (!now) continue;
1642
+ const key = keyOf(node);
1643
+ const was = lines.get(key);
1644
+ if (!was) {
1645
+ lines.set(key, now);
1646
+ continue;
1647
+ }
1648
+ if (was.frame !== now.frame) continue;
1649
+ const by = (k) => Math.round(now[k] - was[k]);
1650
+ const dy = by("top");
1651
+ let dx = by("left");
1652
+ // (a line that moves up or down jumps whatever it says: only a slide along the line can be strict)
1653
+ const strict = Math.abs(dy) <= 2 && (now.text !== was.text || (was.text.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3);
1654
+ if (now.text !== was.text) {
1655
+ const edges = [by("left"), by("right"), ...(now.one && was.one ? [by("mid")] : [])];
1656
+ dx = edges.some((v) => Math.abs(v) <= 2) ? 0 : edges.reduce((a, b) => (Math.abs(b) < Math.abs(a) ? b : a));
1657
+ }
1658
+ // (a comma or a dot moves with the words around it, and is not named apart from them)
1659
+ 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 });
1660
+ if (out.length >= 40) break;
1661
+ }
1662
+
1663
+ return out;
1664
+ }
1665
+
1666
+ 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
1667
  }