rpc-agent 0.5.0 → 0.6.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.
package/dist/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
5
  import { z } from 'zod';
@@ -8,7 +9,9 @@ import { runCheck } from './check.js';
8
9
  import { textSummary } from './check-format.js';
9
10
  import { renderPreview } from './composite.js';
10
11
  import { CATEGORY_LABELS, CATEGORY_ORDER, DEFAULT_DEVICES, DEVICE_ALIASES, DEVICES, resolveDevices, } from './devices.js';
11
- import { COLOR_SCHEMES, DEFAULT_MAX_SLICES, DEFAULT_OUT_DIR, DEFAULT_SLICE_WIDTH, DEFAULT_TIMEOUT_MS, defaultCheckOptions, normalizeUrl, } from './options.js';
12
+ import { COLOR_SCHEMES, DEFAULT_MAX_SLICES, DEFAULT_OUT_DIR, DEFAULT_SLICE_WIDTH, DEFAULT_TIMEOUT_MS, defaultCheckOptions, defaultRecordOptions, DEFAULT_RECORD_DEVICE, DEFAULT_RECORD_SECONDS, MAX_RECORD_SECONDS, normalizeUrl, } from './options.js';
13
+ import { unpinnedLines, warningWhere } from './motion.js';
14
+ import { record } from './record.js';
12
15
  import { DEFAULT_MAX_HEIGHT } from './slices.js';
13
16
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
14
17
  const screenshotInput = {
@@ -58,6 +61,17 @@ const screenshotInput = {
58
61
  .boolean()
59
62
  .optional()
60
63
  .describe('Emulate prefers-reduced-motion: reduce. Helps when scroll-triggered entrance animations (GSAP, Framer Motion) leave sections blank.'),
64
+ finishAnimations: z
65
+ .boolean()
66
+ .optional()
67
+ .describe('Jump GSAP tweens to their end before capture when gsap/ScrollTrigger are on window (scrubbed and pinned ones keep their ' +
68
+ 'scroll position), and finite Web Animations too (the screenshot already ends CSS animations). Use it when the result warns ' +
69
+ 'that elements were still animating. Default false.'),
70
+ unpin: z
71
+ .boolean()
72
+ .optional()
73
+ .describe('Full page: collapse pinned (GSAP ScrollTrigger-style or sticky) sections so each shows once instead of over a tall blank spacer. ' +
74
+ 'Listed under motion.unpinned. Default true.'),
61
75
  waitFor: z.string().optional().describe('CSS selector to wait for (visible) before capturing.'),
62
76
  waitMs: z.number().int().min(0).max(30_000).optional().describe('Extra settle delay after load, in ms.'),
63
77
  colorScheme: z.enum(COLOR_SCHEMES).optional().describe('Emulate prefers-color-scheme.'),
@@ -118,6 +132,32 @@ const checkInput = {
118
132
  colorScheme: z.enum(COLOR_SCHEMES).optional().describe('Emulate prefers-color-scheme.'),
119
133
  timeoutMs: z.number().int().min(1000).max(120_000).optional().describe(`Navigation timeout. Default ${DEFAULT_TIMEOUT_MS}.`),
120
134
  };
135
+ const recordInput = {
136
+ url: z
137
+ .string()
138
+ .describe('Page to record. http(s)://, file://, a bare host like localhost:3000, or a path to a local .html file.'),
139
+ device: z.string().optional().describe(`One device id or alias (see list_devices). Default ${DEFAULT_RECORD_DEVICE}.`),
140
+ selector: z
141
+ .string()
142
+ .trim()
143
+ .min(1)
144
+ .optional()
145
+ .describe('CSS selector of the element to record (a carousel, a marquee, a pinned section). It is scrolled into view and the video is cropped to its box. Default: the whole viewport.'),
146
+ duration: z
147
+ .number()
148
+ .min(0.5)
149
+ .max(MAX_RECORD_SECONDS)
150
+ .optional()
151
+ .describe(`Seconds of video. Default ${DEFAULT_RECORD_SECONDS}, max ${MAX_RECORD_SECONDS}.`),
152
+ gif: z.boolean().optional().describe('Also write a GIF (10 fps, at most 640 px wide). Default false.'),
153
+ mp4: z.boolean().optional().describe('Also write an mp4; needs an ffmpeg with H.264 on PATH, skipped with a warning otherwise. Default false.'),
154
+ reducedMotion: z.boolean().optional().describe('Emulate prefers-reduced-motion: reduce.'),
155
+ waitFor: z.string().optional().describe('CSS selector to wait for (visible) before recording.'),
156
+ waitMs: z.number().int().min(0).max(30_000).optional().describe('Extra delay before recording, in ms.'),
157
+ colorScheme: z.enum(COLOR_SCHEMES).optional().describe('Emulate prefers-color-scheme.'),
158
+ outDir: z.string().optional().describe(`Output directory, relative to the server's cwd. Default ${DEFAULT_OUT_DIR}.`),
159
+ timeoutMs: z.number().int().min(1000).max(120_000).optional().describe(`Navigation timeout. Default ${DEFAULT_TIMEOUT_MS}.`),
160
+ };
121
161
  function authFrom(args) {
122
162
  const auth = {};
123
163
  if (args.cookies?.length)
@@ -157,6 +197,8 @@ export function createServer() {
157
197
  timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
158
198
  emulate: true,
159
199
  reducedMotion: args.reducedMotion ?? false,
200
+ finishAnimations: args.finishAnimations ?? false,
201
+ unpin: args.unpin ?? true,
160
202
  maxHeight: args.maxHeight ?? DEFAULT_MAX_HEIGHT,
161
203
  saveSlices: args.saveSlices,
162
204
  maxSlices: args.maxSlices ?? DEFAULT_MAX_SLICES,
@@ -261,6 +303,60 @@ export function createServer() {
261
303
  await browser.close();
262
304
  }
263
305
  });
306
+ server.registerTool('record', {
307
+ title: 'Record a short video of a page or element',
308
+ description: 'Record a few seconds of a page, or of one element, as a VP9 webm (plus a GIF or mp4 on request), for motion a ' +
309
+ 'screenshot cannot show: carousels, marquees, pinned scroll sections, looping animations. screenshot warns ' +
310
+ '"carousel: use rpc record" when it sees one. Returns the file paths, a short summary and the first frame (poster) inline.',
311
+ inputSchema: { ...recordInput, ...authInput },
312
+ annotations: { readOnlyHint: false, openWorldHint: true },
313
+ }, async (args) => {
314
+ let options;
315
+ try {
316
+ const auth = authFrom(args);
317
+ options = {
318
+ ...defaultRecordOptions(args.url),
319
+ device: resolveDevices([args.device ?? DEFAULT_RECORD_DEVICE])[0].id,
320
+ selector: args.selector,
321
+ duration: args.duration ?? DEFAULT_RECORD_SECONDS,
322
+ gif: args.gif ?? false,
323
+ mp4: args.mp4 ?? false,
324
+ reducedMotion: args.reducedMotion ?? false,
325
+ outDir: args.outDir ?? DEFAULT_OUT_DIR,
326
+ waitFor: args.waitFor,
327
+ waitMs: args.waitMs ?? 0,
328
+ colorScheme: args.colorScheme,
329
+ timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
330
+ ...(Object.keys(auth).length ? { auth } : {}),
331
+ };
332
+ resolveAuth(options.url, auth);
333
+ }
334
+ catch (err) {
335
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
336
+ }
337
+ let browser;
338
+ try {
339
+ browser = await launchBrowser();
340
+ }
341
+ catch (err) {
342
+ return { isError: true, content: [{ type: 'text', text: `Could not start the browser: ${err.message}` }] };
343
+ }
344
+ try {
345
+ const r = await record(options, { browser });
346
+ const content = [{ type: 'text', text: recordSummary(r) + '\n\n' + JSON.stringify(r, null, 2) }];
347
+ if (r.error)
348
+ return { isError: true, content };
349
+ if (r.poster) {
350
+ const p = await renderPreview(browser, await readFile(r.poster.path), r.poster);
351
+ content.push({ type: 'text', text: `${r.device}${r.selector ? ` ${r.selector}` : ''}: first frame (poster) ${p.width}x${p.height}` });
352
+ content.push({ type: 'image', data: p.data.toString('base64'), mimeType: 'image/jpeg' });
353
+ }
354
+ return { content };
355
+ }
356
+ finally {
357
+ await browser.close();
358
+ }
359
+ });
264
360
  server.registerTool('list_devices', {
265
361
  title: 'List device presets',
266
362
  description: 'List device ids, CSS viewport sizes and pixel ratios usable in screenshot.devices and check.devices, plus short aliases.',
@@ -342,8 +438,15 @@ export function headline(shots, options, auth) {
342
438
  continue;
343
439
  }
344
440
  const size = s.shot ? `${s.shot.width}x${s.shot.height}px` : '';
441
+ const motion = [
442
+ ...unpinnedLines(s.motion?.unpinned).map(u => ` unpinned: ${u}`),
443
+ ...(s.warnings ?? []).map(w => {
444
+ const where = warningWhere(w);
445
+ return ` WARN [${w.id}] ${w.message}${where ? ` at ${where}` : ''}\n fix: ${w.fix}`;
446
+ }),
447
+ ];
345
448
  if (!options.fullPage) {
346
- lines.push(`ok ${s.device}: ${size}${s.shot ? ` ${s.shot.path}` : ''}`);
449
+ lines.push(`ok ${s.device}: ${size}${s.shot ? ` ${s.shot.path}` : ''}`, ...motion);
347
450
  continue;
348
451
  }
349
452
  const parts = [
@@ -372,8 +475,29 @@ export function headline(shots, options, auth) {
372
475
  parts.push(` full PNG: ${s.shot ? s.shot.path : ''}`);
373
476
  }
374
477
  }
375
- lines.push(parts.join('\n'));
478
+ lines.push(parts.join('\n'), ...motion);
479
+ }
480
+ return lines.join('\n');
481
+ }
482
+ /** First lines of the record tool's result: what was written, or why not. */
483
+ export function recordSummary(r) {
484
+ const lines = [];
485
+ if (r.auth)
486
+ lines.push(`auth: ${describeAuth(r.auth)}`);
487
+ if (r.error) {
488
+ lines.push(`FAILED ${r.device}: ${r.error}${r.hint ? `\n hint: ${r.hint}` : ''}`);
489
+ return lines.join('\n');
376
490
  }
491
+ const v = r.video;
492
+ lines.push(`ok ${r.device}${r.selector ? ` ${r.selector}` : ''}: ${v.duration}s webm ${v.width}x${v.height} at ${v.fps} fps ${v.path}`);
493
+ if (r.poster)
494
+ lines.push(` poster: ${r.poster.path}`);
495
+ if (r.gif)
496
+ lines.push(` gif: ${r.gif.path} (${r.gif.width}x${r.gif.height}, ${r.gif.frames} frames)`);
497
+ if (r.mp4)
498
+ lines.push(` mp4: ${r.mp4.path}`);
499
+ for (const w of r.warnings ?? [])
500
+ lines.push(` WARN ${w}`);
377
501
  return lines.join('\n');
378
502
  }
379
503
  function summarise(url, outDir, shots, auth) {
package/dist/motion.js ADDED
@@ -0,0 +1,487 @@
1
+ import { HELPERS } from './check-page.js';
2
+ /** Final settle before a capture: quiet period and cap, in ms. */
3
+ export const SETTLE_QUIET_MS = 150;
4
+ export const SETTLE_CAP_MS = 2000;
5
+ /** Per warm-up scroll step, and for all steps together. */
6
+ export const STEP_QUIET_MS = 120;
7
+ export const STEP_CAP_MS = 800;
8
+ export const WARM_UP_BUDGET_MS = 8000;
9
+ /**
10
+ * `__rpcSettle(quiet, cap, waapi)`: resolves when no `style`/`class`
11
+ * attribute on a visible element changed for `quiet` ms and (with `waapi`)
12
+ * no finite animation on the document timeline runs on a visible element,
13
+ * or at `cap` ms. Elements still changing or animating at the cap are
14
+ * remembered as loops (a JS marquee, a ticker, a long CSS animation) and
15
+ * don't hold up later calls on the same page.
16
+ *
17
+ * `waapi` false is for the last wait before a screenshot: Playwright's
18
+ * `animations: 'disabled'` already ends finite CSS and Web Animations in the
19
+ * image, so only JS-driven changes (GSAP-style tweens) can be caught mid-way.
20
+ */
21
+ const SETTLE_FN = `
22
+ const __rpcSettle = async (quiet, cap, waapi = true) => {
23
+ const loops = (window.__rpcLoops = window.__rpcLoops || new WeakSet());
24
+ const start = performance.now();
25
+ const vw = window.innerWidth;
26
+ const vh = window.innerHeight;
27
+ const visible = el => {
28
+ if (!el || el.nodeType !== 1 || !el.isConnected) return false;
29
+ const r = el.getBoundingClientRect();
30
+ return r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
31
+ };
32
+ const changed = new Map();
33
+ let last = start;
34
+ const mo = new MutationObserver(list => {
35
+ const now = performance.now();
36
+ for (const m of list) {
37
+ if (!visible(m.target)) continue;
38
+ changed.set(m.target, now);
39
+ if (!loops.has(m.target)) last = now;
40
+ }
41
+ });
42
+ mo.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
43
+ const running = () => (waapi && document.getAnimations ? document.getAnimations() : []).filter(a => {
44
+ if (a.playState !== 'running' || loops.has(a.effect && a.effect.target) || (a.timeline && a.timeline !== document.timeline)) return false;
45
+ const t = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming() : null;
46
+ return Boolean(t) && t.endTime !== Infinity && visible(a.effect.target);
47
+ });
48
+ let settled = false;
49
+ try {
50
+ for (;;) {
51
+ await new Promise(r => setTimeout(r, 30));
52
+ const now = performance.now();
53
+ if (now - last >= quiet && running().length === 0) { settled = true; break; }
54
+ if (now - start >= cap) break;
55
+ }
56
+ } finally {
57
+ mo.disconnect();
58
+ }
59
+ const now = performance.now();
60
+ const active = new Set(running().map(a => a.effect.target));
61
+ for (const [el, t] of changed) if (now - t < quiet && el.isConnected) active.add(el);
62
+ if (!settled) for (const el of active) loops.add(el);
63
+ return { settled, ms: Math.round(now - start), active: Array.from(active) };
64
+ };
65
+ `;
66
+ /** Settle at the current scroll position (see SETTLE_FN). */
67
+ export const settleJs = (quiet = SETTLE_QUIET_MS, cap = SETTLE_CAP_MS, waapi = true) => `(async () => {
68
+ ${HELPERS}
69
+ ${SETTLE_FN}
70
+ const r = await __rpcSettle(${quiet}, ${cap}, ${waapi});
71
+ return { settled: r.settled, ms: r.ms, active: r.active.slice(0, 20).map(sel), count: r.active.length };
72
+ })()`;
73
+ /**
74
+ * Walk the page one viewport at a time so loading="lazy" images and
75
+ * IntersectionObserver-driven content render, settling at each step, then
76
+ * return to the top. Bounded to 100 steps and the full-page height cap; once
77
+ * the settle budget is spent, steps fall back to a fixed 100 ms pause.
78
+ */
79
+ export const warmUpScrollJs = (maxHeight) => `(async () => {
80
+ ${SETTLE_FN}
81
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
82
+ const root = document.scrollingElement || document.documentElement;
83
+ const step = Math.max(200, Math.floor(window.innerHeight * 0.9));
84
+ let spent = 0;
85
+ let steps = 0;
86
+ let settled = 0;
87
+ const pause = async () => {
88
+ steps++;
89
+ const left = ${WARM_UP_BUDGET_MS} - spent;
90
+ if (left < ${STEP_QUIET_MS}) return sleep(100);
91
+ const r = await __rpcSettle(${STEP_QUIET_MS}, Math.min(${STEP_CAP_MS}, left));
92
+ spent += r.ms;
93
+ if (r.settled) settled++;
94
+ };
95
+ for (let i = 0, y = 0; i < 100 && y < ${maxHeight}; i++, y += step) {
96
+ if (y > root.scrollHeight - window.innerHeight) break;
97
+ window.scrollTo({ top: y, behavior: 'instant' });
98
+ await pause();
99
+ }
100
+ window.scrollTo({ top: Math.min(root.scrollHeight, ${maxHeight}), behavior: 'instant' });
101
+ await pause();
102
+ window.scrollTo({ top: 0, behavior: 'instant' });
103
+ await sleep(150);
104
+ return { steps, settled, ms: Math.round(spent) };
105
+ })()`;
106
+ /**
107
+ * Pinned sections: `.pin-spacer` wrappers (GSAP ScrollTrigger), pins
108
+ * ScrollTrigger reports (when the pin is its parent's only child), and
109
+ * sticky or fixed sections (half a viewport or more) that are the only child of a parent at least half a viewport
110
+ * taller. Sets `pins` to `{ el, spacer, kind }`.
111
+ */
112
+ const PINS_FN = `
113
+ const pins = [];
114
+ {
115
+ const vw = window.innerWidth;
116
+ const vh = window.innerHeight;
117
+ const taken = new Set();
118
+ const h = el => el.getBoundingClientRect().height;
119
+ const add = (el, spacer, kind) => {
120
+ if (taken.has(el) || !spacer || spacer === document.body || spacer === document.documentElement) return;
121
+ taken.add(el);
122
+ pins.push({ el, spacer, kind });
123
+ };
124
+ for (const sp of document.querySelectorAll('.pin-spacer')) {
125
+ const el = sp.firstElementChild;
126
+ if (el && h(sp) - h(el) >= vh / 4) add(el, sp, 'pin-spacer');
127
+ }
128
+ try {
129
+ const ST = window.ScrollTrigger;
130
+ if (ST && typeof ST.getAll === 'function') {
131
+ for (const st of ST.getAll()) {
132
+ const el = st && st.pin;
133
+ const parent = el && el.nodeType === 1 ? el.parentElement : null;
134
+ // Only a wrapper that holds just the pin: collapsing a shared parent (<main>) would move everything in it.
135
+ if (parent && parent.children.length === 1 && h(parent) - h(el) >= vh / 4) add(el, parent, 'scrolltrigger');
136
+ }
137
+ }
138
+ } catch (e) {}
139
+ for (const el of document.body ? document.body.querySelectorAll('*') : []) {
140
+ const cs = getComputedStyle(el);
141
+ if (cs.position !== 'sticky' && cs.position !== 'fixed') continue;
142
+ const r = el.getBoundingClientRect();
143
+ if (r.height < vh / 2 || r.width < vw / 2 || cs.visibility === 'hidden' || parseFloat(cs.opacity) < 0.1) continue;
144
+ const parent = el.parentElement;
145
+ if (!parent || parent.children.length !== 1) continue;
146
+ if (h(parent) - r.height >= vh / 2) add(el, parent, cs.position);
147
+ }
148
+ }
149
+ `;
150
+ /** Detect running animations, pins, hidden reveals and carousels. `scope` 'page' looks past the viewport. */
151
+ export const motionJs = (scope) => `(async () => {
152
+ ${HELPERS}
153
+ ${PINS_FN}
154
+ const vw = window.innerWidth;
155
+ const vh = window.innerHeight;
156
+ const wholePage = ${scope === 'page'};
157
+ const inScope = r => r.width > 0 && r.height > 0 && (wholePage || (r.bottom > -vh / 2 && r.top < vh * 1.5));
158
+ const rounded = (n, p) => Math.round(n * 10 ** p) / 10 ** p;
159
+
160
+ const finite = new Map();
161
+ const loops = new Map();
162
+ for (const a of document.getAnimations ? document.getAnimations() : []) {
163
+ if (a.playState !== 'running' || (a.timeline && a.timeline !== document.timeline)) continue;
164
+ const el = a.effect && a.effect.target;
165
+ if (!el || el.nodeType !== 1 || !inScope(el.getBoundingClientRect())) continue;
166
+ const t = a.effect.getComputedTiming();
167
+ const kind = a.animationName !== undefined ? 'CSS animation ' + a.animationName
168
+ : a.transitionProperty !== undefined ? 'CSS transition ' + a.transitionProperty
169
+ : 'Web Animation' + (a.id ? ' ' + a.id : '');
170
+ (t.endTime === Infinity ? loops : finite).set(el, kind);
171
+ }
172
+ const items = m => Array.from(m).slice(0, 20).map(([el, text]) => ({ selector: sel(el), text }));
173
+
174
+ const pinInfo = pins.map(p => ({
175
+ selector: sel(p.el),
176
+ spacer: sel(p.spacer),
177
+ kind: p.kind,
178
+ spacerHeight: Math.round(p.spacer.getBoundingClientRect().height),
179
+ height: Math.round(p.el.getBoundingClientRect().height),
180
+ }));
181
+
182
+ // Reveals: hidden or offset, and marked as animated content by a class,
183
+ // a data attribute or inline styles a library wrote. Class tokens are the
184
+ // reveal libraries' own (AOS, WOW, sal, animate.css) and plain reveal or
185
+ // fade names; generic words such as scroll, visible, motion or animate
186
+ // would match utility classes (overflow-x-scroll, motion-safe:, animate-spin).
187
+ const REVEAL_CLASS = /(^|[\\s_-])(reveal|reveals|fade|fade-in|fadein|aos|aos-init|aos-animate|appear|animated|inview|in-view|wow|sal|slide-in)([\\s_-]|$)/i;
188
+ const REVEAL_ATTRS = ['data-aos', 'data-sal', 'data-scroll', 'data-animate', 'data-animation', 'data-reveal', 'data-motion', 'data-inview', 'data-in-view', 'data-framer-appear-id'];
189
+ const LIB_STYLE = /opacity|transform|translate|scale|visibility/i;
190
+ // Carousel and slider tracks are moved by transforms on purpose; their offset is the current slide, not a reveal.
191
+ const LIBS = [['.swiper', 'Swiper'], ['.swiper-container', 'Swiper'], ['.embla', 'Embla'], ['.splide', 'Splide'], ['.keen-slider', 'Keen'], ['.flickity-enabled', 'Flickity'], ['.slick-slider', 'Slick'], ['.glide', 'Glide'], ['[aria-roledescription="carousel"]', 'carousel']];
192
+ const LIB_ROOTS = LIBS.map(([s]) => s).join(', ');
193
+ const isTrack = el => {
194
+ const p = el.parentElement;
195
+ return Boolean(p && p !== document.body && el.offsetWidth > p.clientWidth + 1 && getComputedStyle(p).overflowX !== 'visible');
196
+ };
197
+ // One axis of a translate, in px: percentages are of the element's own size.
198
+ const lengthPx = (v, size) => (v && v.endsWith('%') ? (parseFloat(v) / 100) * size : parseFloat(v) || 0);
199
+ // A translate of minus half the element's own size is centring (left: 50%; translate(-50%)), not an offset.
200
+ const offsetOf = (v, size) => (Math.abs(v + size / 2) <= 1 ? 0 : Math.abs(v));
201
+ const hidden = [];
202
+ const hiddenEls = [];
203
+ for (const el of document.body ? document.body.querySelectorAll('*') : []) {
204
+ if (hidden.length >= 20) break;
205
+ if (hiddenEls.some(h => h.contains(el))) continue;
206
+ const r = el.getBoundingClientRect();
207
+ if (r.width < 8 || r.height < 8 || !inScope(r)) continue;
208
+ if (el.closest('[aria-hidden="true"], [inert], [hidden], dialog:not([open])')) continue;
209
+ const cs = getComputedStyle(el);
210
+ if (cs.position === 'fixed') continue;
211
+ if (el.closest(LIB_ROOTS)) continue;
212
+ const opacity = parseFloat(cs.opacity);
213
+ const m = cs.transform && cs.transform !== 'none' ? new DOMMatrixReadOnly(cs.transform) : null;
214
+ const w = el.offsetWidth || r.width;
215
+ const ht = el.offsetHeight || r.height;
216
+ const [tx, ty] = cs.translate && cs.translate !== 'none' ? cs.translate.split(/\\s+/) : [];
217
+ const dx = (m ? m.m41 : 0) + lengthPx(tx, w);
218
+ const dy = (m ? m.m42 : 0) + lengthPx(ty, ht);
219
+ const offset = isTrack(el) ? 0 : Math.max(offsetOf(dx, w), offsetOf(dy, ht));
220
+ let scale = m ? Math.hypot(m.a, m.b) : 1;
221
+ if (cs.scale && cs.scale !== 'none') scale = Math.min(scale, parseFloat(cs.scale) || 1);
222
+ const faded = opacity < 0.1 || cs.visibility === 'hidden';
223
+ const moved = offset >= 24 || scale < 0.85;
224
+ if (!faded && !moved) continue;
225
+ const media = /^(img|svg|video|canvas|picture)$/.test(el.localName) || el.querySelector('img, svg, video, canvas, picture');
226
+ if (!(el.textContent || '').trim() && !media) continue;
227
+ const cls = el.getAttribute('class') || '';
228
+ const why = REVEAL_CLASS.test(cls) ? 'reveal class' : REVEAL_ATTRS.some(a => el.hasAttribute(a)) ? 'reveal attribute' : LIB_STYLE.test(el.getAttribute('style') || '') ? 'inline style' : '';
229
+ if (!why) continue;
230
+ hiddenEls.push(el);
231
+ const state = faded ? (cs.visibility === 'hidden' ? 'visibility hidden' : 'opacity ' + rounded(opacity, 2)) : scale < 0.85 ? 'scale ' + rounded(scale, 2) : 'offset ' + Math.round(offset) + 'px';
232
+ hidden.push({ selector: sel(el), text: state + ' (' + why + ')', value: rounded(opacity, 2) });
233
+ }
234
+
235
+ // Carousels: library roots, horizontal scroll-snap, and tracks seen moving.
236
+ const found = new Map();
237
+ const note = (el, key, value) => found.set(el, Object.assign(found.get(el) || {}, { [key]: value }));
238
+ for (const [s, name] of LIBS) {
239
+ for (const el of document.querySelectorAll(s)) if (inScope(el.getBoundingClientRect())) note(el, 'lib', name);
240
+ }
241
+ const watch = [];
242
+ for (const el of document.body ? document.body.querySelectorAll('*') : []) {
243
+ if (watch.length >= 200) break;
244
+ const r = el.getBoundingClientRect();
245
+ if (r.width < 40 || r.height < 40 || !inScope(r)) continue;
246
+ const cs = getComputedStyle(el);
247
+ if (el.scrollWidth > el.clientWidth + 1 && cs.overflowX !== 'visible') {
248
+ watch.push({ el, root: el, scroller: true });
249
+ if (/x|inline|both/.test(cs.scrollSnapType || '')) note(el, 'snap', true);
250
+ }
251
+ const p = el.parentElement;
252
+ if (p && p !== document.body && r.width > p.clientWidth + 1 && getComputedStyle(p).overflowX !== 'visible') {
253
+ watch.push({ el, root: p, scroller: false });
254
+ }
255
+ }
256
+ const pos = w => (w.scroller ? String(w.el.scrollLeft) : getComputedStyle(w.el).transform + '|' + getComputedStyle(w.el).translate);
257
+ const before = watch.map(pos);
258
+ await new Promise(r => setTimeout(r, 300));
259
+ watch.forEach((w, i) => { if (pos(w) !== before[i]) note(w.root, 'moving', true); });
260
+ const roots = Array.from(found.keys());
261
+ const carousels = roots
262
+ .filter(el => !roots.some(o => o !== el && o.contains(el)))
263
+ .slice(0, 10)
264
+ .map(el => {
265
+ const f = found.get(el);
266
+ return { selector: sel(el), text: [f.lib, f.snap ? 'scroll-snap' : '', f.moving ? 'moving' : ''].filter(Boolean).join(', ') };
267
+ });
268
+
269
+ const libraries = [];
270
+ if (window.gsap) libraries.push('gsap');
271
+ if (window.ScrollTrigger) libraries.push('ScrollTrigger');
272
+ return { animations: { finite: items(finite), loops: items(loops) }, pins: pinInfo, hidden, carousels, libraries };
273
+ })()`;
274
+ /**
275
+ * --finish-animations: every finite Web Animation to its end, then GSAP
276
+ * when its globals are there. Scrubbed and pinned ScrollTriggers follow the
277
+ * scroll position, so they are left alone. Each library step is guarded.
278
+ */
279
+ export const FINISH_JS = `(() => {
280
+ const out = { animations: 0, gsap: 0, scrollTriggers: 0 };
281
+ for (const a of document.getAnimations ? document.getAnimations() : []) {
282
+ try {
283
+ if (a.playState === 'finished' || (a.timeline && a.timeline !== document.timeline)) continue;
284
+ const t = a.effect && a.effect.getComputedTiming();
285
+ if (!t || t.endTime === Infinity) continue;
286
+ a.finish();
287
+ out.animations++;
288
+ } catch (e) {}
289
+ }
290
+ try {
291
+ const ST = window.ScrollTrigger;
292
+ if (ST && typeof ST.getAll === 'function') {
293
+ for (const st of ST.getAll()) {
294
+ try {
295
+ const v = (st && st.vars) || {};
296
+ if (v.scrub || v.pin || st.pin) continue;
297
+ const anim = st.animation;
298
+ if (anim && typeof anim.progress === 'function' && anim.progress() < 1) { anim.progress(1); out.scrollTriggers++; }
299
+ } catch (e) {}
300
+ }
301
+ }
302
+ } catch (e) {}
303
+ try {
304
+ const tl = window.gsap && window.gsap.globalTimeline;
305
+ if (tl && typeof tl.getChildren === 'function') {
306
+ for (const child of tl.getChildren(false, true, true)) {
307
+ try {
308
+ if (!child || child.scrollTrigger || typeof child.progress !== 'function') continue;
309
+ if ((typeof child.repeat === 'function' ? child.repeat() : 0) === -1) continue;
310
+ if (child.progress() < 1) { child.progress(1); out.gsap++; }
311
+ } catch (e) {}
312
+ }
313
+ }
314
+ } catch (e) {}
315
+ return out;
316
+ })()`;
317
+ /**
318
+ * Collapse each pin so the section shows once in a stitched shot. The rules
319
+ * are `!important` in a stylesheet, which beats the inline styles a library
320
+ * writes later (a ScrollTrigger refresh on the capture's resize).
321
+ */
322
+ export const UNPIN_JS = `(() => {
323
+ ${HELPERS}
324
+ ${PINS_FN}
325
+ if (!pins.length) return [];
326
+ const height = el => el.getBoundingClientRect().height;
327
+ const before = pins.map(p => height(p.spacer));
328
+ if (!document.getElementById('rpc-unpin')) {
329
+ const style = document.createElement('style');
330
+ style.id = 'rpc-unpin';
331
+ style.textContent =
332
+ '[data-rpc-unpin="spacer"]{padding:0!important;height:auto!important;min-height:0!important;max-height:none!important}' +
333
+ '[data-rpc-unpin="pin"]{position:relative!important;top:auto!important;left:auto!important;right:auto!important;bottom:auto!important;transform:none!important;translate:none!important}' +
334
+ '[data-rpc-unpin="track"]{height:auto!important;min-height:0!important;padding-bottom:0!important}';
335
+ (document.head || document.documentElement).appendChild(style);
336
+ }
337
+ const out = pins.map(p => ({ selector: sel(p.el), spacer: sel(p.spacer), kind: p.kind }));
338
+ for (const p of pins) {
339
+ if (p.kind === 'sticky') {
340
+ p.spacer.setAttribute('data-rpc-unpin', 'track');
341
+ } else {
342
+ p.spacer.setAttribute('data-rpc-unpin', 'spacer');
343
+ p.el.setAttribute('data-rpc-unpin', 'pin');
344
+ }
345
+ }
346
+ return out.map((o, i) => ({ ...o, removed: Math.max(0, Math.round(before[i] - height(pins[i].spacer))) }));
347
+ })()`;
348
+ /**
349
+ * Wait for the page to stop moving. `waapi: false` ignores CSS and Web
350
+ * Animations (for the wait before a screenshot, which ends them itself).
351
+ */
352
+ export async function settleMotion(page, quiet = SETTLE_QUIET_MS, cap = SETTLE_CAP_MS, { waapi = true } = {}) {
353
+ return (await page.evaluate(settleJs(quiet, cap, waapi)));
354
+ }
355
+ export async function detectMotion(page, scope) {
356
+ return (await page.evaluate(motionJs(scope)));
357
+ }
358
+ export async function finishAnimations(page) {
359
+ return (await page.evaluate(FINISH_JS));
360
+ }
361
+ export async function unpin(page) {
362
+ return (await page.evaluate(UNPIN_JS));
363
+ }
364
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
365
+ const STILL_FIX = 'Captures catch these mid-way. Use rpc shot --finish-animations to jump them to their end state, --wait <ms> to give ' +
366
+ 'them time, or --reduced-motion if the page skips animations under prefers-reduced-motion.';
367
+ const REVEAL_FIX = 'Reveal-on-scroll content waits at opacity 0 or offset until it animates in. Try rpc shot --finish-animations or ' +
368
+ '--reduced-motion, and make sure the content still shows when JavaScript or animations are off.';
369
+ function carouselFix(items) {
370
+ return `A still shows one slide. Record it: rpc record <url> --selector '${items[0]?.selector ?? '<css>'}' --duration 5.`;
371
+ }
372
+ /** `rpc shot` warnings: what the image shows differently from a person scrolling. */
373
+ export function shotWarnings(d, settle, ctx) {
374
+ const out = [];
375
+ if (settle.count > 0) {
376
+ out.push({
377
+ id: 'motion-running',
378
+ severity: 'warn',
379
+ message: `${plural(settle.count, 'element was', 'elements were')} still animating when captured`,
380
+ fix: STILL_FIX,
381
+ value: settle.count,
382
+ items: settle.active.map(selector => ({ selector })),
383
+ });
384
+ }
385
+ if (ctx.fullPage) {
386
+ const collapsed = new Set((ctx.unpinned ?? []).filter(u => u.removed > 0).map(u => u.selector));
387
+ const left = d.pins.filter(p => !collapsed.has(p.selector));
388
+ if (left.length) {
389
+ out.push({
390
+ id: 'motion-pinned',
391
+ severity: 'warn',
392
+ message: 'pinned section: the stitched shot may show blank space',
393
+ fix: 'Leave unpinning on (the default with --full-page; --no-unpin turns it off) so each pin is collapsed to its ' +
394
+ 'section, or record the section with rpc record --selector.',
395
+ items: left.map(pinItem),
396
+ });
397
+ }
398
+ }
399
+ if (d.hidden.length) {
400
+ out.push({
401
+ id: 'motion-reveal-hidden',
402
+ severity: 'warn',
403
+ message: `${plural(d.hidden.length, 'element')} still hidden by a reveal animation`,
404
+ fix: REVEAL_FIX,
405
+ items: d.hidden,
406
+ });
407
+ }
408
+ if (d.carousels.length) {
409
+ out.push({ id: 'motion-carousel', severity: 'warn', message: 'carousel: use `rpc record` for motion', fix: carouselFix(d.carousels), items: d.carousels });
410
+ }
411
+ return out;
412
+ }
413
+ /** `rpc check` Motion findings. Warn or info only: motion is a capture concern, not a launch blocker. */
414
+ export function motionCheckFindings(d, settle) {
415
+ const out = [];
416
+ const running = new Map();
417
+ for (const s of settle.active)
418
+ running.set(s, { selector: s });
419
+ for (const a of d.animations.finite)
420
+ running.set(a.selector, a);
421
+ const loops = d.animations.loops.filter(a => !running.has(a.selector));
422
+ if (running.size) {
423
+ out.push({
424
+ id: 'motion-running',
425
+ severity: 'warn',
426
+ message: `${plural(running.size, 'element')} still animating after load`,
427
+ fix: STILL_FIX,
428
+ selector: [...running.keys()][0],
429
+ items: [...running.values()].slice(0, 20),
430
+ });
431
+ }
432
+ if (d.hidden.length) {
433
+ out.push({
434
+ id: 'motion-reveal-hidden',
435
+ severity: 'warn',
436
+ message: `${plural(d.hidden.length, 'element')} in or near the viewport still hidden by a reveal animation`,
437
+ fix: REVEAL_FIX,
438
+ selector: d.hidden[0].selector,
439
+ items: d.hidden,
440
+ });
441
+ }
442
+ if (loops.length) {
443
+ out.push({
444
+ id: 'motion-loop',
445
+ severity: 'info',
446
+ message: `${plural(loops.length, 'infinite animation')} (loops never settle, so a shot shows one frame)`,
447
+ fix: 'Stop decorative loops under prefers-reduced-motion: reduce; rpc shot --reduced-motion shows what that looks like.',
448
+ selector: loops[0].selector,
449
+ items: loops,
450
+ });
451
+ }
452
+ if (d.pins.length) {
453
+ out.push({
454
+ id: 'motion-pinned',
455
+ severity: 'info',
456
+ message: `${plural(d.pins.length, 'pinned section')}: a stitched full-page shot would show the spacer as blank space`,
457
+ fix: 'rpc shot --full-page collapses pins by default (listed under motion.unpinned in the result); rpc record --selector shows the pinned animation.',
458
+ selector: d.pins[0].selector,
459
+ items: d.pins.map(pinItem),
460
+ });
461
+ }
462
+ if (d.carousels.length) {
463
+ out.push({
464
+ id: 'motion-carousel',
465
+ severity: 'info',
466
+ message: `${plural(d.carousels.length, 'carousel or marquee', 'carousels or marquees')}: a still shows one frame`,
467
+ fix: carouselFix(d.carousels),
468
+ selector: d.carousels[0].selector,
469
+ items: d.carousels,
470
+ });
471
+ }
472
+ return out;
473
+ }
474
+ /** `#a, .b +2 more`: where a warning applies, for one-line output. */
475
+ export function warningWhere(w, max = 3) {
476
+ const sels = (w.items ?? []).map(i => i.selector).filter(Boolean);
477
+ if (!sels.length)
478
+ return '';
479
+ return sels.slice(0, max).join(', ') + (sels.length > max ? ` +${sels.length - max} more` : '');
480
+ }
481
+ /** One line per collapsed pin: `#pinned (2100 css px of spacer removed)`. */
482
+ export function unpinnedLines(unpinned) {
483
+ return (unpinned ?? []).filter(u => u.removed > 0).map(u => `${u.selector} (${u.removed} css px of spacer removed)`);
484
+ }
485
+ function pinItem(p) {
486
+ return { selector: p.selector, text: `${p.spacerHeight}px ${p.kind === 'sticky' ? 'track' : 'spacer'} for a ${p.height}px section`, value: p.spacerHeight - p.height };
487
+ }