rpc-agent 0.4.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/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
+ }
package/dist/options.js CHANGED
@@ -19,11 +19,44 @@ export function defaultShotOptions(url) {
19
19
  timeoutMs: DEFAULT_TIMEOUT_MS,
20
20
  emulate: true,
21
21
  reducedMotion: false,
22
+ finishAnimations: false,
23
+ unpin: true,
22
24
  maxHeight: DEFAULT_MAX_HEIGHT,
23
25
  maxSlices: DEFAULT_MAX_SLICES,
24
26
  sliceWidth: DEFAULT_SLICE_WIDTH,
25
27
  };
26
28
  }
29
+ export const FAIL_ON = ['error', 'warn'];
30
+ export function defaultCheckOptions(url) {
31
+ return {
32
+ url: normalizeUrl(url),
33
+ devices: [...DEFAULT_DEVICES],
34
+ outDir: DEFAULT_OUT_DIR,
35
+ waitMs: 0,
36
+ timeoutMs: DEFAULT_TIMEOUT_MS,
37
+ emulate: true,
38
+ reducedMotion: false,
39
+ shots: false,
40
+ failOn: 'error',
41
+ };
42
+ }
43
+ export const DEFAULT_RECORD_DEVICE = 'desktop-1440';
44
+ export const DEFAULT_RECORD_SECONDS = 5;
45
+ export const MAX_RECORD_SECONDS = 30;
46
+ export function defaultRecordOptions(url) {
47
+ return {
48
+ url: normalizeUrl(url),
49
+ device: DEFAULT_RECORD_DEVICE,
50
+ duration: DEFAULT_RECORD_SECONDS,
51
+ gif: false,
52
+ mp4: false,
53
+ outDir: DEFAULT_OUT_DIR,
54
+ waitMs: 0,
55
+ timeoutMs: DEFAULT_TIMEOUT_MS,
56
+ emulate: true,
57
+ reducedMotion: false,
58
+ };
59
+ }
27
60
  /**
28
61
  * Accept what an agent is likely to type:
29
62
  * http(s)://…, file://… -> unchanged
package/dist/png.js CHANGED
@@ -8,6 +8,53 @@ export function pngSize(buf) {
8
8
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
9
9
  return { width: view.getUint32(16), height: view.getUint32(20) };
10
10
  }
11
+ /** Width/height of a PNG, JPEG, GIF or WebP from its header; undefined for anything else. */
12
+ export function imageSize(buf) {
13
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
14
+ const ascii = (at, len) => String.fromCharCode(...buf.subarray(at, at + len));
15
+ try {
16
+ if (buf.length >= 24 && SIGNATURE.every((b, i) => buf[i] === b))
17
+ return pngSize(buf);
18
+ if (ascii(0, 4) === 'GIF8' && buf.length >= 10) {
19
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
20
+ }
21
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
22
+ const kind = ascii(12, 4);
23
+ if (kind === 'VP8 ')
24
+ return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
25
+ if (kind === 'VP8L') {
26
+ const bits = view.getUint32(21, true);
27
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
28
+ }
29
+ if (kind === 'VP8X') {
30
+ const u24 = (at) => buf[at] | (buf[at + 1] << 8) | (buf[at + 2] << 16);
31
+ return { width: u24(24) + 1, height: u24(27) + 1 };
32
+ }
33
+ return undefined;
34
+ }
35
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
36
+ // Walk the JPEG segments to the first start-of-frame marker.
37
+ for (let at = 2; at + 9 < buf.length;) {
38
+ if (buf[at] !== 0xff)
39
+ return undefined;
40
+ const marker = buf[at + 1];
41
+ if (marker === 0xff) {
42
+ at++;
43
+ continue;
44
+ }
45
+ const len = view.getUint16(at + 2);
46
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
47
+ return { width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
48
+ }
49
+ at += 2 + len;
50
+ }
51
+ }
52
+ }
53
+ catch {
54
+ // Truncated header.
55
+ }
56
+ return undefined;
57
+ }
11
58
  /**
12
59
  * Decode an 8-bit RGB/RGBA non-interlaced PNG, which is what Chromium
13
60
  * screenshots are. Anything else throws: this is not a general decoder.