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.
@@ -0,0 +1,278 @@
1
+ // Pure trajectory analysis: one property sampled over time, curve classification, jump detection.
2
+ import { LONG_FRAME_STEP_SLACK_MS, FRAME_MS, DENSE_MAX_MS, SERIES_JUMP_MAX_DT_MS, SERIES_PEAK_SLOPE, SERIES_NEIGHBOUR_RATIO, JUMP_MIN_SHARE, OVERSHOOT_SPRING_PCT, REMOUNT_GAP_FRAMES } from './constants.mjs';
3
+
4
+ export const round = (x, d = 1) => Math.round(x * 10 ** d) / 10 ** d;
5
+ export const median = (a) => {
6
+ const s = [...a].sort((x, y) => x - y);
7
+ if (!s.length) return 0;
8
+ return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
9
+ };
10
+
11
+ export const COLS = ['left', 'top', 'width', 'height', 'opacity', 'tx', 'ty', 'sx', 'sy', 'rot', 'ow', 'oh'];
12
+ export const PROP_OF = { left: 'left', top: 'top', width: 'width', height: 'height', opacity: 'opacity', tx: 'translateX', ty: 'translateY', sx: 'scaleX', sy: 'scaleY', rot: 'rotate', ow: 'layoutWidth', oh: 'layoutHeight' };
13
+ export const EPS = { left: 1, top: 1, width: 1, height: 1, translateX: 1, translateY: 1, scaleX: 0.01, scaleY: 0.01, rotate: 1, opacity: 0.02, layoutWidth: 1, layoutHeight: 1 };
14
+
15
+ const SHAPES = [
16
+ ['linear', (u) => u],
17
+ ['ease-out', (u) => 1 - (1 - u) ** 2], ['ease-out', (u) => 1 - (1 - u) ** 3], ['ease-out', (u) => 1 - (1 - u) ** 5],
18
+ ['ease-in', (u) => u ** 2], ['ease-in', (u) => u ** 3],
19
+ ['ease-in-out', (u) => (u < 0.5 ? 2 * u * u : 1 - 2 * (1 - u) ** 2)], ['ease-in-out', (u) => (u < 0.5 ? 4 * u ** 3 : 1 - 4 * (1 - u) ** 3)],
20
+ ];
21
+
22
+ // The curve family that fits a settled motion best. The animation began somewhere after the last unmoved
23
+ // sample and ended somewhere before the first settled one: on a machine that drops frames those intervals
24
+ // are long, so the start and the end are searched inside them (FIT_GRID steps each) rather than taken at the
25
+ // sample times, which would read a late-starting or late-seen ease-in as an ease-in-out.
26
+ const FIT_GRID = 6;
27
+ const EASE_IN_TIE_RATIO = 1.5;
28
+ const EASE_IN_TIE_ERROR = 0.02;
29
+ const bestShape = (t, v, start, settle) => {
30
+ const v0 = v[start - 1];
31
+ const span = v[settle] - v0;
32
+ const best = new Map();
33
+ for (let a = 0; a < FIT_GRID; a++) {
34
+ const ts = t[start - 1] + ((t[start] - t[start - 1]) * a) / FIT_GRID;
35
+ for (let b = 1; b <= FIT_GRID; b++) {
36
+ const te = t[settle - 1] + ((t[settle] - t[settle - 1]) * b) / FIT_GRID;
37
+ for (const [name, f] of SHAPES) {
38
+ let err = 0;
39
+ for (let j = start - 1; j <= settle; j++) err += ((v[j] - v0) / span - f(Math.min(1, Math.max(0, (t[j] - ts) / (te - ts))))) ** 2;
40
+ if (err < (best.get(name) ?? Infinity)) best.set(name, err);
41
+ }
42
+ }
43
+ }
44
+ const [[shape, least]] = [...best.entries()].sort((x, y) => x[1] - y[1]);
45
+ // Sparse samples cannot always tell an ease-in from the other curves: it is a tie when the best ease-in fit
46
+ // is nearly as good as the winner.
47
+ const easeIn = best.get('ease-in');
48
+ return { shape, easeInTie: shape !== 'ease-in' && easeIn <= Math.max(EASE_IN_TIE_RATIO * least, least + EASE_IN_TIE_ERROR) };
49
+ };
50
+
51
+ // Angles read from a matrix wrap at +-180 degrees: unwrap so a spinning element is a smooth series.
52
+ export const unwrap = (v) => v.reduce((out, x, i) => {
53
+ if (!i) return [x];
54
+ let y = x;
55
+ while (y - out[i - 1] > 180) y -= 360;
56
+ while (y - out[i - 1] < -180) y += 360;
57
+ return [...out, y];
58
+ }, []);
59
+
60
+ // Jumps of a settled motion. Velocity is taken per millisecond, so irregular sampling does not
61
+ // change it; only intervals short enough to bound the instantaneous velocity count. A jump is a
62
+ // step that is a sizeable share of the amplitude and moves several times faster than the average
63
+ // speed of the whole motion (no easing curve peaks that high after its first interval) and than
64
+ // both short neighbours. The first interval is skipped: an ease-out legitimately starts fastest.
65
+ // `unresolved` counts steps that pass every test but the speed one, in a motion some interval of which is longer
66
+ // than DENSE_MAX_MS (or in an interval too long to bound the velocity): the speed test cannot decide them.
67
+ const findJumps = (t, v, start, settle, amp, t0, shape, reads = t) => {
68
+ const jumps = [];
69
+ let unresolved = 0;
70
+ if (settle - start < 4) return { jumps, unresolved };
71
+ const avgVelocity = amp / Math.max(1, t[settle] - t0);
72
+ const dense = t.slice(start, settle + 1).every((x, i) => x - t[start + i - 1] <= DENSE_MAX_MS);
73
+ const peak = SERIES_PEAK_SLOPE * (shape === 'spring' ? 2 : 1);
74
+ const velocity = (k) => {
75
+ const dt = t[k] - t[k - 1];
76
+ // The interval counts by the frame times and by when the values were read: a value read long after its frame started (a long
77
+ // task in between) says nothing about the frame-to-frame step.
78
+ const read = reads[k] - reads[k - 1];
79
+ return dt > 0 && dt <= SERIES_JUMP_MAX_DT_MS && read <= SERIES_JUMP_MAX_DT_MS ? Math.abs(v[k] - v[k - 1]) / dt : null;
80
+ };
81
+ const fast = (k) => { const x = velocity(k); return x !== null && x >= peak * avgVelocity; };
82
+ for (let k = start + 1; k <= settle; k++) {
83
+ // Chromium can sample a frame in the middle of a teleport: the step spreads over consecutive intervals that are each
84
+ // faster than any curve peaks. They are one jump, compared with the intervals on both sides of the run.
85
+ if (k < settle && fast(k) && fast(k + 1)) {
86
+ let end = k + 1;
87
+ while (end < settle && fast(end + 1)) end++;
88
+ const total = Math.abs(v[end] - v[k - 1]);
89
+ if (total >= JUMP_MIN_SHARE * amp) {
90
+ const before = velocity(k - 1);
91
+ const after = velocity(end + 1);
92
+ const run = total / (t[end] - t[k - 1]);
93
+ if (before === null || after === null) unresolved++;
94
+ else if (run >= SERIES_NEIGHBOUR_RATIO * before && run >= SERIES_NEIGHBOUR_RATIO * after) jumps.push({ atMs: round(t[k] - t0, 0), size: round(total, 2) });
95
+ }
96
+ k = end;
97
+ continue;
98
+ }
99
+ const step = Math.abs(v[k] - v[k - 1]);
100
+ if (step < JUMP_MIN_SHARE * amp) continue;
101
+ const vel = velocity(k);
102
+ if (vel === null) { unresolved++; continue; }
103
+ // Both neighbours must be judged too: a step between long intervals is not told from a steep easing.
104
+ const neighbours = [velocity(k - 1), velocity(k + 1)];
105
+ if (neighbours.includes(null)) { unresolved++; continue; }
106
+ if (neighbours.some((x) => vel < SERIES_NEIGHBOUR_RATIO * x)) continue;
107
+ // Sampled frame by frame (60 Hz) all along, a step slower than a jump is settled: it is no jump. With any longer
108
+ // interval in the motion (a 30 fps machine, a stall) the question stays open.
109
+ if (vel < peak * avgVelocity) { if (!dense) unresolved++; continue; }
110
+ jumps.push({ atMs: round(t[k] - t0, 0), size: round(step, 2) });
111
+ }
112
+ return { jumps, unresolved };
113
+ };
114
+
115
+ // A round trip (a toast in and out) is judged leg by leg, each against its own amplitude and speed:
116
+ // the average speed of the whole series says nothing about any one leg. Returns jumps with `atMs`
117
+ // relative to `t0`, the start of the whole series.
118
+ const legJumps = (t, v, eps, t0, reads = t) => {
119
+ const first = v[0];
120
+ let peak = 0;
121
+ v.forEach((x, i) => { if (Math.abs(x - first) > Math.abs(v[peak] - first)) peak = i; });
122
+ const tol = Math.abs(v[peak] - first) * 0.005;
123
+ let arrive = peak;
124
+ while (arrive > 0 && Math.abs(v[arrive - 1] - v[peak]) <= tol) arrive--;
125
+ let depart = peak;
126
+ while (depart < v.length - 1 && Math.abs(v[depart + 1] - v[peak]) <= tol) depart++;
127
+ const out = [];
128
+ let unresolved = 0;
129
+ for (const [a, b] of [[0, arrive], [depart, v.length - 1]]) {
130
+ const leg = analyzeSeries(t.slice(a, b + 1), v.slice(a, b + 1), eps, [], [], reads.slice(a, b + 1));
131
+ for (const j of leg?.jumps ?? []) out.push({ atMs: round(leg.startT + j.atMs - t0, 0), size: j.size });
132
+ unresolved += leg?.unresolved ?? 0;
133
+ }
134
+ return { jumps: out, unresolved };
135
+ };
136
+
137
+ // Steps where one node was replaced by another (`cuts`: indices of the first sample of the new node):
138
+ // a node removed while still moving and a new one that starts elsewhere is a teleport.
139
+ // A join is a jump only if the interval across it and both the intervals beside it were judged: within the limit by frame time and
140
+ // by the time the values were read. A longer interval (a motion that continues across a 300 ms gap) leaves it unresolved.
141
+ const cutJumps = (t, v, cuts, eps, range, t0, reads = t) => {
142
+ const jumps = [];
143
+ let unresolved = 0;
144
+ const judged = (k) => k >= 1 && k < t.length && t[k] - t[k - 1] <= SERIES_JUMP_MAX_DT_MS && reads[k] - reads[k - 1] <= SERIES_JUMP_MAX_DT_MS;
145
+ for (const c of cuts) {
146
+ if (c < 2 || c >= v.length) continue;
147
+ const step = Math.abs(v[c] - v[c - 1]);
148
+ const moving = Math.abs(v[c - 1] - v[c - 2]) > eps;
149
+ if (!(moving && step >= Math.max(JUMP_MIN_SHARE * range, 3 * eps))) continue;
150
+ if (![c - 1, c, c + 1].every(judged)) { unresolved++; continue; }
151
+ jumps.push({ atMs: round(t[c] - t0, 0), size: round(step, 2) });
152
+ }
153
+ return { jumps, unresolved };
154
+ };
155
+
156
+ // One property over time (`t` ms, `v` values): duration, overshoot, curve shape and jumps.
157
+ // Median and longest interval (ms) between samples.
158
+ const cadenceOf = (t) => {
159
+ const dts = t.slice(1).map((x, i) => x - t[i]);
160
+ return { median: round(median(dts), 0), max: round(Math.max(0, ...dts), 0) };
161
+ };
162
+
163
+ export const analyzeSeries = (t, v, eps, cuts = [], longFrames = [], reads = t) => {
164
+ const n = v.length;
165
+ if (n < 4) return null;
166
+ const first = v[0];
167
+ const final = v[n - 1];
168
+ const range = Math.max(...v) - Math.min(...v);
169
+ if (range < eps) return null;
170
+ const returns = Math.abs(final - first) < eps;
171
+ const amp = returns ? range : Math.abs(final - first);
172
+ const dir = returns ? 1 : Math.sign(final - first);
173
+ const tol = amp * 0.005;
174
+ const start = v.findIndex((x) => Math.abs(x - first) > tol);
175
+ if (start < 1) return null;
176
+ const tail = v.slice(-2);
177
+ const settled = tail.every((x, i) => i === 0 || Math.abs(x - tail[i - 1]) <= tol);
178
+ let settle = n - 1;
179
+ if (settled) while (settle > start && Math.abs(v[settle - 1] - final) <= tol) settle--;
180
+ const t0 = t[start - 1];
181
+ let over = 0;
182
+ if (!returns && settled) for (let j = start; j < n; j++) over = Math.max(over, (v[j] - final) * dir);
183
+ const overshootPct = round((over / amp) * 100);
184
+ let shape;
185
+ let easeInTie = false;
186
+ if (returns) shape = 'round-trip';
187
+ else if (settle - start < 2) shape = 'step';
188
+ else if (overshootPct > OVERSHOOT_SPRING_PCT) shape = 'spring';
189
+ else {
190
+ ({ shape, easeInTie } = bestShape(t, v, start, settle));
191
+ }
192
+ let jumps;
193
+ let unresolved = 0;
194
+ if (cuts.length) {
195
+ // Joined nodes are separate motions: each is judged on its own, the joins by cutJumps.
196
+ const bounds = [0, ...cuts, n];
197
+ const legs = bounds.slice(1).flatMap((end, i) => {
198
+ const leg = analyzeSeries(t.slice(bounds[i], end), v.slice(bounds[i], end), eps, [], [], reads.slice(bounds[i], end));
199
+ unresolved += leg?.unresolved ?? 0;
200
+ return (leg?.jumps ?? []).map((j) => ({ atMs: round(leg.startT + j.atMs - t0, 0), size: j.size }));
201
+ });
202
+ const joins = cutJumps(t, v, cuts, eps, range, t0, reads);
203
+ unresolved += joins.unresolved;
204
+ jumps = legs.concat(joins.jumps);
205
+ } else ({ jumps, unresolved } = shape === 'round-trip' ? legJumps(t, v, eps, t0, reads) : findJumps(t, v, start, settle, amp, t0, shape, reads));
206
+ // A step in a frame that overlaps a long frame is that frame: the animation's clock ran on while the thread was blocked.
207
+ if (longFrames.length) {
208
+ jumps = jumps.filter((j) => {
209
+ const k = t.findIndex((x) => Math.abs(x - (t0 + j.atMs)) < 1.5);
210
+ const [from, to] = k > 0 ? [t[k - 1], t[k]] : [t0 + j.atMs, t0 + j.atMs];
211
+ return !longFrames.some(([s, len]) => s <= to + LONG_FRAME_STEP_SLACK_MS && s + len >= from - LONG_FRAME_STEP_SLACK_MS);
212
+ });
213
+ }
214
+ return { from: round(v[start - 1], 2), to: round(final, 2), startT: t0, firstT: t[start], durationMs: round(t[settle] - t0, 0), samples: settle - start + 1, easeInTie, cadence: cadenceOf(t.slice(start - 1, settle + 1)), overshootPct, shape, settled, jumps, unresolved };
215
+ };
216
+
217
+ // Whether a move is an animation: seen in more than one frame and longer than two 60 Hz frames. A change seen in a
218
+ // single frame is a step however long that frame lasted (a machine that drops frames has 100 ms frames).
219
+ export const animatedMove = (m) => m.samples > 1 && m.durationMs > 2 * FRAME_MS;
220
+
221
+ export const analyzeElement = (el, longFrames = []) => {
222
+ const moves = [];
223
+ for (const c of COLS) {
224
+ const r = analyzeSeries(el.t, c === 'rot' ? unwrap(el[c]) : el[c], EPS[PROP_OF[c]], el.cuts, longFrames, el.r ?? el.t);
225
+ if (r) moves.push({ prop: PROP_OF[c], ...r });
226
+ }
227
+ return moves.length ? { label: el.label, root: el.root, moves } : null;
228
+ };
229
+
230
+ const kindOf = (e) => `${e.tag ?? e.label.replace(/[#.].*$/, '').toUpperCase()}|${e.role ?? ''}`;
231
+
232
+ // A node removed mid-recording and a node of the same tag and role mounted in the same parent within
233
+ // REMOUNT_GAP_FRAMES is one component re-mounted (a changed React `key`): their samples form one series,
234
+ // with `cuts` at the joins, so the animation that starts over shows as a jump.
235
+ export const joinRemounts = (elems) => {
236
+ if (elems.length < 2) return elems;
237
+ // Every tracked node is sampled in the same frames: gaps are counted in those frames, whatever they lasted.
238
+ const frames = [...new Set(elems.flatMap((e) => e.t))].sort((x, y) => x - y);
239
+ const frameOf = new Map(frames.map((x, i) => [x, i]));
240
+ const taken = new Set();
241
+ const next = new Map();
242
+ for (const a of [...elems].sort((x, y) => x.t.at(-1) - y.t.at(-1))) {
243
+ if (a.root || frameOf.get(a.t.at(-1)) >= frames.length - 2) continue;
244
+ const gap = (b) => frameOf.get(b.t[0]) - frameOf.get(a.t.at(-1));
245
+ const b = elems.filter((x) => x !== a && !x.root && !taken.has(x) && x.parent === a.parent && kindOf(x) === kindOf(a) && gap(x) > 0 && gap(x) <= REMOUNT_GAP_FRAMES).sort((x, y) => x.t[0] - y.t[0])[0];
246
+ if (b) { next.set(a, b); taken.add(b); }
247
+ }
248
+ return elems.filter((e) => !taken.has(e)).map((head) => {
249
+ if (!next.has(head)) return head;
250
+ const joined = { ...head, cuts: [] };
251
+ for (const c of COLS) joined[c] = [...head[c]];
252
+ joined.t = [...head.t];
253
+ joined.r = [...(head.r ?? head.t)];
254
+ for (let e = next.get(head); e; e = next.get(e)) {
255
+ joined.cuts.push(joined.t.length);
256
+ joined.t.push(...e.t);
257
+ joined.r.push(...(e.r ?? e.t));
258
+ for (const c of COLS) joined[c].push(...e[c]);
259
+ }
260
+ return joined;
261
+ });
262
+ };
263
+
264
+ // `longFrames`: [[startMs, durationMs]] of the recording's long frames: jumps they explain are not reported.
265
+ export const analyzeRecording = (rec, { longFrames = [] } = {}) => joinRemounts(rec?.elems ?? []).map((e) => analyzeElement(e, longFrames)).filter(Boolean);
266
+
267
+ // 'ease-in' when the curve starts slower than it ends; 'linear'; otherwise 'ok'.
268
+ export const easingKind = (e) => {
269
+ if (e === 'linear' || /^linear\(\s*0(?:\s+0%)?\s*,\s*1(?:\s+100%)?\s*\)$/.test(e)) return 'linear';
270
+ if (e === 'ease-in') return 'ease-in';
271
+ const m = e.match(/^cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/);
272
+ if (!m) return 'ok';
273
+ const [x1, y1, x2, y2] = m.slice(1).map(Number);
274
+ if (x1 === y1 && x2 === y2) return 'linear';
275
+ const startSlope = x1 > 0 ? y1 / x1 : Infinity;
276
+ const endSlope = x2 < 1 ? (1 - y2) / (1 - x2) : Infinity;
277
+ return startSlope < 0.5 && endSlope > 1 ? 'ease-in' : 'ok';
278
+ };
@@ -0,0 +1,76 @@
1
+ // What becomes of a sighting (a jump seen in one recording) that the re-measures do not reproduce: it is dropped only
2
+ // when a re-measure that could see that very motion did not see it; otherwise it stays visible as undecided. Pure.
3
+ import { FINE_SAMPLING_MS, JUMP_MAX_DT_MS, DENSE_MAX_MS, DENSE_MEDIAN_MS, SERIES_JUMP_MAX_DT_MS, SIGHTING_MIN_SAMPLES } from './constants.mjs';
4
+
5
+ // The sampling a recording gave each motion in it. `moved`: [{ label, moves: [{ prop, samples, unresolved, cadence }] }].
6
+ // `mark`: { median, max } of the intervals around the second gesture, for the checks that depend on one; null when
7
+ // there was none. `ran` is false when the probe did not run (it threw, the control left the page).
8
+ export const coverageOf = (moved, { ran = true, mark = null, moment = '' } = {}) => ({
9
+ ran,
10
+ mark,
11
+ moves: moved.flatMap((e) => e.moves.map((m) => ({ moment, label: e.label, prop: m.prop, samples: m.samples, unresolved: m.unresolved, maxDt: m.cadence.max }))),
12
+ });
13
+ export const NO_COVERAGE = { ran: false, mark: null, moves: [] };
14
+
15
+ // The worst of two coverages of the same motion (fewest samples, any unresolved step, longest interval).
16
+ export const mergeCoverage = (a, b) => {
17
+ const moves = [...a.moves];
18
+ for (const m of b.moves) {
19
+ const i = moves.findIndex((x) => sameMotion(x, m));
20
+ if (i < 0) moves.push(m);
21
+ else moves[i] = { ...moves[i], samples: Math.min(moves[i].samples, m.samples), unresolved: moves[i].unresolved || m.unresolved, maxDt: Math.max(moves[i].maxDt, m.maxDt) };
22
+ }
23
+ const mark = a.mark && b.mark ? { median: Math.max(a.mark.median, b.mark.median), max: Math.max(a.mark.max, b.mark.max) } : a.mark ?? b.mark;
24
+ return { ran: a.ran && b.ran, mark, moves };
25
+ };
26
+
27
+ export const sameMotion = (a, b) => a.label === b.label && a.prop === b.prop && (a.moment ?? '') === (b.moment ?? '');
28
+
29
+ // Whether a recording could have shown the sighting's jump: the very motion is in it (same element label, property and
30
+ // gesture), judged on enough samples, with no step left unresolved and no interval too long to bound a velocity.
31
+ // For a check that depends on a second gesture (`mark`) the probe must have run, and the frames around that gesture
32
+ // must follow a value frame by frame.
33
+ export const canSee = (coverage, sighting, { mark = false } = {}) => {
34
+ if (!coverage?.ran) return false;
35
+ const m = coverage.moves.find((x) => sameMotion(x, sighting));
36
+ if (!m || m.samples < SIGHTING_MIN_SAMPLES || m.unresolved || m.maxDt > SERIES_JUMP_MAX_DT_MS) return false;
37
+ if (!mark) return true;
38
+ return Boolean(coverage.mark) && coverage.mark.median <= FINE_SAMPLING_MS && coverage.mark.max <= JUMP_MAX_DT_MS;
39
+ };
40
+
41
+ export const isDense = (cadence) => Boolean(cadence) && cadence.median <= DENSE_MEDIAN_MS && cadence.max <= DENSE_MAX_MS;
42
+
43
+ // The sightings a re-measure did not rule out, each with why: 'flake' (a clear step in frame-by-frame sampling that the
44
+ // re-measures did not reproduce) or 'unseen' (no re-measure could judge that motion). `sightings`: [{ label, prop, moment?, dense,
45
+ // cadence? }] (the cadence is that of the intervals around the sighting); `reruns`: [{ saw: [{ label, prop, moment? }], coverage }].
46
+ export const undecidedSightings = (sightings, reruns, opts = {}) => sightings.flatMap((s) => {
47
+ if (s.dense) return [{ sighting: s, cause: 'flake' }];
48
+ const ruledOut = reruns.some((r) => !(r.saw ?? []).some((x) => sameMotion(x, s)) && canSee(r.coverage, s, opts));
49
+ return ruledOut ? [] : [{ sighting: s, cause: 'unseen' }];
50
+ });
51
+
52
+ // 'drop' when every sighting was ruled out; otherwise the cause of the first that was not ('flake' before 'unseen').
53
+ export const unconfirmedVerdict = (sightings, reruns, opts = {}) => {
54
+ const left = undecidedSightings(sightings, reruns, opts);
55
+ return left.find((x) => x.cause === 'flake')?.cause ?? left[0]?.cause ?? 'drop';
56
+ };
57
+
58
+ // The reason an undecided row gives, by cause.
59
+ export const undecidedReason = (cause, runs) => (cause === 'flake'
60
+ ? `seen in 1 of ${runs} recordings, not reproduced`
61
+ : `seen in 1 of ${runs} recordings; the others could not judge that motion (frames too sparse, or it was not there)`);
62
+
63
+ // The CSS properties whose declared transition shows up as a measured property (a transform moves the box, so it also
64
+ // moves the measured position and size).
65
+ const TRANSFORM = ['transform', 'translate', 'scale', 'rotate'];
66
+ const MEASURED_BY = {
67
+ translateX: TRANSFORM, translateY: TRANSFORM, scaleX: TRANSFORM, scaleY: TRANSFORM, rotate: TRANSFORM, opacity: ['opacity'],
68
+ left: [...TRANSFORM, 'left', 'right', 'inset', 'marginLeft', 'marginRight'], top: [...TRANSFORM, 'top', 'bottom', 'inset', 'marginTop', 'marginBottom'],
69
+ width: [...TRANSFORM, 'width', 'minWidth', 'maxWidth', 'flex', 'padding', 'paddingLeft', 'paddingRight'], height: [...TRANSFORM, 'height', 'minHeight', 'maxHeight', 'flex', 'padding', 'paddingTop', 'paddingBottom'],
70
+ layoutWidth: ['width', 'minWidth', 'maxWidth', 'flex', 'padding', 'paddingLeft', 'paddingRight'], layoutHeight: ['height', 'minHeight', 'maxHeight', 'flex', 'padding', 'paddingTop', 'paddingBottom'],
71
+ };
72
+
73
+
74
+ // The measured properties a CSS property moves (a declared `width` shows as width and layoutWidth; a `transform` as its parts).
75
+ const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
76
+ export const measuredFor = (cssProp) => Object.keys(MEASURED_BY).filter((m) => MEASURED_BY[m].includes(camel(cssProp)));
@@ -0,0 +1,86 @@
1
+ // The rows a sighting leaves when the re-measures do not reproduce it (see sightings.mjs): a jump seen in one recording is
2
+ // dropped only when a re-measure that could see that motion did not see it. Pure: no browser.
3
+ import { CONFIRM_MAJORITY, CONFIRM_RUNS } from './constants.mjs';
4
+ import { NO_COVERAGE, coverageOf, isDense, undecidedReason, undecidedSightings, unconfirmedVerdict } from './sightings.mjs';
5
+
6
+ const sparseText = (c) => `frames too sparse: median Δt ${c.median} ms, max ${c.max} ms`;
7
+
8
+ // What a gesture's recording could see of each of its motions, and the jumps seen in it.
9
+ const elementCoverage = (el) => ({ ran: true, mark: null, moves: Object.entries(el.moments ?? {}).flatMap(([moment, d]) => coverageOf(d.motion, { moment }).moves) });
10
+ const elementSaw = (el) => Object.entries(el.moments ?? {}).flatMap(([moment, d]) => d.motion.flatMap((e) => e.moves.filter((m) => m.jumps.length).map((m) => ({ moment, label: e.label, prop: m.prop }))));
11
+
12
+ // Undecided rows of a gesture's flaky aspects (`el.unconfirmed`): [{ check, key, detail }], one per sighting. `later`: the
13
+ // re-measures of the element (null where the probe did not run).
14
+ export const undecidedOf = (el, later) => {
15
+ const out = [];
16
+ const unconfirmed = el.unconfirmed ?? [];
17
+ if (unconfirmed.includes('interrupt')) {
18
+ const verdict = unconfirmedVerdict(el.interruptSightings ?? [], later.map((x) => ({ saw: x?.interrupt ?? [], coverage: x?.interruptCoverage ?? NO_COVERAGE })), { mark: true });
19
+ if (verdict !== 'drop') out.push({ check: 'interrupt-jump', key: 'hover', detail: `hover left mid-way: ${undecidedReason(verdict, CONFIRM_RUNS)}`, cadence: el.interruptCadence });
20
+ }
21
+ if (unconfirmed.includes('jump')) {
22
+ const reruns = later.map((x) => ({ saw: x ? elementSaw(x) : [], coverage: x ? elementCoverage(x) : NO_COVERAGE }));
23
+ for (const [moment, d] of Object.entries(el.moments)) for (const e of d.motion) for (const m of e.moves) {
24
+ if (!m.jumps.length) continue;
25
+ const verdict = unconfirmedVerdict([{ moment, label: e.label, prop: m.prop, dense: isDense(m.cadence) }], reruns);
26
+ if (verdict !== 'drop') out.push({ check: 'jump', key: `${moment} ${e.label} ${m.prop}`, detail: `${moment}: ${e.label} ${m.prop} ${undecidedReason(verdict, CONFIRM_RUNS)}`, cadence: m.cadence });
27
+ }
28
+ }
29
+ // A step too coarse to judge (unresolved) in a gesture: ruled out only by a re-measure that could see that motion and saw no jump.
30
+ const reruns = later.map((x) => ({ saw: x ? elementSaw(x) : [], coverage: x ? elementCoverage(x) : NO_COVERAGE }));
31
+ for (const [moment, d] of Object.entries(el.moments)) for (const e of d.motion) for (const m of e.moves) {
32
+ if (!(m.unresolved > 0) || m.jumps.length) continue;
33
+ const verdict = unconfirmedVerdict([{ moment, label: e.label, prop: m.prop, dense: false }], reruns);
34
+ const key = `${moment} ${e.label} ${m.prop}`;
35
+ if (verdict !== 'drop' && !out.some((o) => o.key === key)) out.push({ check: 'jump', key, detail: `${moment}: ${e.label} ${m.prop} has a step the frames were too coarse to judge: ${undecidedReason(verdict, CONFIRM_RUNS)}`, cadence: m.cadence });
36
+ }
37
+ return out;
38
+ };
39
+
40
+ // Inconclusive rows of the --click check. `first`: the first measurement (before any flaky aspect was dropped);
41
+ // `unconfirmed`: the aspects the majority vote dropped; `reruns`: the re-measures (a missing target counts as none).
42
+ export const clickRows = (label, first, unconfirmed, reruns) => {
43
+ const rows = [];
44
+ const row = (check, detail, cadence) => rows.push({ kind: 'inconclusive', check, element: label, key: 'click', detail, cadence });
45
+ const asReruns = (pick) => reruns.map((x) => ({ saw: x[pick] ?? [], coverage: x.coverage ?? NO_COVERAGE }));
46
+ const dropped = (name, list) => list.length > 0 && unconfirmed.includes(name);
47
+ if (!(first.jumps.length > 0 && !dropped('jump', first.jumps))) {
48
+ if (first.unsure) row('jump', `a step in the transition could be a jump: ${sparseText(first.unsure)}`, first.unsure);
49
+ else if (dropped('jump', first.jumps) && first.jumpSightings.length) {
50
+ const left = undecidedSightings(first.jumpSightings, asReruns('jumps'));
51
+ const cause = left.find((x) => x.cause === 'flake') ?? left[0];
52
+ if (cause) row('jump', `a step in the transition could be a jump: ${undecidedReason(cause.cause, CONFIRM_RUNS)}`, cause.sighting.cadence);
53
+ }
54
+ }
55
+ if (!(first.interrupt.length > 0 && !dropped('interrupt', first.interrupt))) {
56
+ if (first.interruptUnsure) row('interrupt-jump', `second click: the animation restarted but the value cannot be followed: ${sparseText(first.interruptUnsure)}`, first.interruptUnsure);
57
+ else if (dropped('interrupt', first.interrupt)) {
58
+ const left = undecidedSightings(first.interruptSightings, asReruns('interrupt'), { mark: true });
59
+ const cause = left.find((x) => x.cause === 'flake') ?? left[0];
60
+ if (cause) row('interrupt-jump', `second click: ${undecidedReason(cause.cause, CONFIRM_RUNS)}`, cause.sighting.cadence);
61
+ }
62
+ }
63
+ return rows;
64
+ };
65
+
66
+ // The re-measure the majority vote asks of the --click check: the measurement again with its retries off (two presses, four with
67
+ // --interrupt), recorded in `reruns` as what the sighting rules read.
68
+ export const clickRerun = (measure, reruns) => async () => {
69
+ const again = await measure(0);
70
+ reruns.push(again.missing ? { jumps: [], interrupt: [] } : again);
71
+ return again.missing ? null : again;
72
+ };
73
+
74
+ // A toggle whose reversal showed a restart in one probe: kept when most probes agree, otherwise dropped (`undecided` is
75
+ // null) or left undecided ('flake' | 'unseen'). `again`: the other probes; one that did not run has no jumps and no coverage.
76
+ export const confirmToggle = (r, again) => {
77
+ if (1 + again.filter((x) => x.jumps?.length).length >= CONFIRM_MAJORITY) return r;
78
+ const verdict = unconfirmedVerdict(r.sightings ?? [], again.map((x) => ({ saw: x.jumps ?? [], coverage: x.coverage ?? NO_COVERAGE })), { mark: true });
79
+ return { ...r, jumps: [], undecided: verdict === 'drop' ? null : verdict };
80
+ };
81
+
82
+ export const toggleRow = (r) => {
83
+ if (r.jumps.length || !(r.undecided || r.unsure)) return null;
84
+ const why = r.unsure ? `an animation restart cannot be followed: ${sparseText(r.unsure)}` : undecidedReason(r.undecided, CONFIRM_RUNS);
85
+ return { kind: 'inconclusive', check: 'not-interruptible', element: r.label, key: 'toggle', detail: `reversed part-way through its motion: ${why}`, cadence: r.unsure ?? r.sightings?.[0]?.cadence ?? r.cadence };
86
+ };
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // motion-audit <url|file> [options] (see --help)
3
+ // Thin entry point: everything lives in ./lib. It always runs when executed, so it also works
4
+ // through a symlink or junction (no main-module guard to defeat).
5
+ import { main } from './lib/cli.mjs';
6
+
7
+ process.exitCode = await main(process.argv.slice(2));