motion-audit 0.0.0-stage → 1.0.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 +30 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/package.json +58 -4
- package/skills/motion-audit/SKILL.md +225 -0
- package/skills/motion-audit/references/fixes.md +25 -0
- package/skills/motion-audit/scripts/lib/args.mjs +88 -0
- package/skills/motion-audit/scripts/lib/audit.mjs +342 -0
- package/skills/motion-audit/scripts/lib/browser.mjs +240 -0
- package/skills/motion-audit/scripts/lib/cli.mjs +71 -0
- package/skills/motion-audit/scripts/lib/compare.mjs +70 -0
- package/skills/motion-audit/scripts/lib/config.mjs +101 -0
- package/skills/motion-audit/scripts/lib/constants.mjs +161 -0
- package/skills/motion-audit/scripts/lib/continuity.mjs +81 -0
- package/skills/motion-audit/scripts/lib/index.mjs +9 -0
- package/skills/motion-audit/scripts/lib/judge.mjs +128 -0
- package/skills/motion-audit/scripts/lib/kinds.mjs +33 -0
- package/skills/motion-audit/scripts/lib/page-scripts.mjs +259 -0
- package/skills/motion-audit/scripts/lib/page-tracer.mjs +188 -0
- package/skills/motion-audit/scripts/lib/playwright.mjs +36 -0
- package/skills/motion-audit/scripts/lib/probes.mjs +504 -0
- package/skills/motion-audit/scripts/lib/reduced.mjs +39 -0
- package/skills/motion-audit/scripts/lib/series.mjs +278 -0
- package/skills/motion-audit/scripts/lib/sightings.mjs +76 -0
- package/skills/motion-audit/scripts/lib/undecided.mjs +86 -0
- package/skills/motion-audit/scripts/motion-audit.mjs +7 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
First release as an installable plugin and as the npm package `motion-audit`. The package ships the
|
|
6
|
+
`skills/motion-audit` directory (CLI, library and skill together); `bin` and `exports` point inside it.
|
|
7
|
+
|
|
8
|
+
- Skill `motion-audit` and CLI `motion-audit`: per-frame measurement of hover, press and keyboard focus,
|
|
9
|
+
click transitions, interruption, scroll, long frames and reduced motion in Chromium.
|
|
10
|
+
- Observe-only by default (real hover, `:active` and `:focus-visible` forced over CDP); `--interact`
|
|
11
|
+
adds real presses and toggle probes behind a denylist, with a fresh page load between probes, an even
|
|
12
|
+
number of clicks per toggle and a stop when a gesture navigates.
|
|
13
|
+
- Coverage contract: `audited N of M`, skips with reasons, exit 2 when nothing was audited,
|
|
14
|
+
`--min-coverage`.
|
|
15
|
+
- The tracer follows nodes mounted during a recording (portals, conditionally rendered overlays);
|
|
16
|
+
Animation-object restart detection; interruption analysis normalised by time; flaky kinds kept only
|
|
17
|
+
when two of three runs agree.
|
|
18
|
+
- Config file with bands, duration tokens, per-kind severity and ignores, all requiring a reason;
|
|
19
|
+
default profile where `no-entrance` is info and opt-in and missing press or hover on links and inputs
|
|
20
|
+
is never an error.
|
|
21
|
+
- `audit(pageOrUrl, options)` accepts a Playwright `page`; `--storage-state`, `--wait-for`, redirect
|
|
22
|
+
detection, hit-testing, inner scroll containers, hidden-until-hover controls, development-build
|
|
23
|
+
detection, long-frame attribution from LoAF scripts.
|
|
24
|
+
- JSON report `schemaVersion: 1` with coverage and `loafSupported`; stable `kind:hash` finding ids,
|
|
25
|
+
`--baseline`, `compare <ref.json> <mine.json>`.
|
|
26
|
+
- Argument validation (`--help` exits 0, unknown options and invalid numbers exit 2); Playwright is
|
|
27
|
+
resolved from `PLAYWRIGHT_ROOT`, then the working directory, with real load errors surfaced and a 1.59
|
|
28
|
+
minimum; reports go to a temp directory unless `--out` is given.
|
|
29
|
+
- Packaging: Claude Code plugin and marketplace manifests, `references/fixes.md`, trigger evals, unit and
|
|
30
|
+
fixture tests, CI on Linux, Windows and macOS.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nahuel Mosse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,141 @@
|
|
|
1
|
-
#
|
|
1
|
+
# motion-audit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A CLI, a library and a Claude Code skill that measure how a running web UI moves, in a real Chromium: which
|
|
4
|
+
controls give no hover, press or keyboard-focus feedback, which transitions snap or jank, long frames,
|
|
5
|
+
ease-in curves and off-band durations, animations that jump or restart when interrupted, and
|
|
6
|
+
`prefers-reduced-motion` that is missing or broken. It records the element and its children on every
|
|
7
|
+
animation frame instead of trusting what the CSS declares.
|
|
8
|
+
|
|
9
|
+
It is **observe-only by default**: it hovers, forces `:active` and `:focus-visible` over the DevTools
|
|
10
|
+
protocol, scrolls and reads. It never clicks unless you pass `--interact` (or name a control with
|
|
11
|
+
`--click`), so it cannot toggle a Follow button or submit a form, and no focus, key or press handler
|
|
12
|
+
runs. The pointer and the scroll are real, though: handlers bound to hover (`mouseenter`, `mouseover`,
|
|
13
|
+
`pointerenter` that send a beacon or write storage), scroll-bound `IntersectionObserver` callbacks and
|
|
14
|
+
`beforeunload` beacons run. Audit a throwaway or staging environment, never one where those events
|
|
15
|
+
are billed or irreversible.
|
|
16
|
+
|
|
17
|
+
## Install and run
|
|
18
|
+
|
|
19
|
+
Node 22.12+ and Playwright 1.59+ with Chromium in the project you audit.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm i -D motion-audit playwright
|
|
23
|
+
npx playwright install chromium
|
|
24
|
+
npx motion-audit http://localhost:3000/orders
|
|
25
|
+
npx motion-audit http://localhost:3000/orders --cpu 4 --click '[aria-controls=filters]' --interrupt
|
|
26
|
+
npx motion-audit compare ref.json mine.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Exit `0` clean, `1` findings, `2` could not run or audited nothing. Audit a production build
|
|
30
|
+
(`next start`, `vite preview`): on a dev server long frames are downgraded to warnings.
|
|
31
|
+
|
|
32
|
+
| Option | Meaning |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `--only <sel>` | audit only matching controls (repeatable) |
|
|
35
|
+
| `--max <n>` | audit at most n controls, default 40; the summary says `audited N of M` |
|
|
36
|
+
| `--min-coverage <0-1>` | exit 2 when audited / candidates is lower; controls beyond `--max` count as not audited, so raise `--max` too |
|
|
37
|
+
| `--interact` | real press and toggle probes; a denylist skips submit and reset controls, unnamed buttons, every link that is not `#` or an `#id` on the page, destructive wording in six languages, drag and slider controls |
|
|
38
|
+
| `--click <sel>` `--interrupt` | record a real click (and a second one half-way); clicks the target twice, four times with `--interrupt`, and at most eight times (sixteen) when sparse frames or a flaky finding force a re-measure (a retry adds one press, and a press's effect has to land within 1.5 s: a request still pending at the next reload is reported in the notes, but one a timer sends later cannot be seen; when the count would end odd without `--interrupt` the target is pressed once more, so the state it keeps ends as it began; with `--interrupt` every recording presses twice and the count is always even): throwaway environments only |
|
|
39
|
+
| `--allow-redirect` | audit the page the URL redirects to; without it any redirect, from the server or from script, exits 2, and a login page always does |
|
|
40
|
+
| `--cpu <rate>` | CPU throttle, 4 is a mid-range phone |
|
|
41
|
+
| `--frames <sel>` | contact sheet of hover and press with real timestamps |
|
|
42
|
+
| `--entrances` | also report sections without an entrance (info) |
|
|
43
|
+
| `--storage-state <file>` | logged-in Playwright storage state; a redirect to a login page exits 2 |
|
|
44
|
+
| `--wait-for <sel>` | wait for a selector before auditing |
|
|
45
|
+
| `--config <file>` | config file, default `./motion-audit.config.json` |
|
|
46
|
+
| `--baseline <report.json>` | ignore findings already in an earlier report |
|
|
47
|
+
| `--out <dir>` | report and sheets; default a fresh temp directory |
|
|
48
|
+
| `--strict` | warnings fail too |
|
|
49
|
+
| `--json` | the report, `schemaVersion: 1`, with coverage, `loafSupported` and finding ids |
|
|
50
|
+
|
|
51
|
+
## Config
|
|
52
|
+
|
|
53
|
+
`motion-audit.config.json` in the audited project. Everything that loosens the audit needs a reason:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"bands": { "reason": "Carbon micro-interactions run at 70 ms", "hover": [60, 300] },
|
|
58
|
+
"tokens": { "reason": "design-system duration tokens", "durations": [70, 120, 200] },
|
|
59
|
+
"severity": {
|
|
60
|
+
"inconsistent-duration": { "level": "info", "reason": "size-scaled durations are intended" },
|
|
61
|
+
"duration": "error"
|
|
62
|
+
},
|
|
63
|
+
"ignore": [
|
|
64
|
+
{ "kind": "no-feedback", "element": "a \"Skip to content\"*", "reason": "visible only on focus" },
|
|
65
|
+
{ "id": "no-feedback:3f2a9c1d07", "reason": "tracked in the design-system backlog" }
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`bands` are `[min, max]` ms for `hover`, `press` and `focus`. `severity` levels are `error`, `warning`,
|
|
71
|
+
`info` and `off`; lowering a kind needs the object form with a reason, raising does not. `ignore` takes a
|
|
72
|
+
finding id (`kind:hash`, printed after each finding) or a kind plus an element glob; wildcard-only ids
|
|
73
|
+
and elements are rejected. Defaults: `no-entrance` is info and opt-in, a missing press or hover on links
|
|
74
|
+
and inputs is never an error, durations and consistency are warnings.
|
|
75
|
+
|
|
76
|
+
## CI
|
|
77
|
+
|
|
78
|
+
With Playwright Test, in a project that installed this package:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { test, expect } from '@playwright/test';
|
|
82
|
+
import { audit } from 'motion-audit';
|
|
83
|
+
|
|
84
|
+
test('orders: motion', async ({ page }) => {
|
|
85
|
+
test.setTimeout(240_000);
|
|
86
|
+
await page.goto('/orders');
|
|
87
|
+
const report = await audit(page, { only: ['main button', 'nav a'], max: 20 });
|
|
88
|
+
expect(report.coverage.audited).toBeGreaterThan(0);
|
|
89
|
+
expect(report.findings.filter((f) => f.level === 'error')).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`audit(pageOrUrl, options)` takes a Playwright `page` (its `baseURL` and `storageState` apply) or a URL
|
|
94
|
+
(options: `only`, `max`, `cpu`, `click`, `interrupt`, `interact`, `frames`, `entrances`, `config`,
|
|
95
|
+
`baseline`, `storageState`, `waitFor`, `allowRedirect`, `out`). With a `page`, the CPU throttle is
|
|
96
|
+
removed, the CDP session detached and the tracer stopped and unregistered when the audit ends. Or from
|
|
97
|
+
the shell, failing on new findings only:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx motion-audit "$APP_URL/orders" --max 200 --min-coverage 0.8 --baseline motion-baseline.json
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Claude Code skill
|
|
104
|
+
|
|
105
|
+
The same tool as a skill: Claude measures the page, reads the findings and fixes the CSS. The skill is the
|
|
106
|
+
`skills/motion-audit` directory, self-contained (CLI and library included), so the plugin, `npx skills add
|
|
107
|
+
NahuelMosse/motion-audit` and a copy of that directory all work. Inside the skill, `npx --no-install
|
|
108
|
+
motion-audit` is preferred when the project has the devDependency; otherwise it runs the bundled script.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
claude plugin marketplace add NahuelMosse/motion-audit
|
|
113
|
+
claude plugin install motion-audit@motion-audit
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Pin a release by adding the tag to the marketplace source, and update with the plugin commands:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
claude plugin marketplace add NahuelMosse/motion-audit#v1.0.0
|
|
120
|
+
claude plugin marketplace update motion-audit
|
|
121
|
+
claude plugin update motion-audit@motion-audit
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The skill needs the same Node and Playwright. Set `PLAYWRIGHT_ROOT` to a project that has
|
|
125
|
+
it when the audited project does not.
|
|
126
|
+
|
|
127
|
+
## Develop
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm ci
|
|
131
|
+
npx playwright install chromium
|
|
132
|
+
npm run test:unit # pure and quick, no browser
|
|
133
|
+
npm run test:browser # fixtures, CLI and safety tests in Chromium
|
|
134
|
+
npm test # both
|
|
135
|
+
node test/helpers/fixture-runs.mjs 3 # every fixture three times: identical findings, equal to the golden file
|
|
136
|
+
MA_SHARD=2/6 npm run test:browser # one slice of the browser tests (CI runs the slices in parallel jobs)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT. See [NOTICES.md](NOTICES.md) for credits.
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "motion-audit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Measures how a running web UI moves, frame by frame, in a real browser: missing hover/press/focus feedback, janky or jumping transitions, long frames and broken reduced motion.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"animation",
|
|
7
|
+
"motion",
|
|
8
|
+
"microinteractions",
|
|
9
|
+
"playwright",
|
|
10
|
+
"reduced-motion",
|
|
11
|
+
"performance",
|
|
12
|
+
"jank",
|
|
13
|
+
"qa",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Nahuel Mosse",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/NahuelMosse/motion-audit.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/NahuelMosse/motion-audit#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/NahuelMosse/motion-audit/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"motion-audit": "skills/motion-audit/scripts/motion-audit.mjs"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./skills/motion-audit/scripts/lib/index.mjs",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"skills/motion-audit",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"CHANGELOG.md"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.12"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"playwright": ">=1.59"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test --test-concurrency=2 \"test/**/*.test.mjs\"",
|
|
52
|
+
"test:unit": "node --test test/args.test.mjs test/config.test.mjs test/continuity.test.mjs test/docs.test.mjs test/gates.test.mjs test/press-count.test.mjs test/residuals.test.mjs test/sightings.test.mjs test/judge.test.mjs test/long-frames.test.mjs test/package.test.mjs test/playwright-resolution.test.mjs test/release-plan.test.mjs test/reduced-and-compare.test.mjs test/segments.test.mjs test/series.test.mjs test/skill-doc.test.mjs",
|
|
53
|
+
"test:browser": "node --test --test-concurrency=1 test/cli.test.mjs test/detection.test.mjs test/fixtures.test.mjs test/package-install.test.mjs test/interact-safety.test.mjs test/readme-example.test.mjs test/redirect.test.mjs",
|
|
54
|
+
"fixtures": "node test/helpers/fixture-runs.mjs 3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@playwright/test": "^1.59",
|
|
58
|
+
"playwright": "^1.59"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: motion-audit
|
|
3
|
+
description: Use when the user asks to audit, test or QA the motion, animations or microinteractions of a running page or app, says it feels janky, dead, stutters or jumps, or asks whether reduced motion works. Measures in a real browser what is wrong - no hover, press or focus feedback, snapping or janky transitions, long frames, animations that jump or restart, broken prefers-reduced-motion.
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Bash(node ${CLAUDE_SKILL_DIR}/scripts/*)
|
|
6
|
+
- Bash(node "${CLAUDE_SKILL_DIR}/scripts/*")
|
|
7
|
+
- Bash(npx --no-install motion-audit *)
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Motion audit
|
|
11
|
+
|
|
12
|
+
Motion bugs are invisible in code review and in still screenshots: the CSS says `transition: transform
|
|
13
|
+
200ms` and the button still snaps, because the hover rule sets a property the transition does not list.
|
|
14
|
+
Run the audit against the page, then fix at the cause.
|
|
15
|
+
|
|
16
|
+
**Stance.** Every interactive control owes a visible, quick response to hover, press and keyboard focus;
|
|
17
|
+
frequency decides *how much* motion (a list row gets a 120 ms tint, a primary CTA gets lift and press),
|
|
18
|
+
never *whether* there is feedback. A response with no transition (`snaps`) is a warning to review, not
|
|
19
|
+
always a defect. Motion must never cost legibility, input latency or accessibility.
|
|
20
|
+
|
|
21
|
+
## What is measured and what is read
|
|
22
|
+
|
|
23
|
+
| Source | What it covers |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `frames` | Position, size, opacity and transform of the element and its descendants sampled on every animation frame: measured duration, curve shape, overshoot, jumps, animated layout, Long Animation Frames (with the scripts that caused them) |
|
|
26
|
+
| `pixels` | Computed-style and screenshot comparison at rest, hover, press and focus: whether anything visibly changes |
|
|
27
|
+
| `css` | Declared `transition` duration and easing, `:hover` rules outside `@media (hover: hover)`, reduced-motion rules: read from the stylesheet, so JS-driven motion (Motion, GSAP) is only judged from `frames` |
|
|
28
|
+
| `events` | Animation objects and DOM events: a keyframe animation that restarts or is cancelled mid-way, infinite animations under reduced motion, entrances |
|
|
29
|
+
|
|
30
|
+
`:active` and `:focus-visible` are forced through the DevTools protocol (no pointer or focus event is
|
|
31
|
+
dispatched). Hover is a real pointer move after a hit test, so a control covered by a cookie banner or
|
|
32
|
+
sticky header is skipped (`NOT RUN covered`) instead of producing a false finding.
|
|
33
|
+
|
|
34
|
+
## What counts as a finding
|
|
35
|
+
|
|
36
|
+
Levels are the defaults; the config file can change any of them.
|
|
37
|
+
|
|
38
|
+
| Kind (script output) | What the user feels | Default level | Source |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `no-feedback` | Hover changes nothing on a button: the UI feels dead (links: warning; inputs: not reported) | error | pixels |
|
|
41
|
+
| `no-press-feedback` | Press changes nothing on a button (links and inputs: not reported) | warning | pixels |
|
|
42
|
+
| `no-focus-feedback` | Keyboard focus is invisible | error | pixels |
|
|
43
|
+
| `hover-only-control` | A control revealed only by pointer hover stays invisible when it gets keyboard focus | warning | pixels |
|
|
44
|
+
| `snaps` | Feedback exists but jumps: the changed properties have no transition | warning | css |
|
|
45
|
+
| `layout-animated` | Width, height, top, margin, padding or font animate: neighbours shift and layout runs every frame | error | frames |
|
|
46
|
+
| `long-frame` | A frame over 50 ms during hover, a click transition or scroll, from an application script or from style, layout and paint with no script; a warning on a development build | error | frames |
|
|
47
|
+
| `ease-in` | The response starts slow: declared curve or measured trajectory | error | css |
|
|
48
|
+
| `duration` | Declared duration outside the band for the gesture (hover 80-300, press 50-200, focus up to 250 ms) | warning | css |
|
|
49
|
+
| `linear` | A transform or opacity transition declared linear: mechanical | warning | css |
|
|
50
|
+
| `transition-all` | `transition: all` animates whatever changes, layout included | warning | css |
|
|
51
|
+
| `inconsistent-duration` | Same kind of control and gesture with declared durations 1.5x and 60 ms apart | warning | css |
|
|
52
|
+
| `jump` | A frame moves several times faster than a smooth curve allows: a teleporting animation | error | frames |
|
|
53
|
+
| `interrupt-jump` | Leaving hover mid-way, or clicking again mid-transition, jumps instead of reversing | error | events |
|
|
54
|
+
| `not-interruptible` | A toggle clicked back part-way through its motion restarts from the other end (needs `--interact`) | error | events |
|
|
55
|
+
| `ungated-hover` | `:hover` moves things outside `@media (hover: hover)`: it sticks after a tap on touch screens | warning | css |
|
|
56
|
+
| `reduced-motion-moves` | With prefers-reduced-motion a press still translates, rotates or scales by more than 6 % | error | css |
|
|
57
|
+
| `reduced-motion-residue` | With prefers-reduced-motion a transform stays after release (needs `--interact`) | error | css |
|
|
58
|
+
| `reduced-motion-loop` | An infinite moving animation keeps running under reduced motion (small rotate-only spinners excepted) | error | events |
|
|
59
|
+
| `reduced-motion-hidden` | Content waits for an entrance that reduced motion skipped: it stays at opacity 0 | error | pixels |
|
|
60
|
+
| `reduced-motion-missing` | Things move and no readable stylesheet has a reduced-motion rule (info when some sheets are unreadable) | warning | css |
|
|
61
|
+
| `no-entrance` | A section below the fold appears without an entrance (opt-in with `--entrances`; never fails the run) | info | events |
|
|
62
|
+
| `inconclusive` | The frames were too sparse to confirm or rule out a finding (its `check`, with the fingerprint of that finding): the reason gives the median and longest interval; never fails the run | warning | frames |
|
|
63
|
+
| `click-target` | The `--click` selector matches nothing visible | error | pixels |
|
|
64
|
+
|
|
65
|
+
`info` never fails a run. Missing `:active` or hover on links and inputs is never an error.
|
|
66
|
+
|
|
67
|
+
## Method
|
|
68
|
+
|
|
69
|
+
### 1. Measure on the running page
|
|
70
|
+
|
|
71
|
+
Audit a **production build** (`next start`, `vite preview`, a deployed preview), not a dev server. Dev
|
|
72
|
+
builds add HMR, overlays and unminified work: the audit detects them, says so, and downgrades
|
|
73
|
+
`long-frame` to a warning, but only a production build gives frame timings that mean something.
|
|
74
|
+
|
|
75
|
+
If the project has `motion-audit` as a devDependency, prefer `npx --no-install motion-audit <url> ...`: it runs
|
|
76
|
+
the version the project pins. Otherwise run the script bundled with this skill. The same command works in Git Bash, other POSIX shells and
|
|
77
|
+
PowerShell (`${CLAUDE_SKILL_DIR}` is substituted before the shell runs it; the single-quoted selectors are literal in both):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
node "${CLAUDE_SKILL_DIR}/scripts/motion-audit.mjs" http://localhost:3000/orders
|
|
81
|
+
node "${CLAUDE_SKILL_DIR}/scripts/motion-audit.mjs" http://localhost:3000/orders --cpu 4 --click '[aria-controls=filters]' --interrupt
|
|
82
|
+
node "${CLAUDE_SKILL_DIR}/scripts/motion-audit.mjs" http://localhost:3000/orders --only 'nav a' --only '.toolbar button' --frames '.btn-primary'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Exit 0 clean, 1 findings, **2 could not run, audited nothing or audited incompletely** - a 2 is never a
|
|
86
|
+
pass. The summary says `audited N of M controls` and lists every skip with its reason (`NOT RUN hidden`,
|
|
87
|
+
`covered`, `beyond --max 40`...); when N is lower than M, say which controls were not audited.
|
|
88
|
+
`--min-coverage 0.8` turns a low ratio into exit 2; controls beyond `--max` count as not audited, so
|
|
89
|
+
raise it too (`--max 200 --min-coverage 0.8`). Controls that appear after the audit counted them (a late
|
|
90
|
+
fetch) make the run exit 2 with `INCOMPLETE`: re-run with `--wait-for`. A URL that redirects, from the
|
|
91
|
+
server or from script (a session check, a router), exits 2 instead of auditing another page; a login
|
|
92
|
+
page is never audited (use `--storage-state`), another page only with `--allow-redirect`.
|
|
93
|
+
|
|
94
|
+
**Observe-only by default.** The audit hovers, forces `:active` and `:focus-visible`, scrolls and reads.
|
|
95
|
+
It does not click, so it cannot toggle a Follow button, open a menu that opens on `pointerdown`, submit a
|
|
96
|
+
form, switch a tab that switches on `mousedown`, or run focus and keyboard handlers. It does move the
|
|
97
|
+
real pointer and scroll the real page, so handlers bound to hover (`mouseenter`, `mouseover`,
|
|
98
|
+
`pointerenter` that send a beacon or write storage), scroll-bound `IntersectionObserver` callbacks and
|
|
99
|
+
`beforeunload` beacons run: audit a throwaway or staging environment, not one where an impression or a
|
|
100
|
+
hover event is billed or irreversible. `--interact` is opt-in and adds a real press and the
|
|
101
|
+
toggle probes (`not-interruptible`, `reduced-motion-residue`); a denylist skips submit and reset
|
|
102
|
+
controls (inside their form or tied to it with `form=`), buttons with no accessible name, every link
|
|
103
|
+
whose `href` is not `#` or the `#id` of an element of the page (hash routes included; and any control inside one), destructive or payment wording in
|
|
104
|
+
English, Spanish, Portuguese, French, German and Italian (including `aria-labelledby` and image alt
|
|
105
|
+
text), `[draggable]`, sliders and separators; a press is released where it was made (the pointer never
|
|
106
|
+
moves while it is down, so nothing is dragged), cancelled with a `pointercancel` before the release and with clicks cancelled for a moment after it (longer than the 80 ms a library waits for the native click), so libraries that act on pointerup (React Aria) do not fire; a plain `mouseup` handler on the control still runs; the
|
|
107
|
+
page is reloaded between probes, the run stops if a gesture navigates, every toggle is clicked as the
|
|
108
|
+
same node an even number of times, and the denylist is checked again before each click and before the
|
|
109
|
+
reduced-motion pass presses a control. Use it only
|
|
110
|
+
against a disposable environment.
|
|
111
|
+
|
|
112
|
+
`--click <selector>` is an explicit click on the control you name, and it clicks more than once: twice
|
|
113
|
+
(a pass without the tracer for long frames, one with it for trajectories), four times with
|
|
114
|
+
`--interrupt`. A step the frames were too coarse to judge adds up to two more recordings (one press each,
|
|
115
|
+
two with `--interrupt`), and when a flaky aspect is found the measurement is repeated twice more, two presses
|
|
116
|
+
each: at most eight clicks, sixteen with `--interrupt` (the count is always even: an odd one, after a retry without `--interrupt`, gets one more press that restores the state, provided each press's effect lands within the 1.5 s of its recording: a request still pending at the next reload is reported in the notes, one a timer sends later cannot be seen). Run
|
|
117
|
+
it only against a throwaway environment: a "Save" or "Delete" target is saved or deleted several times.
|
|
118
|
+
|
|
119
|
+
- `--cpu 4` throttles like a mid-range phone. Jank that only shows throttled is still jank.
|
|
120
|
+
- `--click <sel>` records everything that moves for 1.5 s after a real click, including overlays mounted
|
|
121
|
+
into `<body>` on open; `--interrupt` clicks again at 160 ms. Do it for every overlay the screen opens.
|
|
122
|
+
- `--frames <sel>` writes a contact sheet through hover, press and release with the real time of each
|
|
123
|
+
frame. Look at it: the numbers say *that* it moved, the sheet shows *how*.
|
|
124
|
+
- `--storage-state <file>` runs logged in (a Playwright storage state); a redirect to a login page exits 2.
|
|
125
|
+
- `--wait-for <sel>` waits for the selector before auditing (apps that hydrate late).
|
|
126
|
+
- `--entrances` also reports sections that appear without an entrance, as info.
|
|
127
|
+
- `--max 40` is the default; with more controls the summary says `audited 40 of 200`. Narrow with `--only`.
|
|
128
|
+
- Inner scroll containers are scrolled and their controls audited; hidden-until-hover controls are found.
|
|
129
|
+
- Flaky kinds (`long-frame`, `jump`, `interrupt-jump`, `not-interruptible`) are re-measured twice and kept
|
|
130
|
+
only when two of three runs agree.
|
|
131
|
+
- `inconclusive` rows appear when a machine drops so many frames that a check (`ease-in`, `interrupt-jump`,
|
|
132
|
+
`jump`) cannot be decided even after re-recording. They are listed with the findings, counted in
|
|
133
|
+
`coverage.inconclusive` and never fail the run. Rerun on a quieter machine, or without `--cpu` throttling.
|
|
134
|
+
- `report.json` (printed with `--json`, `schemaVersion: 1`) has the coverage, `loafSupported`, every
|
|
135
|
+
element's measurements, the findings with stable ids, baselined and suppressed findings.
|
|
136
|
+
|
|
137
|
+
States the first load does not show (open menus, toasts, loading, error) need the page put in that state:
|
|
138
|
+
run against a route or story that renders it, or use `--click` to open it.
|
|
139
|
+
|
|
140
|
+
### 2. Diagnose: name the cause before editing
|
|
141
|
+
|
|
142
|
+
For each finding: which element, which gesture, which property moved (or failed to), and **who drives
|
|
143
|
+
it** - a CSS transition, `@keyframes`, WAAPI, a JS library, or a rAF loop writing styles. A jump during a
|
|
144
|
+
click is usually a JS animation fighting a CSS transition on the same property, or an element re-mounted
|
|
145
|
+
mid-animation (React `key` change). Open `references/fixes.md` for the row that matches the cause.
|
|
146
|
+
|
|
147
|
+
### 3. Fix at the cause, in the component or the tokens
|
|
148
|
+
|
|
149
|
+
Load `references/fixes.md` only now: it maps each root cause to the fix and to the tempting wrong fix.
|
|
150
|
+
Do not add motion to silence an `info`, and do not add a scale to every control to clear a `warning`.
|
|
151
|
+
|
|
152
|
+
### 4. Verify and close
|
|
153
|
+
|
|
154
|
+
1. Re-run with the same flags.
|
|
155
|
+
2. Re-run with `--cpu 4` on the screens that had long frames, on a production build.
|
|
156
|
+
3. Look at a contact sheet (`--frames`) of the primary control and one frequent control.
|
|
157
|
+
4. **Finish with 0 errors, or each remaining finding suppressed in `motion-audit.config.json` with its reason.**
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"bands": { "reason": "Carbon micro-interactions run at 70 ms", "hover": [60, 300] },
|
|
162
|
+
"tokens": { "reason": "design-system duration tokens", "durations": [70, 120, 200] },
|
|
163
|
+
"severity": { "inconsistent-duration": { "level": "info", "reason": "size-scaled durations are intended" } },
|
|
164
|
+
"ignore": [
|
|
165
|
+
{ "kind": "no-feedback", "element": "a \"Skip to content\"*", "reason": "visible only on focus, by design" },
|
|
166
|
+
{ "id": "no-feedback:3f2a9c1d07", "reason": "tracked in the design-system backlog" }
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Anything that loosens the audit (bands, tokens, a lowered or `off` severity, an ignore) needs a
|
|
172
|
+
`reason`; ids are the `kind:hash` shown after each finding, never a wildcard; a kind rule must name an
|
|
173
|
+
element. Raising a severity needs no reason. `--baseline report.json` accepts the findings an earlier
|
|
174
|
+
report already had, so CI fails only on new ones; `compare <ref.json> <mine.json>` lines two reports up
|
|
175
|
+
control by control (measured duration, curve, overshoot) and lists new and gone findings.
|
|
176
|
+
|
|
177
|
+
### Lock it in with a test
|
|
178
|
+
|
|
179
|
+
From a project that installed the package (`npm i -D github:NahuelMosse/motion-audit#v1.0.0`) next to
|
|
180
|
+
`@playwright/test`; the test's own `page`, `baseURL` and `storageState` are used:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
// motion.spec.mjs
|
|
184
|
+
import { test, expect } from '@playwright/test';
|
|
185
|
+
import { audit } from 'motion-audit';
|
|
186
|
+
|
|
187
|
+
test('orders: motion', async ({ page }) => {
|
|
188
|
+
test.setTimeout(240_000); // each control takes a few seconds of real time
|
|
189
|
+
await page.goto('/orders');
|
|
190
|
+
const report = await audit(page, { only: ['main button', 'nav a'], max: 20, out: 'test-results/motion' });
|
|
191
|
+
expect(report.coverage.audited).toBeGreaterThan(0);
|
|
192
|
+
expect(report.findings.filter((f) => f.level === 'error')).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Or the CLI: `npx motion-audit http://localhost:3000/orders --max 200 --min-coverage 0.8 --baseline motion-baseline.json`.
|
|
197
|
+
|
|
198
|
+
## Limits
|
|
199
|
+
|
|
200
|
+
- Chromium only. Safari renders some transitions differently; check gesture-heavy UI there by hand.
|
|
201
|
+
- Colour and shadow transitions are judged from computed styles and pixels, not traced per frame.
|
|
202
|
+
- Long Animation Frames need Chromium 123+; when unsupported the report says
|
|
203
|
+
`loafSupported: false` - never read that as clean.
|
|
204
|
+
- Cross-origin stylesheets (and linked ones under `file://`) cannot be read for the `:hover` and
|
|
205
|
+
reduced-motion rule checks: they are counted, and `reduced-motion-missing` becomes `info` with the count.
|
|
206
|
+
- `not-interruptible` and `interrupt-jump` rely on Animation objects (keyframe restarts, cancelled
|
|
207
|
+
animations) at any sampling density; for JS-driven motion (rAF, spring libraries) a teleport is only
|
|
208
|
+
detected when frames are sampled about every 16 ms, so a slow or throttled run can miss a small one.
|
|
209
|
+
- A component re-mounted mid-animation (a changed React `key`) is seen when the old node and the new one
|
|
210
|
+
have the same tag and role, share a parent and are one to four frames apart; other replacements are
|
|
211
|
+
separate series.
|
|
212
|
+
- Long frames are the page's own: the time of the audit's injected scripts (the tracer, `page.evaluate`)
|
|
213
|
+
is taken out of each frame, so a heavy page under heavy machine load can still show an unattributed one.
|
|
214
|
+
- On a machine that drops frames (a loaded or virtualised runner), a teleport that lands in a frame of 66 ms or
|
|
215
|
+
more is indistinguishable from a steep easing: the audit re-records the click while some steps are
|
|
216
|
+
undecidable and, if it still cannot be decided, reports an `inconclusive` row.
|
|
217
|
+
- Shadow DOM and iframes are not entered; canvas and WebGL motion is not measured.
|
|
218
|
+
- Durations are compared within the same element kind (`button`, `a`, `tab`...): put deliberate
|
|
219
|
+
differences in `tokens.durations` or ignore them with a reason.
|
|
220
|
+
|
|
221
|
+
## Reporting back
|
|
222
|
+
|
|
223
|
+
Per finding fixed: element, gesture, cause, fix, and the measured value before and after. Findings left
|
|
224
|
+
in place: the suppression and its reason. The script result as exit code and its summary line; if it
|
|
225
|
+
could not run or audited fewer controls than the page has, say so - never report an unrun audit as passed.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Fix catalogue
|
|
2
|
+
|
|
3
|
+
Load this when a finding is diagnosed and a fix has to be chosen. Each row maps a root cause to the
|
|
4
|
+
fix that removes it and the tempting fix that does not.
|
|
5
|
+
|
|
6
|
+
| Root cause | Correct fix | Wrong fix |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| Hover/press rule changes a property the `transition` does not list | List exactly the properties that change: `transition: background-color 140ms var(--ease-out), transform 120ms var(--ease-out)` | `transition: all` |
|
|
9
|
+
| Button has no hover or press state | Tint or shadow on hover, `scale(0.97)` or a 1-2 px `translateY` on press | Leaving frequent buttons static "because they are frequent" |
|
|
10
|
+
| `outline: none` with nothing in its place | `:focus-visible { outline: 2px solid ...; outline-offset: 2px }` (or a ring on the wrapper with `:focus-within`) | `:focus` ring shown on every mouse click; removing the outline |
|
|
11
|
+
| A control is revealed only by `:hover` (`opacity-0 group-hover:opacity-100`) | Reveal it on `:focus-visible` and `:focus-within` too (`group-focus-within:opacity-100`, `focus-visible:opacity-100`) | Keeping it `display: none` until hover |
|
|
12
|
+
| Width/height/margin/top animated (expand, grow, slide) | `transform`/`opacity`; for size, FLIP (measure, invert with `scale`, play), `grid-template-rows: 0fr -> 1fr` for accordions, or `interpolate-size: allow-keywords` with a short duration | `max-height: 9999px` hacks; `will-change` on everything |
|
|
13
|
+
| Long frame when the gesture starts | Move work out of the handler: precompute, `requestIdleCallback`, `scheduler.yield()`, defer the fetch until after the first frame of the transition; animate on the compositor | Shortening the animation so the stutter is shorter |
|
|
14
|
+
| Long frames while scrolling | Scroll-driven animations (`animation-timeline: view()`) or IntersectionObserver instead of scroll listeners; no layout reads in scroll handlers; fewer and lighter blurs and shadows animating | `passive: true` alone (it fixes input delay, not frame cost) |
|
|
15
|
+
| `ease-in` or `linear` on a response | Strong ease-out (`cubic-bezier(0.23, 1, 0.32, 1)`) for enter and feedback; ease-in-out only for on-screen movement between two places; springs for gesture-driven motion | `ease` everywhere |
|
|
16
|
+
| Durations out of band or inconsistent | Duration tokens per gesture (`--dur-hover`, `--dur-press`, `--dur-enter`, `--dur-overlay`) used by every component; list deliberate ones in `tokens.durations` of the config | Tuning each component by eye |
|
|
17
|
+
| Hover driven by `@keyframes`, or a JS animation restarting from its start value | CSS transitions (they reverse from the current value) or springs that retarget from the live value | `animation-fill-mode` tricks; disabling pointer events during the animation |
|
|
18
|
+
| Toggle reversed mid-motion restarts (`not-interruptible`): open and close are `@keyframes` classes, each starting at its own `from` | Transitions between two states (`.open { transform: none }` with `transition: transform ...`), which reverse from the current value; or WAAPI: keep the `Animation` and call `reverse()`, or start the new one from `getComputedStyle(el).transform`; with Motion or springs, retarget from the live value | Disabling the button or pointer events while it animates; a longer duration |
|
|
19
|
+
| Dialog, menu or toast mounted on open has no exit and jumps | Keep it mounted until the exit finishes (Presence, `onAnimationEnd`, `<dialog>` with `@starting-style` and `transition-behavior: allow-discrete`) | Unmounting in the same tick as the state change |
|
|
20
|
+
| JS animation and CSS transition on the same property | One owner per property: remove the CSS transition where the library animates | Adding `!important` |
|
|
21
|
+
| `:hover` transform on touch | Wrap motion in `@media (hover: hover) and (pointer: fine)`; keep colour feedback outside it | Removing hover motion everywhere |
|
|
22
|
+
| Reduced motion ignored | `@media (prefers-reduced-motion: reduce)`: drop translate, large scale, parallax and loops; keep opacity and colour changes and a small press scale; in JS honour `matchMedia` (Motion: `MotionConfig reducedMotion="user"`) | `* { animation: none !important; transition: none !important }`: it also kills feedback and can strand entrances at opacity 0 |
|
|
23
|
+
| Entrance leaves content at opacity 0 when its animation is skipped | Put the visible state in the base style and animate *from* hidden (`@starting-style`, `animation-fill-mode: backwards`, or a class added by an observer that is a no-op under reduced motion) | Hiding content until JS runs |
|
|
24
|
+
| Infinite decorative loop | Stop it or replace it with a slow opacity pulse under reduced motion; pause off-screen | Leaving it because it is "small" |
|
|
25
|
+
| A section appears with no entrance (`no-entrance`, info) | Only if the design calls for it: a short opacity + 8-16 px translate on first view, 30-80 ms stagger for groups, once only | Re-animating on every scroll pass; entrances over 600 ms; adding motion just to silence an info |
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Command-line parsing with validation: unknown flags and invalid numbers are usage errors.
|
|
2
|
+
import { DEFAULT_MAX_ELEMENTS } from './constants.mjs';
|
|
3
|
+
|
|
4
|
+
export class UsageError extends Error {}
|
|
5
|
+
|
|
6
|
+
// type: 'string' | 'int' | 'number' | 'ratio' | 'bool'; repeat: collected into an array.
|
|
7
|
+
const SPEC = {
|
|
8
|
+
only: { type: 'string', repeat: true },
|
|
9
|
+
max: { type: 'int', min: 1 },
|
|
10
|
+
cpu: { type: 'number', min: 1 },
|
|
11
|
+
click: { type: 'string' },
|
|
12
|
+
interrupt: { type: 'bool' },
|
|
13
|
+
interact: { type: 'bool' },
|
|
14
|
+
frames: { type: 'string', repeat: true },
|
|
15
|
+
entrances: { type: 'bool' },
|
|
16
|
+
out: { type: 'string' },
|
|
17
|
+
config: { type: 'string' },
|
|
18
|
+
baseline: { type: 'string' },
|
|
19
|
+
'storage-state': { type: 'string' },
|
|
20
|
+
'wait-for': { type: 'string' },
|
|
21
|
+
'allow-redirect': { type: 'bool' },
|
|
22
|
+
'min-coverage': { type: 'ratio' },
|
|
23
|
+
strict: { type: 'bool' },
|
|
24
|
+
json: { type: 'bool' },
|
|
25
|
+
help: { type: 'bool' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const USAGE = `motion-audit <url|file> [options]
|
|
29
|
+
motion-audit compare <ref.json> <mine.json>
|
|
30
|
+
|
|
31
|
+
--only <selector> audit only elements matching it (repeatable)
|
|
32
|
+
--max <n> audit at most n controls (default ${DEFAULT_MAX_ELEMENTS})
|
|
33
|
+
--cpu <rate> throttle the CPU (4 is a mid-range phone)
|
|
34
|
+
--interact also press and toggle controls for real (denylist applies); default is observe-only
|
|
35
|
+
--click <selector> record 1.5 s after a real click on it
|
|
36
|
+
--interrupt with --click: click again half-way
|
|
37
|
+
--frames <selector> write a contact sheet of its hover and press (repeatable)
|
|
38
|
+
--entrances report sections that appear without an entrance (info)
|
|
39
|
+
--config <file> config file (default ./motion-audit.config.json)
|
|
40
|
+
--baseline <report> ignore findings already present in an earlier report.json
|
|
41
|
+
--storage-state <file> Playwright storage state (logged-in session)
|
|
42
|
+
--wait-for <selector> wait for it before auditing (hydration)
|
|
43
|
+
--allow-redirect audit the page the URL redirects to (a login page is never audited)
|
|
44
|
+
--min-coverage <0-1> exit 2 when audited/candidates is lower (candidates include controls beyond --max)
|
|
45
|
+
--out <dir> report and contact sheets (default: a temp directory)
|
|
46
|
+
--strict warnings fail too
|
|
47
|
+
--json print the report as JSON (schemaVersion 1)
|
|
48
|
+
--help this text
|
|
49
|
+
|
|
50
|
+
Exit: 0 clean, 1 findings, 2 could not run or audited nothing.`;
|
|
51
|
+
|
|
52
|
+
const parseValue = (flag, spec, raw) => {
|
|
53
|
+
if (raw === undefined || raw === null) throw new UsageError(`--${flag} needs a value`);
|
|
54
|
+
if (spec.type === 'string') {
|
|
55
|
+
if (raw === '' || raw.startsWith('--')) throw new UsageError(`--${flag} needs a value, got "${raw}"`);
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
const n = Number(raw);
|
|
59
|
+
if (raw.trim() === '' || !Number.isFinite(n)) throw new UsageError(`--${flag} must be a number, got "${raw}"`);
|
|
60
|
+
if (spec.type === 'int' && !Number.isInteger(n)) throw new UsageError(`--${flag} must be an integer, got "${raw}"`);
|
|
61
|
+
if (spec.type === 'ratio' && (n < 0 || n > 1)) throw new UsageError(`--${flag} must be between 0 and 1, got "${raw}"`);
|
|
62
|
+
if (spec.min !== undefined && n < spec.min) throw new UsageError(`--${flag} must be at least ${spec.min}, got "${raw}"`);
|
|
63
|
+
return n;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const parseArgs = (argv) => {
|
|
67
|
+
const opts = { only: [], frames: [] };
|
|
68
|
+
const positional = [];
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
const a = argv[i];
|
|
71
|
+
if (!a.startsWith('--')) { positional.push(a); continue; }
|
|
72
|
+
const eq = a.indexOf('=');
|
|
73
|
+
const flag = eq < 0 ? a.slice(2) : a.slice(2, eq);
|
|
74
|
+
const spec = SPEC[flag];
|
|
75
|
+
if (!spec) throw new UsageError(`unknown option --${flag} (see --help)`);
|
|
76
|
+
if (spec.type === 'bool') {
|
|
77
|
+
if (eq >= 0) throw new UsageError(`--${flag} takes no value`);
|
|
78
|
+
opts[flag.replace(/-(\w)/g, (_, c) => c.toUpperCase())] = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const raw = eq >= 0 ? a.slice(eq + 1) : argv[++i];
|
|
82
|
+
const value = parseValue(flag, spec, raw);
|
|
83
|
+
const key = flag.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
84
|
+
if (spec.repeat) opts[key].push(value); else opts[key] = value;
|
|
85
|
+
}
|
|
86
|
+
if (opts.interrupt && !opts.click) throw new UsageError('--interrupt needs --click <selector>');
|
|
87
|
+
return { opts, positional };
|
|
88
|
+
};
|