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/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ Motion: captures that match what a person scrolling sees, a Motion check,
6
+ and `rpc record`.
7
+
8
+ ### Added
9
+
10
+ - `rpc record <url>` writes a few seconds of video (VP9 webm) of the page
11
+ or of one element (`--selector`, scrolled into view and cropped to its
12
+ box), plus a poster PNG of the first frame. `--duration` (default 5, up
13
+ to 30 s), `-d` for one device (default `desktop-1440`), `--gif`, `--mp4`
14
+ (needs an ffmpeg with H.264 on PATH; skipped with a warning otherwise),
15
+ and the same auth flags as `rpc shot`. Frames come from a screenshot loop
16
+ at device pixels, encoded at a constant 25 fps by Playwright's own ffmpeg
17
+ build (already downloaded by `rpc install`), with a system ffmpeg as the
18
+ fallback. GIFs are encoded in Node with no new dependency.
19
+ - MCP `record` tool with the same options; it returns the paths, a summary
20
+ and the poster inline.
21
+ - `rpc shot --finish-animations` (MCP `finishAnimations`) jumps GSAP tweens
22
+ and non-scrubbed ScrollTriggers to their end when `gsap` / `ScrollTrigger`
23
+ are on `window`, and finite Web Animations, CSS animations and transitions
24
+ too. Counts are in `motion.finished`.
25
+ - `rpc shot --no-unpin` (MCP `unpin: false`) keeps pinned sections as they
26
+ are in full-page shots.
27
+ - Each shot has `warnings` when motion made the capture differ from the
28
+ page in use: elements still animating, a pinned section left in place,
29
+ content still hidden by a reveal, and carousels ("use `rpc record` for
30
+ motion"). The terminal prints them under the device and the MCP headline
31
+ lists them with a fix.
32
+ - `rpc check` has a Motion group: `motion-running` and
33
+ `motion-reveal-hidden` (warn), `motion-loop`, `motion-pinned` and
34
+ `motion-carousel` (info). Never an error.
35
+ - `rpc doctor` reports the ffmpeg `rpc record` would use and whether mp4 is
36
+ available. Its exit code still depends on Chromium only.
37
+
38
+ ### Changed
39
+
40
+ - `rpc shot` waits for the visible page to settle before capturing, and at
41
+ each warm-up scroll step: no style or class change on a visible element
42
+ for a short quiet period (and at the warm-up steps no finite animation
43
+ running on one; the screenshot ends CSS and Web Animations itself),
44
+ capped (2 s before capture, 800 ms per step, 8 s for the warm-up).
45
+ The result is in `motion.settle`.
46
+ - Full-page shots collapse pinned sections (GSAP-style `.pin-spacer`
47
+ wrappers, pins `ScrollTrigger.getAll()` reports, and sticky or fixed
48
+ sections that are the only child of a much taller parent), so each
49
+ section shows once instead of over a blank spacer. Each one is listed in
50
+ `motion.unpinned` with the CSS px removed.
51
+
52
+ ### Fixed
53
+
54
+ - `rpc check`: the tap-target check skips inline links in a sentence (WCAG
55
+ 2.5.8 inline exception). A link is skipped when its computed `display` is
56
+ `inline` and its block of text has letters or digits outside any
57
+ control. Buttons, `[role=button]`, links laid out as boxes, nav links and
58
+ icon links still count, and the threshold stays 44x44.
59
+ - Test servers answer 500 with the error when a fixture file is missing
60
+ instead of hanging, and the check suite stops early with "run pnpm
61
+ install" when `@fontsource/ibm-plex-mono` isn't installed.
62
+
63
+ ## 0.5.0
64
+
65
+ Ship-check: `rpc check <url>` audits a page before launch, from the CLI, in
66
+ CI or through MCP.
67
+
68
+ ### Added
69
+
70
+ - `rpc check <url>` runs on a phone and a desktop width by default (`-d`
71
+ to change) and reports findings with an id, severity (`error`, `warn`,
72
+ `info`), message, selector or URL, devices and a fix. Checks:
73
+ - Basics: HTTP status, mixed content, uncaught exceptions, console errors,
74
+ `theme-color`.
75
+ - Meta and social: title, description, canonical, `lang`, viewport (and
76
+ zoom blocking), favicon, apple-touch-icon, `og:title`,
77
+ `og:description`, `og:image`, `og:url`, `twitter:card`. `og:image` is
78
+ fetched for its status, content type and pixel size.
79
+ - Layout shift: CLS (largest session window) after load and one scroll,
80
+ with the elements that moved. Warn above 0.1, error above 0.25.
81
+ - Unlabelled controls: buttons, links and `[role=button]` with no
82
+ accessible name, images without `alt`, form fields without a label.
83
+ - Fonts: missing or `block` `font-display`, above-the-fold fonts that
84
+ aren't preloaded, preloads without `crossorigin`, unused preloads,
85
+ font files that fail.
86
+ - Mobile viewport (phones): `100vh`, sideways scroll with the elements
87
+ that cause it, tap targets under 44x44, text fields under 16px.
88
+ - Exit code 1 on errors; `--fail-on warn` fails on warnings too.
89
+ `--json` prints a versioned report (`schema: 1`, documented in the
90
+ README). `--shots` saves PNGs of the elements behind layout-shift and
91
+ overflow findings.
92
+ - Auth works as for `rpc shot`: `--cookie`, `--header`,
93
+ `--storage-state`, `--allow-origin`, saved `rpc login` sessions and
94
+ `AUTH_REQUIRED` on a redirect to sign-in.
95
+ - MCP `check` tool with the same options and the `screenshot` tool's auth
96
+ params. It returns a short text summary and the JSON report.
97
+
98
+ ### Unchanged
99
+
100
+ - `rpc shot`, its output and the MCP `screenshot` tool. The MCP tool list
101
+ gains `check`.
102
+
3
103
  ## 0.4.0
4
104
 
5
105
  Authenticated captures: screenshot logged-in pages without a hand-written
package/README.md CHANGED
@@ -63,6 +63,8 @@ these docs say `rpc`, or `pnpm rpc` from inside the repo.
63
63
  ```sh
64
64
  rpc # interactive: pick a URL, devices and options
65
65
  rpc shot <url> [options]
66
+ rpc check <url> [options] # pre-launch audit, see Ship-check below
67
+ rpc record <url> [options] # a few seconds of video of the page or one element
66
68
  rpc login <url> [--form] # sign in once; later shots of that origin use the session
67
69
  rpc sessions [list|clear [origin]]
68
70
  rpc devices [--json]
@@ -93,6 +95,8 @@ the files it wrote:
93
95
  | `--slice-width <px>` | Maximum slice width. Default 768 |
94
96
  | `--max-height <px>` | Full-page cap in CSS px. Default 60000 |
95
97
  | `--reduced-motion` | Emulate `prefers-reduced-motion: reduce` |
98
+ | `--finish-animations` | Jump animations to their end state before capturing. See [Animation-aware capture](#animation-aware-capture) |
99
+ | `--no-unpin` | With `--full-page`, keep pinned sections as they are instead of collapsing them |
96
100
  | `-o, --out <dir>` | Output directory. Default `./.rpc-shots` |
97
101
  | `--wait-for <selector>` | Wait for a visible element before capturing |
98
102
  | `--wait <ms>` | Extra settle delay after load |
@@ -148,6 +152,90 @@ and `100vh` sections keep their real height.
148
152
  Bezels frame the above-the-fold viewport. With `--bezels --full-page` you get
149
153
  both: the full page raw, and the first screen framed.
150
154
 
155
+ ### Animation-aware capture
156
+
157
+ A stitched shot of a page with scroll animations used to come out wrong in
158
+ three ways: pinned scroll sections left tall blank bands, text was caught
159
+ halfway through its reveal, and carousels froze on whatever slide was up.
160
+
161
+ #### Settle
162
+
163
+ Before each capture, and at every step of the full-page warm-up
164
+ scroll, rpc waits until the visible part of the page stops moving: no
165
+ `style` or `class` change on a visible element for 150 ms (120 ms per
166
+ warm-up step), and, at the warm-up steps, no finite animation running on
167
+ one. The screenshot itself ends finite CSS and Web Animations (Playwright's
168
+ `animations: 'disabled'`), so the wait just before it only watches for
169
+ JS-driven changes, the kind a GSAP tween makes. It gives up after 2 s
170
+ (800 ms per step, 8 s for the whole warm-up), so a page that never stops
171
+ can't hang it. Anything still changing or animating when a step runs out
172
+ of time is treated as a loop (a JS marquee, a ticker, a long CSS
173
+ animation), and later steps stop waiting for it. A static page only pays the quiet period. The result is on each shot as
174
+ `motion.settle`: `settled`, `ms`, and the selectors still active.
175
+
176
+ #### Pinned sections
177
+
178
+ GSAP ScrollTrigger pins a section by wrapping it in a
179
+ `.pin-spacer` with padding below it, then fixing the section in place while
180
+ you scroll through that padding. A full-page capture renders the page at
181
+ scroll 0, where the section sits at the top of its spacer and the padding
182
+ is empty, so the shot gets a blank band as tall as the pin's scroll
183
+ distance. With `--full-page`, rpc collapses each pin it finds. The spacer
184
+ loses its padding, the section goes back into normal flow, and the shot
185
+ shows it once with the rest of the page right after it. A sticky section
186
+ that is the only child of a much taller parent gets the same treatment. The
187
+ rules go in an injected stylesheet as `!important`, so when the library
188
+ rewrites its inline styles during the capture, they still win. Each
189
+ collapsed pin is listed in `motion.unpinned` with the CSS px removed, and
190
+ the stitched height is measured after the collapse. `--no-unpin` turns it
191
+ off.
192
+
193
+ | `--no-unpin` (before) | default (after) |
194
+ | --- | --- |
195
+ | <img alt="A full-page shot of a fixture with a pinned orange section: a 2100px white band sits between the section and the green outro" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/pinned-before.png" width="240"> | <img alt="The same page with the pin collapsed: blue intro, orange section, green outro, no gap" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/pinned-after.png" width="240"> |
196
+
197
+ #### Finish animations
198
+
199
+ `--finish-animations` is for JS-driven motion, which the screenshot can't
200
+ end on its own. It calls `.finish()` on every finite Web Animation (CSS
201
+ animations and transitions too, so layout measured before the capture is
202
+ the end state). If `window.gsap` exists,
203
+ tweens and timelines on its global timeline that don't repeat forever go to
204
+ `progress(1)`. If `window.ScrollTrigger` exists, triggers that aren't
205
+ scrubbed or pinned get their animation set to `progress(1)`. Scrubbed and
206
+ pinned ones follow the scroll position, so they keep their scroll-0 state.
207
+ Each library step runs in its own try/catch and does nothing when the
208
+ library isn't there. Infinite loops are left alone. The counts are in
209
+ `motion.finished`.
210
+
211
+ #### Reduced motion
212
+
213
+ `--reduced-motion` still emulates
214
+ `prefers-reduced-motion: reduce`. On a site that respects it, that's the
215
+ cleanest fix, because the animations never run.
216
+
217
+ #### Warnings
218
+
219
+ Each shot gets a `warnings` list in the JSON, and the terminal
220
+ prints them under the device:
221
+
222
+ ```
223
+ ok desktop-1440 (1440x900 @1x)
224
+ .rpc-shots/localhost-3000-desktop-1440.png 1440x900
225
+ warn: 3 elements were still animating when captured: div.reveal:nth-of-type(1), div.reveal:nth-of-type(2), div.reveal:nth-of-type(3)
226
+ warn: carousel: use `rpc record` for motion: #carousel
227
+ ```
228
+
229
+ | id | When |
230
+ | --- | --- |
231
+ | `motion-running` | Elements were still changing (style or class writes, as a JS tween makes) when the settle ran out of time: "N elements were still animating when captured". CSS and Web Animations don't count here, because the screenshot ends them |
232
+ | `motion-pinned` | Full page only. A pin was left in place (`--no-unpin`, or one that couldn't be collapsed): "pinned section: the stitched shot may show blank space" |
233
+ | `motion-reveal-hidden` | Content still at opacity 0, or offset by 24px or more, that looks like a reveal (see [Motion](#motion)) |
234
+ | `motion-carousel` | A carousel or marquee is in the shot: "carousel: use `rpc record` for motion" |
235
+
236
+ Each warning has a `fix` naming the flag to try. Warnings never change the
237
+ exit code.
238
+
151
239
  ### Terminal, pipes and CI
152
240
 
153
241
  The banner, spinners, prompts and boxes only appear when stdout is an
@@ -299,9 +387,299 @@ rpc shot localhost:3000/app --storage-state auth.json
299
387
  - **Use a test account.** Sign in as a user whose data you're happy for
300
388
  screenshots (and the agent reading them) to see.
301
389
 
390
+ ## Ship-check
391
+
392
+ `rpc check <url>` audits one page before you call it done. It loads the page
393
+ on a phone and a desktop width, the way `rpc shot` does, and lists what's
394
+ wrong: missing or broken social tags, layout shift, buttons a screen reader
395
+ can't name, fonts that hide text while they load, the mobile viewport bugs
396
+ that only show up on a phone, and motion a screenshot will get wrong.
397
+
398
+ ```sh
399
+ rpc check localhost:3000
400
+ rpc check localhost:3000 --fail-on warn --json # CI
401
+ rpc check example.com/pricing -d pixel,iphone-se,desktop --shots
402
+ ```
403
+
404
+ <img alt="rpc check in a terminal on turbo-start-paystack.vercel.app: both devices load with HTTP 200 and CLS 0.000, then a Meta and social box (no canonical link, no og:url) and a Mobile viewport box (16 of 54 tap targets under 44x44 px, with selectors and sizes), each finding with a fix, and the line 0 errors, 3 warnings, 0 info" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/terminal-check.png" width="720">
405
+
406
+ URLs, devices and auth work as in `rpc shot`: `localhost:3000` needs no
407
+ scheme, `-d` takes ids or aliases (default `iphone-15-pro,desktop-1440`),
408
+ and `--cookie`, `--header`, `--storage-state` and the session saved by
409
+ `rpc login` all apply. A page that redirects to a login URL fails with
410
+ `AUTH_REQUIRED`. Each run checks one URL. It isn't Lighthouse: CLS is the
411
+ only performance number it reports.
412
+
413
+ | Option | Meaning |
414
+ | --- | --- |
415
+ | `-d, --devices <ids>` | Devices to check on. The mobile checks run on phones only |
416
+ | `--fail-on <level>` | `error` (default) exits 1 on errors; `warn` exits 1 on warnings too |
417
+ | `--shots` | Save PNGs of the elements behind layout-shift and overflow findings into `--out` |
418
+ | `-o, --out <dir>` | Where `--shots` writes. Default `./.rpc-shots` |
419
+ | `--json` | Print the report as JSON (see below) |
420
+ | `--wait-for`, `--wait`, `--timeout`, `--color-scheme` | As in `rpc shot` |
421
+ | `--cookie`, `--header`, `--storage-state`, `--no-session`, `--allow-origin` | As in `rpc shot` |
422
+
423
+ The exit code is 0 when there are no errors (with `--fail-on warn`, no
424
+ warnings either), 1 otherwise and 2 for a usage error.
425
+
426
+ ### How a check runs
427
+
428
+ Each device gets a fresh browser context, the same one `rpc shot` uses.
429
+ Before the page's own scripts run, rpc installs a `PerformanceObserver` for
430
+ `layout-shift`. It loads the page and waits for network idle (up to 10 s),
431
+ web fonts and pending images. Then it waits a fixed 500 ms, scrolls down one
432
+ viewport and back with a 300 ms pause each way, and reads the page. Because
433
+ the sequence is fixed, CLS comes out the same on every run; the tests check
434
+ the same page twice and allow 0.005 of difference.
435
+
436
+ When a finding is identical on several devices it's reported once, with
437
+ every device listed, so a missing `lang` appears one time, not two.
438
+
439
+ ### What it checks
440
+
441
+ Severity decides the exit code. `error` fails the run, `warn` fails it only
442
+ with `--fail-on warn`, and `info` never does.
443
+
444
+ #### Basics
445
+
446
+ | id | Severity | When | Fix |
447
+ | --- | --- | --- | --- |
448
+ | `http-status` | error | The page answered 400 or above | Serve it with a 200 |
449
+ | `page-load-failed`, `auth-required` | error | The page didn't load on a device (DNS, refused, timeout) or landed on a login page | The error's own hint, e.g. ``run `rpc login <origin>` `` |
450
+ | `mixed-content` | error (warn if the browser upgraded every request) | `http://` requests on an https page | Load everything over https |
451
+ | `uncaught-exception` | error | Exceptions thrown during load, first three listed | Fix the first one; the rest often follow from it |
452
+ | `console-errors` | warn | `console.error` during load: the count and the first three | Fix or silence them |
453
+ | `theme-color-missing` | info | No `<meta name="theme-color">` | Add one to tint the mobile browser bar |
454
+
455
+ #### Meta and social
456
+
457
+ | id | Severity | When | Fix |
458
+ | --- | --- | --- | --- |
459
+ | `title-missing`, `title-long` | error, warn | No `<title>`, or one over 60 characters | 50 to 60 characters, key words first |
460
+ | `description-missing`, `description-long` | warn, info | No meta description, or one over 160 characters | One or two sentences |
461
+ | `canonical-missing` | warn | No `<link rel="canonical">` | The absolute URL to index |
462
+ | `lang-missing` | error | No `lang` on `<html>` | `<html lang="en">` |
463
+ | `viewport-missing`, `viewport-no-zoom` | error | No viewport meta, or one with `user-scalable=no` or `maximum-scale` under 2 | `width=device-width, initial-scale=1` and nothing that blocks zoom |
464
+ | `favicon-missing` | warn | No `<link rel="icon">`, and `/favicon.ico` doesn't load | Add an icon link |
465
+ | `apple-touch-icon-missing` | info | No `apple-touch-icon` | A 180x180 PNG |
466
+ | `og-title-missing`, `og-description-missing`, `og-image-missing`, `og-url-missing` | warn | That Open Graph tag is absent | Add it; link previews read it |
467
+ | `twitter-card-missing` | warn | No `twitter:card` | `summary_large_image` |
468
+ | `og-image-relative` | warn | `og:image` isn't an absolute URL | Make it absolute; most scrapers don't resolve relative URLs |
469
+ | `og-image-broken`, `og-image-not-image` | error | `og:image` doesn't return 200, or isn't served as an image | Point it at the image file |
470
+ | `og-image-small`, `og-image-ratio` | warn, info | Smaller than 600x315, or not the 1.91:1 shape of 1200x630 | Export it at 1200x630 |
471
+
472
+ rpc fetches `og:image` once per run and reads its pixel size from the file
473
+ header (PNG, JPEG, GIF or WebP). The result is in the report's `ogImage`.
474
+
475
+ #### Layout shift
476
+
477
+ | id | Severity | When | Fix |
478
+ | --- | --- | --- | --- |
479
+ | `layout-shift` | error above 0.25, warn above 0.1, info above 0 | CLS for the load and the scroll, with the three elements that moved most and each one's share | Reserve space for late content: `width`/`height` or `aspect-ratio` on media, `min-height` on async sections, nothing inserted above what's on screen |
480
+
481
+ The score is computed the way Chrome computes it: the largest session
482
+ window, meaning shifts less than 1 s apart and at most 5 s in total. Chrome
483
+ marks shifts that follow input as `hadRecentInput`, including any in the
484
+ first 500 ms after navigation, and those don't count. Totals under 0.001
485
+ count as zero. Each device's score is also in `devices[].cls`.
486
+
487
+ #### Unlabelled controls
488
+
489
+ | id | Severity | When | Fix |
490
+ | --- | --- | --- | --- |
491
+ | `button-no-name`, `link-no-name` | error | A button, link or `[role=button]` with no accessible name, such as an icon-only button | `aria-label`, visually hidden text, or alt text on the image inside |
492
+ | `img-no-alt` | error | An `<img>` with no `alt` attribute (`alt=""` is fine) | Describe the image, or `alt=""` if it's decorative |
493
+ | `field-no-label` | error | An input, select or textarea with no label | `<label for>`, a wrapping `<label>`, or `aria-label` |
494
+ | `field-placeholder-only` | warn | A field labelled only by its placeholder | Add a visible label |
495
+
496
+ rpc works out accessible names in the page from `aria-labelledby`,
497
+ `aria-label`, `<label>`, `alt`, the text inside (minus anything
498
+ `aria-hidden`), an SVG `<title>` and `title`. It skips elements assistive
499
+ tech can't reach: `display: none`, `visibility: hidden`, `aria-hidden` and
500
+ `inert`.
501
+
502
+ #### Fonts
503
+
504
+ | id | Severity | When | Fix |
505
+ | --- | --- | --- | --- |
506
+ | `font-display-missing`, `font-display-block` | warn | A loaded `@font-face` has no `font-display` (Chrome then hides its text for up to 3 s) or uses `block`. The message says when it sets text above the fold (FOIT) | `font-display: swap` or `optional`; `&display=swap` for Google Fonts |
507
+ | `font-not-preloaded` | warn | A font file that sets text in the first viewport isn't preloaded | `<link rel="preload" as="font" type="font/woff2" crossorigin>` |
508
+ | `font-preload-no-crossorigin` | warn | A font preload without `crossorigin`, so the file downloads twice | Add `crossorigin` |
509
+ | `font-preload-unused` | info | A preloaded font that no `@font-face` uses | Remove it, or match the URL exactly |
510
+ | `font-failed` | error | A font file returned 400 or above | Fix the `src` URL |
511
+
512
+ Every font finding names the file URL. The `@font-face` rules come from the
513
+ page's stylesheets; the page can't read cross-origin ones (Google Fonts, a
514
+ CDN), so rpc downloads and parses those itself. Faces with only `local()`
515
+ sources, like next/font's size-adjusted fallbacks, never block text and are
516
+ skipped.
517
+
518
+ #### Mobile viewport
519
+
520
+ These run on phone devices only.
521
+
522
+ | id | Severity | When | Fix |
523
+ | --- | --- | --- | --- |
524
+ | `horizontal-overflow` | error | `scrollWidth` is wider than the layout viewport. Lists the outermost elements that stick out, furthest first | `max-width: 100%` on the element, or `overflow-x: auto` on its wrapper |
525
+ | `vh-units` | warn | A rendered element sized with `100vh` in `height`, `min-height` or `max-height`, from a stylesheet rule that applies or an inline style | `100dvh` or `100svh`, with `100vh` before it as a fallback |
526
+ | `tap-target-small` | warn | Links, buttons and form controls smaller than 44x44 CSS px. Inline links in a sentence are exempt, per the WCAG 2.5.8 inline exception: a link with `display: inline` and other text in the same block. Buttons and links laid out as boxes (`inline-block`, `flex`, `block`) still count | Padding, `min-height`, or a bigger hit area |
527
+ | `input-zoom` | warn | A text field with `font-size` under 16px; iOS Safari zooms the page when it gets focus | `font-size: 16px` or more |
528
+
529
+ A headless phone has no browser bars, so `100vh` and `100dvh` measure the
530
+ same there. That's why `vh-units` reads the CSS instead of measuring
531
+ elements. The `height: 100vh; height: 100dvh` fallback pattern isn't
532
+ flagged: the browser keeps only the last value it understands.
533
+
534
+ #### Motion
535
+
536
+ After the scroll, rpc gives running animations up to 1 s to stop, then
537
+ looks at the viewport plus half a viewport above and below. These findings
538
+ are about whether a screenshot comes out right, and a page can ship with
539
+ them, so they are `warn` or `info` and never `error`.
540
+
541
+ | id | Severity | When | Fix |
542
+ | --- | --- | --- | --- |
543
+ | `motion-running` | warn | Finite animations (from `document.getAnimations()`) or style changes still going on visible elements | `rpc shot --finish-animations`, `--wait`, or `--reduced-motion` |
544
+ | `motion-reveal-hidden` | warn | Elements with text or media at opacity under 0.1, `visibility: hidden`, offset by 24px or more, or scaled under 0.85, that also carry a reveal class (`reveal`, `fade`, `aos`, `in-view`, `appear`, `animated` and similar; utility words such as `scroll`, `visible` or `animate-` don't count), a reveal attribute (`data-aos`, `data-sal`, `data-scroll` and similar) or inline opacity or transform written by a library. Carousel tracks, elements inside a carousel library root, and `translate(-50%)` centring are not offsets. Only the outermost element is listed | Make sure the reveal fires, and that the content shows without JavaScript or animation |
545
+ | `motion-loop` | info | Infinite animations: spinners, pulses, CSS marquees | Stop them under `prefers-reduced-motion: reduce` |
546
+ | `motion-pinned` | info | A `.pin-spacer` taller than its section by a quarter of the viewport, a pin that `ScrollTrigger.getAll()` reports as its parent's only child, or a sticky or fixed section (half a viewport tall or more) that is the only child of a parent at least half a viewport taller | `rpc shot --full-page` collapses it; `rpc record --selector` shows it moving |
547
+ | `motion-carousel` | info | Swiper, Embla, Splide, Keen, Flickity, Slick or Glide roots, `aria-roledescription="carousel"`, horizontal scrollers with `scroll-snap-type` on x, and tracks whose scroll position or transform changes during a 300 ms sample | `rpc record <url> --selector '<the carousel>'` |
548
+
549
+ ### JSON report
550
+
551
+ `--json` prints this object, and the MCP `check` tool returns the same one.
552
+ `schema` goes up only when a field changes meaning or is removed; new fields
553
+ can appear without it.
554
+
555
+ ```jsonc
556
+ {
557
+ "schema": 1,
558
+ "url": "https://example.com/",
559
+ "devices": [
560
+ {
561
+ "device": "iphone-15-pro",
562
+ "name": "iPhone 15 Pro",
563
+ "viewport": { "width": 393, "height": 852 },
564
+ "dpr": 3,
565
+ "mobile": true, // the phone-only checks ran here
566
+ "status": 200,
567
+ "finalUrl": "https://example.com/",
568
+ "title": "Example",
569
+ "cls": 0.012,
570
+ "layoutShifts": 2,
571
+ // only when the page didn't load on this device:
572
+ "error": "…", "code": "ERR_NAME_NOT_RESOLVED", "hint": "…"
573
+ }
574
+ ],
575
+ "summary": { "errors": 1, "warnings": 4, "info": 2 },
576
+ "findings": [
577
+ {
578
+ "id": "horizontal-overflow", // stable; see the tables above
579
+ "check": "mobile", // basics | meta | layout-shift | labels | fonts | mobile
580
+ "severity": "error", // error | warn | info
581
+ "message": "The page scrolls sideways: 601px wide in a 393px viewport",
582
+ "fix": "Find the element that sticks out …",
583
+ "devices": ["iphone-15-pro"],
584
+ "selector": "div.pricing-table", // when one element is the answer
585
+ "url": "…", // when a file is (a font, og:image)
586
+ "value": 208, // the number behind it, if there is one
587
+ "items": [ // elements, files or log lines, when there are several
588
+ {
589
+ "selector": "div.pricing-table",
590
+ "text": "600px wide, 207px past the edge",
591
+ "value": 207,
592
+ "screenshot": "/abs/.rpc-shots/…-check-horizontal-overflow-1.png" // with --shots
593
+ }
594
+ ]
595
+ }
596
+ ],
597
+ "ogImage": { "url": "…", "status": 200, "contentType": "image/png", "width": 1200, "height": 630 },
598
+ "auth": { … } // as in rpc shot, values masked
599
+ }
600
+ ```
601
+
602
+ Findings are sorted by check, in the order of the tables above, then by
603
+ severity. `id`, `check`, `severity`, `message`, `fix` and `devices` are
604
+ always there; the other fields appear when they apply.
605
+
606
+ ### In CI
607
+
608
+ ```sh
609
+ npx rpc-agent install
610
+ npx rpc-agent check https://staging.example.com --fail-on warn --json > ship-check.json
611
+ ```
612
+
613
+ The exit code fails the job, and the JSON can go into a PR comment or a
614
+ build artifact. For a page behind a login, sign in first with
615
+ `rpc login <url> --form` (see [CI and agents](#ci-and-agents-rpc-login---form)).
616
+
617
+ ### With a coding agent
618
+
619
+ Add the MCP server (below) and tell the agent to run `check` before it
620
+ reports UI work as finished, for example in `CLAUDE.md` or `AGENTS.md`:
621
+
622
+ ```md
623
+ Before you say UI work is done, run the rpc `check` tool on each page you
624
+ changed and fix every error. Fix the warnings too, or say why one doesn't apply.
625
+ ```
626
+
627
+ ## Recording motion: `rpc record`
628
+
629
+ A still can't show a carousel, a marquee, or a pinned section whose panels
630
+ slide as you scroll. `rpc record` takes a few seconds of video of the page,
631
+ or of one element on it.
632
+
633
+ ```sh
634
+ rpc record localhost:3000 --selector '.hero-carousel' # 5 s of one element
635
+ rpc record localhost:3000 -d iphone --duration 10 --gif # whole phone viewport, plus a GIF
636
+ rpc record example.com --selector '#logos' --mp4 # mp4 too, if ffmpeg is on PATH
637
+ ```
638
+
639
+ | Option | Meaning |
640
+ | --- | --- |
641
+ | `--selector <css>` | Record this element. rpc scrolls it into view, waits for reveals around it to settle, and crops the video to its box. If it's bigger than the viewport, the visible part is recorded and the result says so |
642
+ | `--duration <s>` | Seconds of video, 0.5 to 30. Default 5 |
643
+ | `-d, --device <id>` | One device id or alias. Default `desktop-1440` |
644
+ | `--gif` | Also write a GIF: 10 fps, at most 640 px wide |
645
+ | `--mp4` | Also write an mp4. This needs an ffmpeg with an H.264 encoder on PATH; without one, rpc skips the mp4 and says so |
646
+ | `-o, --out <dir>`, `--json`, `--open` | As in `rpc shot` |
647
+ | `--wait-for`, `--wait`, `--timeout`, `--color-scheme`, `--reduced-motion`, `--no-emulate` | As in `rpc shot` |
648
+ | `--cookie`, `--header`, `--storage-state`, `--no-session`, `--allow-origin` | As in `rpc shot` |
649
+
650
+ It writes `<host>[-<path>]-<device>[-<selector>].webm` (VP9) and a
651
+ `-poster.png` of the first frame, plus the `.gif` and `.mp4` when asked:
652
+
653
+ ```
654
+ ok iphone-15-pro (393x852 @3x) #carousel
655
+ .rpc-shots/localhost-3000-iphone-15-pro-carousel.webm 1080x600, 3s at 25 fps (90 frames captured, playwright ffmpeg)
656
+ .rpc-shots/localhost-3000-iphone-15-pro-carousel-poster.png 1080x600 poster
657
+ .rpc-shots/localhost-3000-iphone-15-pro-carousel.gif 640x356, 32 frames
658
+ ```
659
+
660
+ <img alt="A GIF recorded from a fixture's autoplaying carousel: four coloured slides advancing in turn" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/record-carousel.gif" width="360">
661
+
662
+ Frames come from a loop of `page.screenshot()` calls clipped to the
663
+ element's box. Headless Chromium returns those at device pixels and cropped
664
+ exactly, at about 30 fps on a DPR 2 or 3 phone and 60 fps at DPR 1. The two
665
+ obvious alternatives do worse. CDP screencast frames arrive at CSS pixel
666
+ size in headless mode (393x852 for a DPR 3 phone), so they lose the
667
+ pixel density and need a scaled crop. Playwright's `recordVideo` records the
668
+ whole viewport at CSS size with a fixed bitrate, and it can't crop.
669
+
670
+ The frames go to ffmpeg as MJPEG at a constant 25 fps. Each one is repeated
671
+ until the next one's timestamp, so the video is as long as you asked for,
672
+ whatever rate the screenshots came in at. The ffmpeg is Playwright's own
673
+ build, which `rpc install` already downloads next to Chromium. It writes
674
+ VP9 webm and PNG but has no H.264 encoder, which is why webm is the default
675
+ and mp4 needs a system ffmpeg. If Playwright's build is missing, rpc falls
676
+ back to `ffmpeg` on PATH. GIFs are encoded in Node (`src/gif.ts`) from PNG
677
+ frames that ffmpeg decodes out of the webm, so they add no dependency.
678
+ `rpc doctor` says which ffmpeg it found and whether mp4 will work.
679
+
302
680
  ## MCP server
303
681
 
304
- `rpc mcp` runs an MCP server on stdio with three tools.
682
+ `rpc mcp` runs an MCP server on stdio with five tools.
305
683
 
306
684
  ### `screenshot`
307
685
 
@@ -316,6 +694,8 @@ rpc shot localhost:3000/app --storage-state auth.json
316
694
  | `sliceWidth` | 768 | full page: max slice width in px (raise it for small text on desktop) |
317
695
  | `maxHeight` | 60000 | full page: CSS px cap |
318
696
  | `reducedMotion` | false | emulate `prefers-reduced-motion: reduce` |
697
+ | `finishAnimations` | false | jump animations (Web Animations, GSAP on `window`) to their end first |
698
+ | `unpin` | true | full page: collapse pinned sections so each shows once |
319
699
  | `waitFor`, `waitMs`, `colorScheme`, `outDir`, `timeoutMs` | | as in the CLI |
320
700
  | `inline` | true | return images, not just paths |
321
701
  | `cookies` | | `[{ name, value, domain?, path? }]`, set on the target origin |
@@ -327,7 +707,10 @@ rpc shot localhost:3000/app --storage-state auth.json
327
707
  The result starts with a plain-text block: one line per device (or the error
328
708
  and hint), followed by the JSON summary (file paths, pixel sizes,
329
709
  `scrollHeight`, `capturedHeight`, `segments`, `clipped`, `slices`, HTTP status,
330
- final URL, title, console errors, `error`/`code`/`hint`).
710
+ final URL, title, console errors, `motion`, `warnings`, `error`/`code`/`hint`).
711
+ Each motion warning also gets its own line under its device, followed by
712
+ the fix, like ``WARN [motion-carousel] carousel: use `rpc record` for motion
713
+ at #carousel``.
331
714
 
332
715
  Then the images:
333
716
 
@@ -355,6 +738,46 @@ session, cookies and headers (values masked), and the JSON summary gains an
355
738
  `AUTH_REQUIRED` and the `rpc login` hint. Signing in is something the user
356
739
  does in a terminal (`rpc login`); the agent then just calls `screenshot`.
357
740
 
741
+ ### `check`
742
+
743
+ Runs [Ship-check](#ship-check) on one URL.
744
+
745
+ | Param | Default | |
746
+ | --- | --- | --- |
747
+ | `url` | required | http(s), file, bare host or local path |
748
+ | `devices` | `["iphone-15-pro", "desktop-1440"]` | ids or aliases; the mobile checks run on phones |
749
+ | `shots` | false | save PNGs of the elements behind layout-shift and overflow findings |
750
+ | `outDir`, `waitFor`, `waitMs`, `colorScheme`, `timeoutMs` | | as for `screenshot` |
751
+ | `cookies`, `headers`, `storageState`, `useSession`, `allowOrigins` | | as for `screenshot` |
752
+
753
+ The result is two text blocks. The first is a short summary: the counts,
754
+ then one line per error and warning with its id and selector, like
755
+ `ERROR [button-no-name] 2 buttons with no accessible name at button.icon-menu`.
756
+ The second is the JSON report described under [JSON report](#json-report).
757
+ The call is a tool error only when the page failed to load on every device;
758
+ findings, even errors, come back as a normal result for the agent to act on.
759
+
760
+ ### `record`
761
+
762
+ Runs [`rpc record`](#recording-motion-rpc-record) on one URL.
763
+
764
+ | Param | Default | |
765
+ | --- | --- | --- |
766
+ | `url` | required | http(s), file, bare host or local path |
767
+ | `device` | `desktop-1440` | one id or alias |
768
+ | `selector` | whole viewport | CSS selector of the element to record |
769
+ | `duration` | 5 | seconds, 0.5 to 30 |
770
+ | `gif` | false | also write a GIF |
771
+ | `mp4` | false | also write an mp4, when a system ffmpeg has H.264 |
772
+ | `reducedMotion`, `waitFor`, `waitMs`, `colorScheme`, `outDir`, `timeoutMs` | | as for `screenshot` |
773
+ | `cookies`, `headers`, `storageState`, `useSession`, `allowOrigins` | | as for `screenshot` |
774
+
775
+ The result starts with a summary line (`ok iphone-15 #carousel: 3s webm
776
+ 1080x600 at 25 fps <path>`, then the poster, GIF and mp4 paths) and the
777
+ JSON result. The poster comes back inline as a JPEG, so the agent can see
778
+ what it recorded. An element that isn't there is a tool error with the code
779
+ `SELECTOR_NOT_FOUND`.
780
+
358
781
  ### `list_devices`
359
782
 
360
783
  Takes an optional `category` (`apple-phone`, `android-phone`, `tablet`,
@@ -445,6 +868,13 @@ appear once, that the IntersectionObserver section rendered, and the slice
445
868
  count and labels, including through the MCP server. The missing-browser tests
446
869
  point `PLAYWRIGHT_BROWSERS_PATH` at an empty temp dir.
447
870
 
871
+ The Ship-check tests serve `test/fixtures/check`: one page per check
872
+ (`meta.html`, `shift.html`, `labels.html`, `fonts.html`, `mobile.html`,
873
+ `basics.html`) and `clean.html`, which must produce no errors or warnings.
874
+ `shift.html` inserts a 300 px banner 800 ms after load, so CLS is known in
875
+ advance (300/852 on the phone, 300/1440 on desktop); the test runs it twice
876
+ and compares.
877
+
448
878
  The README media are built from real captures by `scripts/media/build.ts`;
449
879
  `docs/media.md` explains how. The CLI's design notes, and what it borrows
450
880
  from the `skills` CLI, are in `docs/cli-design.md`.
@@ -454,10 +884,19 @@ from the `skills` CLI, are in `docs/cli-design.md`.
454
884
  - Chromium only, portrait only. There is no landscape mode, and none of RPC's
455
885
  fake browser chrome bars.
456
886
  - Scroll-driven animations are captured in their scroll-top state. Entrance
457
- animations that run once are usually fine after the warm-up scroll; ones
887
+ animations that run once are fine after the warm-up scroll and settle. Ones
458
888
  that reverse when scrolled back (GSAP `toggleActions` with reverse, scrubbed
459
- timelines) may show their initial state. `reducedMotion` helps on sites that
460
- respect it.
889
+ timelines) may show their initial state, and a collapsed pin shows its
890
+ section's first frame. `--finish-animations` only reaches GSAP when it's on
891
+ `window`; GSAP bundled into the app's JavaScript isn't, so its tweens stay
892
+ as they are. `--reduced-motion` helps on sites that respect it.
893
+ - Pins are found by GSAP's `.pin-spacer` class, `ScrollTrigger.getAll()`, and
894
+ sticky or fixed sections that are the only child of a tall parent. A pin
895
+ built some other way is still captured with its blank spacer.
896
+ - `rpc record` records what the page does on its own. It doesn't scroll or
897
+ click, so a scrubbed section only moves if something else moves it. Frames
898
+ come at the screenshot rate (about 30 fps on high-DPR phones), and the
899
+ webm repeats frames to hold 25 fps.
461
900
  - Very tall stitched PNGs are big files; review them through the slices.
462
901
  - The bezel geometry is RPC's, unchanged. On kind-default phones the page
463
902
  covers the dynamic island or notch, and generic monitors are drawn edge to
@@ -466,6 +905,11 @@ from the `skills` CLI, are in `docs/cli-design.md`.
466
905
  - Login-page detection goes by the URL path. An app that shows its sign-in
467
906
  form at the requested URL without redirecting is captured as is.
468
907
  - Every MCP call launches a fresh Chromium, which adds about a second.
908
+ - Ship-check reads `100vh` only from stylesheets the page can read and from
909
+ inline styles; a `100vh` rule in a cross-origin stylesheet is missed. Tap
910
+ targets are measured by their box, so a hit area enlarged with a
911
+ pseudo-element still counts as small. CLS covers the load and one scroll,
912
+ not shifts caused by clicking around.
469
913
 
470
914
  ## License
471
915