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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Report-to-report comparison: `compare <ref.json> <mine.json>` and the `--baseline` filter.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
const SIGNIFICANT_MS = 50;
|
|
5
|
+
const SIGNIFICANT_RATIO = 1.25;
|
|
6
|
+
const SIGNIFICANT_OVERSHOOT = 5;
|
|
7
|
+
|
|
8
|
+
export const readReport = (file) => {
|
|
9
|
+
let report;
|
|
10
|
+
try { report = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new Error(`cannot read report ${file}: ${e instanceof SyntaxError ? 'not valid JSON (content not shown)' : e.code ?? 'unreadable'}`); }
|
|
11
|
+
if (report.schemaVersion !== 1) throw new Error(`${file}: unsupported schemaVersion ${report.schemaVersion} (expected 1)`);
|
|
12
|
+
return report;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Strongest measured move of an element at a gesture, or null.
|
|
16
|
+
const primary = (el, moment) => {
|
|
17
|
+
const moves = (el.moments?.[moment]?.motion ?? []).flatMap((e) => e.moves.map((m) => ({ ...m, label: e.label })));
|
|
18
|
+
return moves.sort((a, b) => b.durationMs - a.durationMs)[0] ?? null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const declared = (el, moment) => {
|
|
22
|
+
const tr = el.moments?.[moment]?.transition ?? el.transition;
|
|
23
|
+
const changed = el.moments?.[moment]?.changed ?? [];
|
|
24
|
+
if (!changed.length) return null;
|
|
25
|
+
const i = tr?.properties?.findIndex((p) => p === 'all' || changed.some((c) => c === p || c.startsWith(`${p}-`)));
|
|
26
|
+
return i >= 0 ? { duration: tr.durations[i % tr.durations.length], easing: tr.easings[i % tr.easings.length] } : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const describe = (m, d) => {
|
|
30
|
+
if (m) return `${m.durationMs} ms ${m.shape}${m.overshootPct > 3 ? ` (+${m.overshootPct} % overshoot)` : ''}`;
|
|
31
|
+
if (d) return `${d.duration} ms declared (${d.easing})`;
|
|
32
|
+
return 'no motion';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const differences = (am, ad, bm, bd) => {
|
|
36
|
+
if (!am && !ad && !bm && !bd) return null;
|
|
37
|
+
const out = [];
|
|
38
|
+
const da = am?.durationMs ?? ad?.duration ?? 0;
|
|
39
|
+
const db = bm?.durationMs ?? bd?.duration ?? 0;
|
|
40
|
+
if (Math.abs(db - da) >= SIGNIFICANT_MS || Math.max(da, db) / Math.max(1, Math.min(da, db)) > SIGNIFICANT_RATIO) out.push(`duration ${db - da > 0 ? '+' : ''}${db - da} ms`);
|
|
41
|
+
if (am && bm && am.shape !== bm.shape) out.push(`curve ${am.shape} -> ${bm.shape}`);
|
|
42
|
+
if (am && bm && Math.abs(am.overshootPct - bm.overshootPct) >= SIGNIFICANT_OVERSHOOT) out.push(`overshoot ${am.overshootPct} -> ${bm.overshootPct} %`);
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// One line per control present in both reports and gesture; controls are paired by label.
|
|
47
|
+
export const compareReports = (ref, mine) => {
|
|
48
|
+
const lines = [];
|
|
49
|
+
let differ = 0;
|
|
50
|
+
const byLabel = new Map(mine.elements.map((e) => [e.label, e]));
|
|
51
|
+
for (const a of ref.elements) {
|
|
52
|
+
const b = byLabel.get(a.label);
|
|
53
|
+
if (!b) continue;
|
|
54
|
+
for (const moment of ['hover', 'press', 'focus']) {
|
|
55
|
+
const am = primary(a, moment);
|
|
56
|
+
const bm = primary(b, moment);
|
|
57
|
+
const diff = differences(am, declared(a, moment), bm, declared(b, moment));
|
|
58
|
+
if (!diff) continue;
|
|
59
|
+
if (diff.length) differ++;
|
|
60
|
+
lines.push(`${a.label} ${moment}: ref ${describe(am, declared(a, moment))} · mine ${describe(bm, declared(b, moment))} · ${diff.length ? `diff: ${diff.join(', ')}` : 'matches'}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const refIds = new Set(ref.findings.map((f) => f.id));
|
|
64
|
+
const mineIds = new Set(mine.findings.map((f) => f.id));
|
|
65
|
+
const added = mine.findings.filter((f) => !refIds.has(f.id));
|
|
66
|
+
const gone = ref.findings.filter((f) => !mineIds.has(f.id));
|
|
67
|
+
return { lines, differ, total: lines.length, added, gone };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const baselineIds = (report) => new Set([...report.findings.map((f) => f.id), ...(report.baselined ?? []).map((f) => f.id)]);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// motion-audit.config.json: bands, duration tokens, per-kind severity and ignores with a reason.
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { BANDS } from './constants.mjs';
|
|
6
|
+
import { KINDS, LEVELS } from './kinds.mjs';
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE = 'motion-audit.config.json';
|
|
9
|
+
const TOP_KEYS = new Set(['bands', 'tokens', 'severity', 'ignore']);
|
|
10
|
+
const GESTURES = Object.keys(BANDS);
|
|
11
|
+
|
|
12
|
+
export class ConfigError extends Error {}
|
|
13
|
+
|
|
14
|
+
// Fingerprint of a finding: stable across runs because it uses the element's label and the
|
|
15
|
+
// moment/property that identifies the finding, never DOM indices or measured numbers.
|
|
16
|
+
// An `inconclusive` row carries the `check` it could not decide and the hash of that check's own finding.
|
|
17
|
+
export const fingerprint = (f) => `${f.kind}:${createHash('sha1').update(`${f.check ?? f.kind}\n${f.element}\n${f.key ?? ''}`).digest('hex').slice(0, 10)}`;
|
|
18
|
+
|
|
19
|
+
const ID_PATTERN = /^([a-z-]+):[0-9a-f]{10}$/;
|
|
20
|
+
const hasReason = (x) => typeof x?.reason === 'string' && x.reason.trim().length > 0;
|
|
21
|
+
const rank = (level) => LEVELS.indexOf(level);
|
|
22
|
+
|
|
23
|
+
// Everything that loosens the audit (bands, tokens, a lowered severity, an ignore) needs a reason.
|
|
24
|
+
export const validateConfig = (raw, source = CONFIG_FILE) => {
|
|
25
|
+
const fail = (m) => { throw new ConfigError(`${source}: ${m}`); };
|
|
26
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('must be a JSON object');
|
|
27
|
+
for (const k of Object.keys(raw)) if (!TOP_KEYS.has(k)) fail(`unknown key "${k}" (allowed: ${[...TOP_KEYS].join(', ')})`);
|
|
28
|
+
const bands = { ...BANDS };
|
|
29
|
+
const bandEntries = Object.entries(raw.bands ?? {}).filter(([g]) => g !== 'reason');
|
|
30
|
+
if (bandEntries.length && !hasReason(raw.bands)) fail('bands needs a non-empty "reason"');
|
|
31
|
+
for (const [g, v] of bandEntries) {
|
|
32
|
+
if (!GESTURES.includes(g)) fail(`bands.${g}: unknown gesture (allowed: ${GESTURES.join(', ')})`);
|
|
33
|
+
if (!Array.isArray(v) || v.length !== 2 || v.some((x) => typeof x !== 'number' || x < 0) || v[0] > v[1]) fail(`bands.${g} must be [min, max] in ms`);
|
|
34
|
+
bands[g] = v;
|
|
35
|
+
}
|
|
36
|
+
const durations = raw.tokens?.durations ?? [];
|
|
37
|
+
if (!Array.isArray(durations) || durations.some((x) => typeof x !== 'number' || x <= 0)) fail('tokens.durations must be an array of positive numbers (ms)');
|
|
38
|
+
if (durations.length && !hasReason(raw.tokens)) fail('tokens needs a non-empty "reason"');
|
|
39
|
+
const severity = {};
|
|
40
|
+
for (const [k, v] of Object.entries(raw.severity ?? {})) {
|
|
41
|
+
if (!KINDS[k]) fail(`severity.${k}: unknown kind`);
|
|
42
|
+
const level = typeof v === 'string' ? v : v?.level;
|
|
43
|
+
if (!LEVELS.includes(level)) fail(`severity.${k}: level must be one of ${LEVELS.join(', ')}`);
|
|
44
|
+
if (rank(level) < rank(KINDS[k].level) && !(typeof v === 'object' && hasReason(v))) fail(`severity.${k}: lowering a kind needs { "level": "${level}", "reason": "..." }`);
|
|
45
|
+
severity[k] = level;
|
|
46
|
+
}
|
|
47
|
+
const ignore = [];
|
|
48
|
+
for (const [i, e] of (raw.ignore ?? []).entries()) {
|
|
49
|
+
if (!e || typeof e !== 'object') fail(`ignore[${i}] must be an object`);
|
|
50
|
+
if (!hasReason(e)) fail(`ignore[${i}] needs a non-empty "reason"`);
|
|
51
|
+
if (e.id !== undefined) {
|
|
52
|
+
const m = ID_PATTERN.exec(String(e.id));
|
|
53
|
+
if (!m || !KINDS[m[1]]) fail(`ignore[${i}].id must be a finding id: a known kind, a colon and 10 hex digits`);
|
|
54
|
+
} else {
|
|
55
|
+
if (!e.kind || !KINDS[e.kind]) fail(`ignore[${i}] needs "id" (a finding id) or a known "kind" with an "element"`);
|
|
56
|
+
if (typeof e.element !== 'string' || !/[^*\s]/.test(e.element)) fail(`ignore[${i}].element must name an element; a bare wildcard would silence the whole kind (lower its severity with a reason instead)`);
|
|
57
|
+
}
|
|
58
|
+
ignore.push({ id: e.id, kind: e.kind, element: e.element, reason: e.reason.trim() });
|
|
59
|
+
}
|
|
60
|
+
return { bands, tokens: { durations }, severity, ignore };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const loadConfig = (path, cwd = process.cwd()) => {
|
|
64
|
+
const file = path ? resolve(cwd, path) : join(cwd, CONFIG_FILE);
|
|
65
|
+
if (!existsSync(file)) {
|
|
66
|
+
if (path) throw new ConfigError(`config file not found: ${file}`);
|
|
67
|
+
return validateConfig({});
|
|
68
|
+
}
|
|
69
|
+
let raw;
|
|
70
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new ConfigError(`${file}: not valid JSON (${e instanceof SyntaxError ? 'syntax error; content not shown' : e.code ?? 'unreadable'})`); }
|
|
71
|
+
return validateConfig(raw, file);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const globToRegExp = (g) => new RegExp(`^${g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
|
|
75
|
+
const ignoreMatches = (rule, f) => (rule.id ? rule.id === f.id : rule.kind === f.kind && (!rule.element || globToRegExp(rule.element).test(f.element)));
|
|
76
|
+
|
|
77
|
+
// Raw findings -> final findings: default level, config severity, ignores. Returns
|
|
78
|
+
// { findings, suppressed } where suppressed carries the reason of the rule that dropped it.
|
|
79
|
+
export const finalize = (raw, config, { devServer = false } = {}) => {
|
|
80
|
+
const findings = [];
|
|
81
|
+
const suppressed = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
// What an inconclusive row could not decide is settled when the check found something.
|
|
84
|
+
const found = new Set(raw.filter((f) => f.kind !== 'inconclusive').map((f) => fingerprint(f).split(':')[1] + f.kind));
|
|
85
|
+
for (const f of raw) {
|
|
86
|
+
if (f.kind === 'inconclusive' && found.has(fingerprint(f).split(':')[1] + f.check)) continue;
|
|
87
|
+
// One finding per fingerprint: several series can describe the same thing (two nodes of one component).
|
|
88
|
+
const id = fingerprint(f);
|
|
89
|
+
if (seen.has(id)) continue;
|
|
90
|
+
seen.add(id);
|
|
91
|
+
let level = f.level ?? KINDS[f.kind].level;
|
|
92
|
+
if (f.kind === 'long-frame' && devServer) level = 'warning';
|
|
93
|
+
level = config.severity[f.kind] ?? level;
|
|
94
|
+
if (level === 'off') continue;
|
|
95
|
+
const full = { id, level, kind: f.kind, ...(f.check ? { check: f.check } : {}), element: f.element, detail: f.detail, ...(f.cadence ? { cadence: f.cadence } : {}) };
|
|
96
|
+
const rule = config.ignore.find((r) => ignoreMatches(r, full));
|
|
97
|
+
if (rule) suppressed.push({ ...full, reason: rule.reason });
|
|
98
|
+
else findings.push(full);
|
|
99
|
+
}
|
|
100
|
+
return { findings, suppressed };
|
|
101
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Named thresholds and timings shared by the analysis and the browser driver.
|
|
2
|
+
export const FRAME_MS = 1000 / 60;
|
|
3
|
+
export const LONG_FRAME_MS = 50;
|
|
4
|
+
// A frame with no application script behind it counts above LONG_FRAME_MS, or above this multiple of the
|
|
5
|
+
// longest frame a blank page shows on the same machine (a slow VM has long frames with nothing to do).
|
|
6
|
+
export const FRAME_NOISE_FACTOR = 2;
|
|
7
|
+
// Where a machine shows frames over LONG_FRAME_MS with nothing to do, its noise comes in bursts a short
|
|
8
|
+
// calibration can miss: a frame without application work counts only above this many ms at least.
|
|
9
|
+
export const NOISY_UNATTRIBUTED_MS = 250;
|
|
10
|
+
// A frame whose page scripts add up to less than this is judged as one without application work: the
|
|
11
|
+
// rest of it belongs to the machine.
|
|
12
|
+
export const PAGE_SCRIPT_MIN_MS = 25;
|
|
13
|
+
// Frames without a script behind them in this many controls of one audit are the machine's, whatever its calibration.
|
|
14
|
+
export const ENV_NOISE_MIN_ELEMENTS = 3;
|
|
15
|
+
// A motion of at least SPARSE_MIN_MOTION_MS sampled at fewer than SPARSE_DENSITY samples per 60 Hz
|
|
16
|
+
// frame is measured again (at most SPARSE_RETRIES times), keeping the densest recording.
|
|
17
|
+
export const SPARSE_MIN_MOTION_MS = 100;
|
|
18
|
+
export const SPARSE_DENSITY = 0.7;
|
|
19
|
+
export const SPARSE_RETRIES = 3;
|
|
20
|
+
// A hover left mid-way is recorded again (at most this many times) while an animation restart was seen
|
|
21
|
+
// but the value could not be followed because the frames were sparse.
|
|
22
|
+
export const INTERRUPT_RETRIES = 3;
|
|
23
|
+
export const CALIBRATION_CYCLES = 6;
|
|
24
|
+
export const IDLE_NOISE_MS = 1000;
|
|
25
|
+
export const MIN_PLAYWRIGHT = [1, 59];
|
|
26
|
+
export const SCHEMA_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_MAX_ELEMENTS = 40;
|
|
29
|
+
export const VIEWPORT = { width: 1280, height: 900 };
|
|
30
|
+
export const NAV_TIMEOUT_MS = 45000;
|
|
31
|
+
export const WAIT_FOR_TIMEOUT_MS = 15000;
|
|
32
|
+
// The first load of a URL waits this long for hydration and for client-side redirects; later loads
|
|
33
|
+
// of the same URL (probes reload the page) wait RELOAD_WAIT_MS.
|
|
34
|
+
export const HYDRATION_WAIT_MS = 800;
|
|
35
|
+
export const RELOAD_WAIT_MS = 250;
|
|
36
|
+
// Controls that show up later than this after a load are not "late": the audit waits this long
|
|
37
|
+
// before it counts the controls again, and a larger count means the first count was incomplete.
|
|
38
|
+
export const RECOUNT_AFTER_MS = 3000;
|
|
39
|
+
|
|
40
|
+
// A gesture lasts until the page is still: no running CSS or WAAPI animation on the element or its
|
|
41
|
+
// subtree, and no change of position, size, opacity, transform or colour for SETTLE_QUIET_MS, but at
|
|
42
|
+
// least SETTLE_MIN_MS (a transition starts on the next frame) and at most SETTLE_MAX_MS.
|
|
43
|
+
export const SETTLE_MIN_MS = 50;
|
|
44
|
+
export const SETTLE_QUIET_MS = 80;
|
|
45
|
+
export const SETTLE_MAX_MS = 2000;
|
|
46
|
+
export const INTERRUPT_MIN_MOTION_MS = 80;
|
|
47
|
+
export const INTERRUPT_LEAD_MS = 80;
|
|
48
|
+
export const CLICK_LEAD_MS = 120;
|
|
49
|
+
// After a real press is released, clicks stay cancelled this long: a library may dispatch its own click up to ~80 ms later.
|
|
50
|
+
export const PRESS_GUARD_MS = 150;
|
|
51
|
+
export const CLICK_RECORD_MS = 1500;
|
|
52
|
+
// A click recording whose only jump candidate fell in a frame too long to judge is recorded again, at most this many times.
|
|
53
|
+
export const CLICK_RESOLVE_RETRIES = 2;
|
|
54
|
+
export const CLICK_INTERRUPT_MS = 160;
|
|
55
|
+
export const SCROLL_STEP_PX = 160;
|
|
56
|
+
export const SCROLL_STEP_MS = 90;
|
|
57
|
+
export const SCROLL_TAIL_MS = 600;
|
|
58
|
+
|
|
59
|
+
// Toggle probing.
|
|
60
|
+
export const MAX_TOGGLES = 8;
|
|
61
|
+
export const TOGGLE_MIN_MOTION_MS = 80;
|
|
62
|
+
export const TOGGLE_SETTLE_MIN_MS = 100;
|
|
63
|
+
export const TOGGLE_QUIET_MS = 150;
|
|
64
|
+
export const TOGGLE_SHARES = [0.3, 0.55];
|
|
65
|
+
|
|
66
|
+
// Tracer.
|
|
67
|
+
export const TRACE_SUBTREE_CAP = 60;
|
|
68
|
+
export const TRACE_PAGE_CAP = 800;
|
|
69
|
+
export const TRACE_MOUNT_CAP = 400;
|
|
70
|
+
export const TRACE_MIN_SIZE_PX = 4;
|
|
71
|
+
|
|
72
|
+
// Findings policy.
|
|
73
|
+
export const BANDS = { hover: [80, 300], press: [50, 200], focus: [0, 250] };
|
|
74
|
+
export const INCONSISTENT_RATIO = 1.5;
|
|
75
|
+
export const INCONSISTENT_MIN_GAP_MS = 60;
|
|
76
|
+
export const OVERSHOOT_SPRING_PCT = 3;
|
|
77
|
+
export const EASE_IN_MIN_MS = 60;
|
|
78
|
+
|
|
79
|
+
// Jump detection. A position step is a jump only when the sampling is fine enough to bound the
|
|
80
|
+
// instantaneous velocity (or an Animation object says the animation restarted).
|
|
81
|
+
export const FINE_SAMPLING_MS = 1.5 * FRAME_MS;
|
|
82
|
+
export const JUMP_MAX_DT_MS = 2.5 * FRAME_MS;
|
|
83
|
+
// A step is a jump only if it was judged in an interval no longer than this, by frame time and by the time its value was read,
|
|
84
|
+
// with both neighbouring intervals as short: a step inside a longer interval, or beside one, can only leave the motion
|
|
85
|
+
// unresolved. Within the limit a settled motion is judged on its own velocities, which are bounded whatever the interval (the
|
|
86
|
+
// mean slope of a smooth curve over an interval never exceeds its steepest slope), so a machine that renders 30 fps with
|
|
87
|
+
// stalls up to this long still gets its teleports found.
|
|
88
|
+
export const SERIES_JUMP_MAX_DT_MS = 6 * FRAME_MS;
|
|
89
|
+
|
|
90
|
+
// An interval of a settled motion is a jump when it is a local speed peak (at least twice as fast as
|
|
91
|
+
// both neighbours) and this many times faster than the average speed of the whole motion.
|
|
92
|
+
export const SERIES_PEAK_SLOPE = 5;
|
|
93
|
+
// ...and at least this many times faster than each neighbouring interval (an ease-in speeds up by less, even sampled coarsely).
|
|
94
|
+
export const SERIES_NEIGHBOUR_RATIO = 3;
|
|
95
|
+
export const JUMP_PEAK_SLOPE = 6;
|
|
96
|
+
export const JUMP_MIN_SHARE = 0.15;
|
|
97
|
+
export const RESTART_MIN_SHARE = 0.1;
|
|
98
|
+
export const RESTART_WINDOW = 3;
|
|
99
|
+
export const SMALL_MIN_SHARE = 0.2;
|
|
100
|
+
export const SMALL_STEP_EPS_FACTOR = 2;
|
|
101
|
+
export const RESTART_EVENT_SLACK_MS = 3 * FRAME_MS;
|
|
102
|
+
// A node removed and a same-kind node mounted in the same parent within this many frames are one re-mounted component.
|
|
103
|
+
export const REMOUNT_GAP_FRAMES = 4;
|
|
104
|
+
// A step next to a long frame is that frame, not a jump (the animation clock ran ahead while the thread was blocked).
|
|
105
|
+
export const LONG_FRAME_STEP_SLACK_MS = 2 * FRAME_MS;
|
|
106
|
+
export const JUMP_MIN_STEP = { left: 24, top: 24, width: 24, height: 24, tx: 24, ty: 24, opacity: 0.4, sx: 0.3, sy: 0.3 };
|
|
107
|
+
|
|
108
|
+
// Confirmation of flaky findings: re-measured, kept when most runs agree.
|
|
109
|
+
export const CONFIRM_RUNS = 3;
|
|
110
|
+
export const CONFIRM_MAJORITY = 2;
|
|
111
|
+
|
|
112
|
+
// Reduced motion.
|
|
113
|
+
export const REDUCED_SCALE_DELTA = 0.06;
|
|
114
|
+
export const REDUCED_TRANSLATE_PX = 4;
|
|
115
|
+
export const REDUCED_LOOP_SPINNER_PX = 64;
|
|
116
|
+
|
|
117
|
+
export const HIT_TEST_POINTS = [[0.5, 0.5], [0.25, 0.5], [0.75, 0.5], [0.5, 0.25], [0.5, 0.75]];
|
|
118
|
+
export const CLIP_MARGIN_PX = 6;
|
|
119
|
+
// Screenshots differ visibly when enough PIXEL_BLOCK_PX squares change, in their average colour, by more than
|
|
120
|
+
// the tolerance. Averaging squares ignores anti-aliasing (a different text rasterisation keeps the mean colour).
|
|
121
|
+
export const PIXEL_BLOCK_PX = 8;
|
|
122
|
+
export const PIXEL_CHANNEL_TOLERANCE = 24;
|
|
123
|
+
export const PIXEL_MIN_BLOCKS = 2;
|
|
124
|
+
export const CONTACT_SHEET_STEP_MS = 40;
|
|
125
|
+
|
|
126
|
+
export const VISIBLE_PROPS = ['transform', 'translate', 'scale', 'rotate', 'opacity', 'background-color', 'background-image', 'color', 'box-shadow', 'border-color', 'outline-style', 'outline-width', 'outline-color', 'text-decoration-color', 'filter', 'width', 'height', 'padding-top', 'padding-left', 'margin-top', 'margin-left', 'top', 'left', 'letter-spacing', 'font-weight'];
|
|
127
|
+
export const LAYOUT_PROPS = /^(width|height|top|left|right|bottom|inset|margin|padding|max-height|max-width|min-height|min-width|font-size|font-weight|letter-spacing|border-width|line-height|gap)/;
|
|
128
|
+
export const INTERACTIVE = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=tab], [role=switch], [role=menuitem], [role=checkbox], [role=link], [tabindex]:not([tabindex="-1"])';
|
|
129
|
+
export const TOGGLE = '[aria-expanded], [aria-controls], [aria-pressed], summary, [data-toggle]';
|
|
130
|
+
// Controls that --interact never presses or toggles: they submit, destroy, leave, drag or resize.
|
|
131
|
+
// Also denied in denyReason: any submit or reset control owned by a form (inside it or through form=),
|
|
132
|
+
// links (and anything inside one) whose href is not a same-document fragment, and buttons with no accessible name.
|
|
133
|
+
export const DENY_SELECTOR = '[type=submit], [type=image], [type=reset], [draggable=true], [role=slider], [role=separator], [role=scrollbar], input[type=range], input[type=file]';
|
|
134
|
+
const DENY_WORDS = [
|
|
135
|
+
// English
|
|
136
|
+
'delete', 'remove', 'destroy', 'discard', 'logout', 'log out', 'sign out', 'signout', 'unsubscribe', 'pay', 'buy', 'purchase', 'checkout', 'submit', 'send', 'confirm', 'archive', 'publish', 'deploy', 'reset', 'place order', 'subscribe', 'revoke', 'block',
|
|
137
|
+
// Spanish
|
|
138
|
+
'eliminar', 'elimina', 'borrar', 'borra', 'quitar', 'descartar', 'salir', 'cerrar sesión', 'cerrar sesion', 'desconectar', 'darse de baja', 'pagar', 'paga', 'comprar', 'compra', 'enviar', 'envía', 'envia', 'confirmar', 'confirma', 'publicar', 'publica', 'archivar', 'restablecer', 'realizar pedido', 'suscribir', 'revocar', 'bloquear',
|
|
139
|
+
// Portuguese
|
|
140
|
+
'excluir', 'apagar', 'sair', 'encerrar sessão', 'encerrar sessao',
|
|
141
|
+
// French
|
|
142
|
+
'supprimer', 'effacer', 'déconnexion', 'deconnexion', 'se déconnecter', 'payer', 'acheter', 'envoyer', 'confirmer', 'publier', 'valider',
|
|
143
|
+
// German
|
|
144
|
+
'löschen', 'entfernen', 'abmelden', 'ausloggen', 'bezahlen', 'kaufen', 'senden', 'absenden', 'bestätigen', 'veröffentlichen',
|
|
145
|
+
// Italian
|
|
146
|
+
'cancella', 'rimuovi', 'esci', 'paga', 'acquista', 'invia', 'conferma', 'pubblica',
|
|
147
|
+
];
|
|
148
|
+
export const DENY_TEXT = new RegExp(`(?<!\\p{L})(?:${DENY_WORDS.join('|')})(?!\\p{L})`, 'iu');
|
|
149
|
+
// Controls that must have an accessible name to be pressed: an unnamed one may be a trash can.
|
|
150
|
+
export const NAMED_CONTROL = 'button, summary, [role=button]';
|
|
151
|
+
export const LOGIN_PATH = /\/(log-?in|sign-?in|sign_in|auth|sso|session\/new)(\/|$|\?)/i;
|
|
152
|
+
// A motion sampled fewer times than this is never examined for jumps (findJumps needs a start, a settle and room between).
|
|
153
|
+
export const SIGHTING_MIN_SAMPLES = 5;
|
|
154
|
+
|
|
155
|
+
// Frames at 60 Hz: a recording whose intervals have a median up to DENSE_MEDIAN_MS and none over DENSE_MAX_MS is dense. A step in
|
|
156
|
+
// it that is slower than a jump is settled, and a row that says "too sparse" about it would be wrong. Product and tests share
|
|
157
|
+
// these two numbers.
|
|
158
|
+
export const DENSE_MEDIAN_MS = 20;
|
|
159
|
+
export const DENSE_MAX_MS = 34;
|
|
160
|
+
// A declared transition shorter than this cannot hold SIGHTING_MIN_SAMPLES frames at 60 Hz: its measured curve is not judged.
|
|
161
|
+
export const MEASURE_MIN_MS = 120;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Interruption analysis: after a second gesture (the mark) the motion must continue from where it
|
|
2
|
+
// was. A CSS transition reverses from its live value, so a step after the mark is only a defect
|
|
3
|
+
// when an Animation object says the animation restarted, or when the sampling is fine enough to
|
|
4
|
+
// show the value teleporting faster than any easing curve allows.
|
|
5
|
+
import { COLS, EPS, PROP_OF, median, round, unwrap } from './series.mjs';
|
|
6
|
+
import { FINE_SAMPLING_MS, JUMP_MAX_DT_MS, JUMP_MIN_STEP, LONG_FRAME_STEP_SLACK_MS, JUMP_PEAK_SLOPE, RESTART_EVENT_SLACK_MS, RESTART_MIN_SHARE, RESTART_WINDOW, SMALL_MIN_SHARE, SMALL_STEP_EPS_FACTOR } from './constants.mjs';
|
|
7
|
+
|
|
8
|
+
const ancestorsOf = (el, byId) => {
|
|
9
|
+
const chain = new Set([el.id]);
|
|
10
|
+
for (let p = el.parent, guard = 0; p && guard < 64; p = byId.get(p)?.parent, guard++) chain.add(p);
|
|
11
|
+
return chain;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Animation events from the tracer: { t, nodeId, type: 'transition' | 'animation' | 'waapi' | 'cancel',
|
|
15
|
+
// props, explicitFrom }. A restart is a new keyframe/WAAPI animation with an explicit `from`
|
|
16
|
+
// keyframe, or a keyframe animation cancelled before it finished.
|
|
17
|
+
const isRestart = (e) => e.type === 'cancel' || ((e.type === 'animation' || e.type === 'waapi') && e.explicitFrom);
|
|
18
|
+
|
|
19
|
+
// Position steps in the window after `markT`. `durationMs` is the measured duration of the motion
|
|
20
|
+
// (estimated from the series when absent). `small` is for micro-interactions (hover, a second click
|
|
21
|
+
// on a trigger): steps are judged against the property's epsilon and 20 % of its range instead of
|
|
22
|
+
// the absolute 24 px / 0.4 opacity of a toggle's panel.
|
|
23
|
+
// `longFrames`: [[startMs, durationMs]] of the long frames of the recording. A step in a frame that
|
|
24
|
+
// overlaps one is the long frame (the page's animation clock kept running while its thread was
|
|
25
|
+
// blocked), reported as `long-frame`; only an Animation object that restarted is still a jump there.
|
|
26
|
+
export const interruptionJumps = (rec, markT, { durationMs, small = false, longFrames = [] } = {}) => {
|
|
27
|
+
const found = [];
|
|
28
|
+
const anims = rec.anims ?? [];
|
|
29
|
+
const byId = new Map(rec.elems.map((e) => [e.id, e]));
|
|
30
|
+
for (const el of rec.elems) {
|
|
31
|
+
const scope = ancestorsOf(el, byId);
|
|
32
|
+
const own = anims.filter((a) => scope.has(a.nodeId));
|
|
33
|
+
const transitionOnly = own.some((a) => a.type === 'transition') && !own.some(isRestart);
|
|
34
|
+
for (const c of COLS) {
|
|
35
|
+
const min = small ? SMALL_STEP_EPS_FACTOR * EPS[PROP_OF[c]] : JUMP_MIN_STEP[c];
|
|
36
|
+
if (min === undefined) continue;
|
|
37
|
+
const { t } = el;
|
|
38
|
+
// When each sample was read (`t` is the start of its frame, which a slow frame leaves far behind): marks and
|
|
39
|
+
// animation events share this clock.
|
|
40
|
+
const r = el.r ?? t;
|
|
41
|
+
const v = c === 'rot' ? unwrap(el[c]) : el[c];
|
|
42
|
+
const i = r.findIndex((x) => x >= markT);
|
|
43
|
+
if (i < 3 || i >= v.length - 1) continue;
|
|
44
|
+
const range = Math.max(...v) - Math.min(...v);
|
|
45
|
+
if (range < EPS[PROP_OF[c]]) continue;
|
|
46
|
+
const before = t.slice(1, i).map((x, k) => x - t[k]);
|
|
47
|
+
const fine = median(before) <= FINE_SAMPLING_MS;
|
|
48
|
+
const dur = durationMs ?? Math.max(1, t.at(-1) - t[0]);
|
|
49
|
+
let worst = null;
|
|
50
|
+
for (let k = i; k <= Math.min(v.length - 1, i + RESTART_WINDOW); k++) {
|
|
51
|
+
const d = Math.abs(v[k] - v[k - 1]);
|
|
52
|
+
const dt = t[k] - t[k - 1];
|
|
53
|
+
// An Animation object that restarted is strong evidence, so a smaller step suffices than
|
|
54
|
+
// for a position-only teleport judged in small mode.
|
|
55
|
+
const restarted = own.some((a) => isRestart(a) && a.t >= markT - RESTART_EVENT_SLACK_MS && a.t <= r[k] + Math.max(RESTART_EVENT_SLACK_MS, 2 * dt));
|
|
56
|
+
if (d < Math.max(min, (small && !restarted ? SMALL_MIN_SHARE : RESTART_MIN_SHARE) * range)) continue;
|
|
57
|
+
if (!restarted && longFrames.some(([s, len]) => s <= t[k] + LONG_FRAME_STEP_SLACK_MS && s + len >= t[k - 1] - LONG_FRAME_STEP_SLACK_MS)) continue;
|
|
58
|
+
const teleports = !transitionOnly && fine && dt > 0 && dt <= JUMP_MAX_DT_MS && d / dt > (JUMP_PEAK_SLOPE * range) / dur;
|
|
59
|
+
if ((restarted || teleports) && (!worst || d > worst.delta)) worst = { delta: d, from: v[k - 1], to: v[k], atMs: t[k] - markT, evidence: restarted ? 'animation' : 'position' };
|
|
60
|
+
}
|
|
61
|
+
if (worst) found.push({ label: el.label, prop: PROP_OF[c], delta: round(worst.delta, 2), from: round(worst.from, 2), to: round(worst.to, 2), atMs: round(worst.atMs, 0), evidence: worst.evidence });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found.sort((a, b) => b.delta - a.delta);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// An animation restart was seen after the mark but the samples around it are too far apart to follow the
|
|
68
|
+
// value: { median, max } of the intervals there, or null when nothing restarted or the frames were fine.
|
|
69
|
+
const dtsAround = (rec, markT) => rec.elems.flatMap((el) => (el.r ?? el.t).slice(1).map((x, k) => [x, x - (el.r ?? el.t)[k]]).filter(([x]) => Math.abs(x - markT) <= 300).map(([, d]) => d));
|
|
70
|
+
|
|
71
|
+
// { median, max } of the intervals between the samples within 300 ms of the mark, or null without samples.
|
|
72
|
+
export const markCadence = (rec, markT) => {
|
|
73
|
+
const dts = dtsAround(rec, markT);
|
|
74
|
+
return dts.length ? { median: round(median(dts), 0), max: round(Math.max(...dts), 0) } : null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const restartUnfollowed = (rec, markT) => {
|
|
78
|
+
if (!(rec.anims ?? []).some((a) => isRestart(a) && a.t >= markT - RESTART_EVENT_SLACK_MS)) return null;
|
|
79
|
+
const cadence = markCadence(rec, markT);
|
|
80
|
+
return cadence && cadence.max > JUMP_MAX_DT_MS ? cadence : null;
|
|
81
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Public API of the package: `import { audit } from 'motion-audit'`.
|
|
2
|
+
export { audit, exitCode } from './audit.mjs';
|
|
3
|
+
export { KINDS, kindNames } from './kinds.mjs';
|
|
4
|
+
export { analyzeSeries, analyzeRecording, easingKind } from './series.mjs';
|
|
5
|
+
export { interruptionJumps } from './continuity.mjs';
|
|
6
|
+
export { judgeElement, inconsistencies } from './judge.mjs';
|
|
7
|
+
export { compareReports, readReport } from './compare.mjs';
|
|
8
|
+
export { fingerprint, loadConfig, validateConfig } from './config.mjs';
|
|
9
|
+
export { reducedMoves, reducedResidue, transformState } from './reduced.mjs';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Turns what was measured on one element into findings. Pure: no browser, no config file.
|
|
2
|
+
import { BANDS, EASE_IN_MIN_MS, FRAME_MS, INCONSISTENT_MIN_GAP_MS, INCONSISTENT_RATIO, LAYOUT_PROPS, SPARSE_DENSITY } from './constants.mjs';
|
|
3
|
+
import { animatedMove, easingKind } from './series.mjs';
|
|
4
|
+
import { isDense, measuredFor } from './sightings.mjs';
|
|
5
|
+
import { MEASURE_MIN_MS, SIGHTING_MIN_SAMPLES } from './constants.mjs';
|
|
6
|
+
|
|
7
|
+
// The declared duration and easing of the transition that animates `prop`, or null if none does.
|
|
8
|
+
export const transitionOf = (tr, prop) => {
|
|
9
|
+
const longhand = (p) => p === prop || prop.startsWith(`${p}-`) || p === 'all';
|
|
10
|
+
const i = tr.properties.findIndex(longhand);
|
|
11
|
+
if (i < 0) return null;
|
|
12
|
+
return { duration: tr.durations[i % tr.durations.length], easing: tr.easings[i % tr.easings.length] };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const isLink = (kind) => kind === 'a' || kind === 'link';
|
|
16
|
+
const isInput = (kind) => kind === 'input';
|
|
17
|
+
|
|
18
|
+
const closeToToken = (ms, tokens) => (tokens ?? []).some((x) => Math.abs(x - ms) <= 1);
|
|
19
|
+
|
|
20
|
+
// Findings for one audited element: [{ kind, element, detail, key, level? }]. `key` identifies the
|
|
21
|
+
// finding inside its element and kind (moment, property) so fingerprints survive changed numbers.
|
|
22
|
+
// `policy`: { bands, tokens }.
|
|
23
|
+
export const judgeElement = (el, policy = {}) => {
|
|
24
|
+
const bands = { ...BANDS, ...policy.bands };
|
|
25
|
+
const out = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const add = (kind, detail, key, level, extra) => {
|
|
28
|
+
if (seen.has(`${kind}|${key}`)) return;
|
|
29
|
+
seen.add(`${kind}|${key}`);
|
|
30
|
+
out.push({ kind, element: el.label, detail, key, ...(level ? { level } : {}), ...extra });
|
|
31
|
+
};
|
|
32
|
+
// A check the frames were too sparse to decide: same fingerprint as the finding it could not confirm or rule out.
|
|
33
|
+
const unsure = (check, detail, key, cadence) => add('inconclusive', `${detail}: frames too sparse: median Δt ${cadence.median} ms, max ${cadence.max} ms`, `${check} ${key}`, undefined, { check, key, cadence });
|
|
34
|
+
for (const [moment, d] of Object.entries(el.moments)) {
|
|
35
|
+
if (!d) continue;
|
|
36
|
+
const tr = d.transition ?? el.transition;
|
|
37
|
+
if (moment === 'focus' && d.hiddenUntilHover) { add('hover-only-control', 'revealed by hover only: keyboard focus leaves it invisible', 'focus'); continue; }
|
|
38
|
+
const responds = d.changed.length > 0 || d.pixels;
|
|
39
|
+
if (!responds) {
|
|
40
|
+
if (moment === 'focus') add('no-focus-feedback', 'keyboard focus shows nothing', 'focus');
|
|
41
|
+
else if (moment === 'hover' && !el.selected && !isInput(el.kind)) add('no-feedback', 'hover: nothing visible changes', 'hover', isLink(el.kind) ? 'warning' : undefined);
|
|
42
|
+
else if (moment === 'press' && !isLink(el.kind) && !isInput(el.kind)) add('no-press-feedback', 'press: nothing visible changes', 'press');
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const visual = d.changed.filter((p) => !/^outline/.test(p));
|
|
46
|
+
const animated = visual.filter((p) => transitionOf(tr, p)?.duration > 0);
|
|
47
|
+
const measured = d.motion.some((m) => m.moves.some(animatedMove));
|
|
48
|
+
if (moment !== 'focus' && visual.length && !animated.length && !measured) add('snaps', `${moment}: ${visual.join(', ')} change with no transition`, moment);
|
|
49
|
+
for (const p of animated) {
|
|
50
|
+
const { duration, easing } = transitionOf(tr, p);
|
|
51
|
+
const [lo, hi] = bands[moment] ?? [0, Infinity];
|
|
52
|
+
if ((duration < lo || duration > hi) && !closeToToken(duration, policy.tokens?.durations)) add('duration', `${moment}: ${p} ${duration} ms, outside ${lo}-${hi} ms`, `${moment} ${p}`);
|
|
53
|
+
const k = easingKind(easing);
|
|
54
|
+
if (k === 'ease-in') add('ease-in', `${moment}: ${p} eases in (${easing}): the response starts late`, `declared ${p}`);
|
|
55
|
+
if (k === 'linear' && /transform|scale|translate|opacity/.test(p)) add('linear', `${moment}: ${p} moves linearly`, p);
|
|
56
|
+
}
|
|
57
|
+
const layout = animated.filter((p) => LAYOUT_PROPS.test(p));
|
|
58
|
+
const measuredLayout = d.motion.flatMap((e) => e.moves.filter((m) => /^layout/.test(m.prop) && animatedMove(m)).map((m) => `${e.label} ${m.prop}`));
|
|
59
|
+
if (layout.length || measuredLayout.length) add('layout-animated', `${moment}: animates layout (${[...layout, ...measuredLayout].join(', ')}): moves neighbours and costs layout every frame`, moment);
|
|
60
|
+
if (!animated.length) {
|
|
61
|
+
for (const e of d.motion) for (const m of e.moves) {
|
|
62
|
+
if (m.shape === 'linear' && /^(translate|scale|opacity)/.test(m.prop) && animatedMove(m)) add('linear', `${moment}: ${e.label} ${m.prop} moves linearly (measured)`, `measured ${e.label} ${m.prop}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// The stylesheet says this gesture eases in: its own measured motion, sampled too sparsely to be fitted, cannot
|
|
66
|
+
// rule that out, so it stays visible as undecided (never nothing).
|
|
67
|
+
// The measured ease-in of the control is ruled out only by a recording that saw its own motion of a declared property
|
|
68
|
+
// with enough samples, densely enough, to fit a curve: no motion recorded, too few samples or sparse ones decide nothing.
|
|
69
|
+
// A declared duration too short to hold that many frames at 60 Hz is not judged by measurement at all (the declared curve
|
|
70
|
+
// is), and a recording that was dense is never called too sparse.
|
|
71
|
+
const wanted = new Set(animated.filter((p) => easingKind(transitionOf(tr, p).easing) === 'ease-in' && transitionOf(tr, p).duration >= MEASURE_MIN_MS).flatMap(measuredFor));
|
|
72
|
+
if (wanted.size) {
|
|
73
|
+
const root = d.motion.find((e) => e.root);
|
|
74
|
+
const label = d.rootLabel ?? root?.label;
|
|
75
|
+
const mine = (root?.moves ?? []).filter((m) => wanted.has(m.prop));
|
|
76
|
+
const fitted = (m) => m.samples >= SIGHTING_MIN_SAMPLES && m.samples / (m.durationMs / FRAME_MS) >= SPARSE_DENSITY && !m.easeInTie;
|
|
77
|
+
if (label && mine.length && isDense(d.cadence) && !mine.some((m) => (m.shape === 'ease-in' && m.durationMs > EASE_IN_MIN_MS) || fitted(m))) {
|
|
78
|
+
// Dense frames and a fit that cannot tell the curve from an ease-in: the declared curve decides, so it is the finding.
|
|
79
|
+
if (mine.some((m) => m.easeInTie && m.durationMs > EASE_IN_MIN_MS)) add('ease-in', `${moment}: ${label} measured ease-in (the fit ties with it and the stylesheet declares it)`, `measured ${label}`);
|
|
80
|
+
} else if (label && !isDense(d.cadence) && !mine.some((m) => (m.shape === 'ease-in' && m.durationMs > EASE_IN_MIN_MS) || fitted(m))) {
|
|
81
|
+
const worst = mine.map((m) => m.cadence).sort((x, y) => y.max - x.max)[0];
|
|
82
|
+
if (worst) unsure('ease-in', `${moment}: ${label} is declared to ease in, the measured curve cannot confirm it`, `measured ${label}`, worst);
|
|
83
|
+
else add('inconclusive', `${moment}: ${label} is declared to ease in and its motion was not recorded with enough samples to fit a curve`, `ease-in measured ${label}`, undefined, { check: 'ease-in', key: `measured ${label}`, cadence: d.cadence });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const e of d.motion) for (const m of e.moves) {
|
|
87
|
+
if (m.easeInTie && m.durationMs > EASE_IN_MIN_MS && m.samples / (m.durationMs / FRAME_MS) < SPARSE_DENSITY) unsure('ease-in', `${moment}: ${e.label} ${m.prop} may ease in`, `measured ${e.label}`, m.cadence);
|
|
88
|
+
if (m.shape === 'ease-in' && m.durationMs > EASE_IN_MIN_MS) add('ease-in', `${moment}: ${e.label} ${m.prop} measured ease-in over ${m.durationMs} ms`, `measured ${e.label}`);
|
|
89
|
+
for (const j of m.jumps) add('jump', `${moment}: ${e.label} ${m.prop} jumps ${j.size} at ${j.atMs} ms`, `${moment} ${e.label} ${m.prop}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const d of Object.values(el.moments)) {
|
|
93
|
+
const tr = d?.transition ?? el.transition;
|
|
94
|
+
if (tr.properties.includes('all') && tr.durations.some((x) => x > 0)) {
|
|
95
|
+
add('transition-all', '`transition: all` animates whatever changes, layout included', 'all');
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const u of el.undecided ?? []) add('inconclusive', u.detail, `${u.check} ${u.key}`, undefined, { check: u.check, key: u.key, cadence: u.cadence });
|
|
100
|
+
if (el.interruptUnsure && !el.interrupt?.length) unsure('interrupt-jump', 'hover left mid-way: the animation restarted but the value cannot be followed', 'hover', el.interruptUnsure);
|
|
101
|
+
if (el.interrupt?.length) add('interrupt-jump', `hover left mid-way: ${el.interrupt.map((j) => `${j.label} ${j.prop}`).join(', ')} jump instead of reversing`, 'hover');
|
|
102
|
+
if (el.reduced?.moves) add('reduced-motion-moves', `with prefers-reduced-motion it still moves (${el.reduced.moves})`, 'press');
|
|
103
|
+
if (el.reduced?.residue) add('reduced-motion-residue', `with prefers-reduced-motion a transform stays after release (${el.reduced.residue})`, 'press');
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Same kind of control, same moment, different declared durations: no single motion grammar.
|
|
108
|
+
export const inconsistencies = (elements, policy = {}) => {
|
|
109
|
+
const groups = new Map();
|
|
110
|
+
for (const el of elements) for (const [moment, d] of Object.entries(el.moments)) {
|
|
111
|
+
if (!d || moment === 'focus') continue;
|
|
112
|
+
const tr = d.transition ?? el.transition;
|
|
113
|
+
const durs = d.changed.map((p) => transitionOf(tr, p)?.duration).filter((x) => x > 0);
|
|
114
|
+
if (!durs.length) continue;
|
|
115
|
+
const key = `${el.kind} ${moment}`;
|
|
116
|
+
if (!groups.has(key)) groups.set(key, new Map());
|
|
117
|
+
const top = Math.max(...durs);
|
|
118
|
+
groups.get(key).set(top, [...(groups.get(key).get(top) ?? []), el.label]);
|
|
119
|
+
}
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [key, byDur] of groups) {
|
|
122
|
+
const ds = [...byDur.keys()].sort((a, b) => a - b);
|
|
123
|
+
if (ds.length < 2 || ds.at(-1) / ds[0] < INCONSISTENT_RATIO || ds.at(-1) - ds[0] < INCONSISTENT_MIN_GAP_MS) continue;
|
|
124
|
+
if (ds.every((x) => closeToToken(x, policy.tokens?.durations))) continue;
|
|
125
|
+
out.push({ kind: 'inconsistent-duration', element: key, key, detail: ds.map((x) => `${x} ms (${byDur.get(x).slice(0, 2).join(', ')}${byDur.get(x).length > 2 ? ', ...' : ''})`).join(' vs ') });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The single registry of finding kinds. SKILL.md's table is checked against it by the tests.
|
|
2
|
+
// source: 'frames' = measured from the per-frame trace, 'pixels' = screenshot or computed-style
|
|
3
|
+
// comparison, 'css' = read from declared CSS, 'events' = Animation objects and DOM events.
|
|
4
|
+
export const LEVELS = ['off', 'info', 'warning', 'error'];
|
|
5
|
+
|
|
6
|
+
export const KINDS = {
|
|
7
|
+
'no-feedback': { level: 'error', source: 'pixels', user: 'Hover changes nothing on a button: the UI feels dead (links: warning; inputs: not reported)' },
|
|
8
|
+
'no-press-feedback': { level: 'warning', source: 'pixels', user: 'Press changes nothing on a button (links and inputs: not reported)' },
|
|
9
|
+
'no-focus-feedback': { level: 'error', source: 'pixels', user: 'Keyboard focus is invisible' },
|
|
10
|
+
'hover-only-control': { level: 'warning', source: 'pixels', user: 'A control revealed only by pointer hover stays invisible when it gets keyboard focus' },
|
|
11
|
+
snaps: { level: 'warning', source: 'css', user: 'Feedback exists but jumps: the changed properties have no transition' },
|
|
12
|
+
'layout-animated': { level: 'error', source: 'frames', user: 'Width, height, top, margin, padding or font animate: neighbours shift and layout runs every frame' },
|
|
13
|
+
'long-frame': { level: 'error', source: 'frames', user: 'A frame over 50 ms during hover, a click transition or scroll (warning on a dev build)' },
|
|
14
|
+
'ease-in': { level: 'error', source: 'css', user: 'The response starts slow: declared curve or measured trajectory' },
|
|
15
|
+
duration: { level: 'warning', source: 'css', user: 'Declared duration outside the band for the gesture (hover 80-300, press 50-200, focus up to 250 ms)' },
|
|
16
|
+
linear: { level: 'warning', source: 'css', user: 'A transform or opacity transition declared linear: mechanical' },
|
|
17
|
+
'transition-all': { level: 'warning', source: 'css', user: '`transition: all` animates whatever changes, layout included' },
|
|
18
|
+
'inconsistent-duration': { level: 'warning', source: 'css', user: 'Same kind of control and gesture with declared durations 1.5x and 60 ms apart' },
|
|
19
|
+
jump: { level: 'error', source: 'frames', user: 'A frame moves several times faster than a smooth curve allows: a teleporting animation' },
|
|
20
|
+
'interrupt-jump': { level: 'error', source: 'events', user: 'Leaving hover mid-way, or clicking again mid-transition, jumps instead of reversing' },
|
|
21
|
+
'not-interruptible': { level: 'error', source: 'events', user: 'A toggle clicked back part-way through its motion restarts from the other end (needs --interact)' },
|
|
22
|
+
'ungated-hover': { level: 'warning', source: 'css', user: '`:hover` moves things outside `@media (hover: hover)`: it sticks after a tap on touch screens' },
|
|
23
|
+
'reduced-motion-moves': { level: 'error', source: 'css', user: 'With prefers-reduced-motion a press still translates, rotates or scales by more than 6 %' },
|
|
24
|
+
'reduced-motion-residue': { level: 'error', source: 'css', user: 'With prefers-reduced-motion a transform stays after release' },
|
|
25
|
+
'reduced-motion-loop': { level: 'error', source: 'events', user: 'An infinite moving animation keeps running under reduced motion (small rotate-only spinners excepted)' },
|
|
26
|
+
'reduced-motion-hidden': { level: 'error', source: 'pixels', user: 'Content waits for an entrance that reduced motion skipped: it stays at opacity 0' },
|
|
27
|
+
'reduced-motion-missing': { level: 'warning', source: 'css', user: 'Things move and no readable stylesheet has a reduced-motion rule (info when some sheets are unreadable)' },
|
|
28
|
+
'no-entrance': { level: 'info', source: 'events', user: 'A section below the fold appears without an entrance (opt-in with --entrances; never fails the run)' },
|
|
29
|
+
inconclusive: { level: 'warning', source: 'frames', user: 'The frames were too sparse to confirm or rule out a finding (its `check`): rerun on a quieter machine, without --cpu; never fails the run' },
|
|
30
|
+
'click-target': { level: 'error', source: 'pixels', user: 'The --click selector matches nothing visible' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const kindNames = () => Object.keys(KINDS);
|