Files

286 lines
14 KiB
JavaScript
Raw Permalink Normal View History

// The motion primitives, and the places the app is wired to them.
//
// These are unit tests for physics, which is unusual for this repo, but the
// numbers here are the ones a gesture is judged by. A projection that is off by
// a factor of two turns a flick into a shove; a spring that overshoots when it
// was told not to puts a wobble on a panel nobody threw. Both are invisible in
// a screenshot and obvious in the hand, so they get asserted instead of eyeballed.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
// ---------------------------------------------------------------------------
// A controllable clock, installed before the module under test is imported so
// its rAF loop runs on virtual time. Real timers would make this test slow and
// flaky for no benefit — the integrator only cares about the deltas it is fed.
// ---------------------------------------------------------------------------
let now = 0;
let nextId = 1;
const frames = new Map();
globalThis.performance = { now: () => now };
globalThis.requestAnimationFrame = (cb) => { const id = nextId++; frames.set(id, cb); return id; };
globalThis.cancelAnimationFrame = (id) => { frames.delete(id); };
let reduceMotion = false;
globalThis.matchMedia = (q) => ({
matches: q.includes('prefers-reduced-motion') ? reduceMotion : false
});
/** Advance the virtual clock one frame and run whatever was scheduled. */
function tick(ms = 16) {
now += ms;
const due = [...frames.entries()];
frames.clear();
for (const [, cb] of due) cb(now);
}
/** Run frames until the spring reports done, or give up. */
function run(handle, maxFrames = 600) {
let n = 0;
while (!handle.done && n++ < maxFrames) tick();
return n;
}
const { spring, project, snapTarget, rubberband, velocityTracker, prefersReducedMotion, SPRING } =
await import('../src/ui/motion.js');
// ---------------------------------------------------------------------------
// project() — momentum, not proximity
// ---------------------------------------------------------------------------
{
// Apple's exponential-decay form, not the textbook v²/2a. At the default
// deceleration rate a metre-per-second flick carries about half a screen.
assert.ok(Math.abs(project(1000) - 499) < 1,
'project(1000px/s) must be ~499px — this is the constant every snap ' +
'decision is measured against');
assert.equal(project(0), 0, 'a release with no velocity travels nowhere');
assert.ok(project(-800) < 0, 'projection keeps the sign of the gesture');
assert.ok(project(2000) > project(1000),
'a faster flick must project further, or velocity is being discarded');
// A snappier deceleration rate must land shorter, not longer.
assert.ok(project(1000, 0.99) < project(1000, 0.998));
}
// ---------------------------------------------------------------------------
// snapTarget() — where the gesture was going, not where it stopped
//
// These are the drawer's actual numbers: a 292px panel, closed at -292, open
// at 0. Getting this wrong is the difference between a drawer you can flick
// shut and one that argues with you.
// ---------------------------------------------------------------------------
{
const W = 292;
const ends = [-W, 0];
// Released at a standstill, barely open: it falls back closed.
assert.equal(snapTarget(-250, 0, ends), -W);
// Released at a standstill, mostly open: it completes.
assert.equal(snapTarget(-40, 0, ends), 0);
// The case position alone gets wrong. Dragged nearly all the way open, then
// flicked back hard — intent is unmistakably "away", even though the panel
// is sitting closer to open than to closed at the instant of release.
assert.equal(snapTarget(-30, -1600, ends), -W,
'a hard flick must be decided by its direction, not by where it let go');
// And the mirror: barely peeked open, but thrown forward.
assert.equal(snapTarget(-260, 1600, ends), 0,
'a flick towards open must be allowed to arrive');
// Below the flick threshold, momentum still counts — a gentle push that was
// clearly going somewhere should not be snapped back to the nearest edge.
assert.equal(snapTarget(-160, 180, ends), 0,
'a slow push past the midpoint should complete on its projection');
// Degenerate inputs must not throw; a gesture with nowhere to go stays put.
assert.equal(snapTarget(-100, 500, []), -100);
assert.equal(snapTarget(-100, 500, [-100]), -100);
}
// ---------------------------------------------------------------------------
// rubberband() — resistance, not a wall
// ---------------------------------------------------------------------------
{
assert.equal(rubberband(0, 300), 0, 'at the boundary there is no resistance yet');
// The defining property: the element keeps following, but always less than
// the finger. Equal movement would mean no boundary; zero would mean a wall.
for (const overshoot of [10, 50, 120, 400, 2000]) {
const out = rubberband(overshoot, 300);
assert.ok(out > 0 && out < overshoot,
`overshoot ${overshoot} must still move the element, but less than the finger`);
}
// Resistance increases: each extra pixel of finger buys less travel.
const gain = (o) => rubberband(o + 1, 300) - rubberband(o, 300);
assert.ok(gain(200) < gain(10), 'resistance must grow the further past the edge you drag');
assert.ok(rubberband(-100, 300) < 0, 'resistance is symmetric');
assert.equal(rubberband(100, 0), 0, 'a zero-sized dimension cannot resist anything');
}
// ---------------------------------------------------------------------------
// velocityTracker() — the number handed to the spring on release
// ---------------------------------------------------------------------------
{
const v = velocityTracker();
assert.equal(v.get(0), 0, 'a single sample is not a velocity');
// 100px over 100ms = 1000px/s, averaged over the window rather than taken
// from the last pair of events, which is far too noisy to animate from.
v.add(0, 0); v.add(50, 50); v.add(100, 100);
assert.ok(Math.abs(v.get(100) - 1000) < 1, 'steady drag must report its true speed');
// A finger that has come to rest has stopped, whatever the older samples say.
// Without this, lifting after a pause throws the element across the screen.
assert.equal(v.get(1000), 0, 'a stale history must not resurrect an old velocity');
v.reset();
assert.equal(v.get(0), 0);
}
// ---------------------------------------------------------------------------
// spring() — arrives, and only bounces when told to
// ---------------------------------------------------------------------------
{
// Critically damped: the default for anything the user did not throw.
let peak = -Infinity;
const s = spring({
from: 0, to: 100,
damping: SPRING.move.damping, response: SPRING.move.response,
onUpdate: (v) => { peak = Math.max(peak, v); }
});
run(s);
assert.ok(s.done, 'the spring must come to rest, not run forever');
assert.equal(s.value, 100, 'and it must rest exactly on the target');
assert.ok(peak <= 100.01, `damping 1.0 must not overshoot (peaked at ${peak.toFixed(2)})`);
}
{
// Under-damped: overshoot is the whole point, and it is what a flick earns.
let peak = -Infinity;
const s = spring({
from: 0, to: 100,
damping: SPRING.drawer.damping, response: SPRING.drawer.response,
velocity: 900,
onUpdate: (v) => { peak = Math.max(peak, v); }
});
run(s);
assert.ok(peak > 100, 'damping 0.8 with velocity behind it must overshoot');
assert.equal(s.value, 100, 'and still settle on the target');
}
{
// Velocity handoff: the spring must leave the gesture at the speed the
// gesture arrived. Starting from rest is the visible seam between dragging
// and animating that separates "fluid" from merely "fine".
const slow = spring({ from: 0, to: 100, velocity: 0, onUpdate() {} });
const fast = spring({ from: 0, to: 100, velocity: 2000, onUpdate() {} });
tick(); tick();
assert.ok(fast.value > slow.value,
'a spring handed a release velocity must already be ahead after two frames');
slow.stop(); fast.stop();
}
{
// Interruption and reversal. Re-targeting keeps the live velocity instead of
// resetting it to zero, so a reversed gesture bends rather than hitting a
// brick wall at the moment the user changed their mind.
const s = spring({ from: 0, to: 300, response: 0.5, onUpdate() {} });
tick(); tick(); tick();
const mid = s.value;
const carried = s.velocity;
assert.ok(mid > 0 && mid < 300, 'caught mid-flight');
assert.ok(carried > 0, 'and still moving');
s.retarget(0);
assert.equal(s.velocity, carried, 'retarget must not zero the velocity');
assert.equal(s.value, mid, 'nor teleport the presentation value');
run(s);
assert.equal(s.value, 0, 'the reversed spring lands on the new target');
}
{
// stop() must actually unschedule, or a dismissed drawer keeps burning
// frames for the life of the session.
const s = spring({ from: 0, to: 100, onUpdate() {} });
tick();
assert.ok(frames.size > 0, 'a live spring keeps a frame queued');
s.stop();
assert.equal(frames.size, 0, 'stop() must unschedule the pending frame');
tick();
assert.equal(frames.size, 0, 'and never queue another');
assert.ok(s.done);
}
{
// Reduced motion: no travel, but the caller still gets its value. Silently
// dropping the update would leave the drawer stuck off-screen.
reduceMotion = true;
assert.equal(prefersReducedMotion(), true);
const seen = [];
const s = spring({ from: 0, to: 100, onUpdate: (v) => seen.push(v) });
assert.deepEqual(seen, [100], 'with motion reduced the value arrives immediately');
assert.ok(s.done, 'and nothing is left animating');
assert.equal(frames.size, 0, 'no frame is scheduled at all');
// Opting out is possible for motion that is not vestibular.
const t = spring({ from: 0, to: 100, respectReducedMotion: false, onUpdate() {} });
assert.equal(t.done, false, 'respectReducedMotion:false still animates');
t.stop();
reduceMotion = false;
}
// ---------------------------------------------------------------------------
// Wiring — the primitives are worth nothing sitting in a file nobody imports
// ---------------------------------------------------------------------------
const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const css = readFileSync(new URL('../src/styles/apple-motion.css', import.meta.url), 'utf8');
assert.match(app, /from '\.\/ui\/motion\.js'/, 'app.jsx must use the motion primitives');
// The drawer is the one true gesture surface in the app. If it goes back to a
// display toggle, everything above is decoration.
assert.match(app, /onPointerDown: drawerDown/, 'the drawer must be dragged, not toggled');
assert.match(app, /snapTarget\(offsetRef\.current, v, \[-w, 0\]/,
'the release must be resolved by projection, not by which edge is nearer');
assert.match(app, /onPointerMove: drawerMove/);
assert.match(app, /onPointerCancel: drawerUp/, 'a cancelled pointer must release the drag too');
assert.match(app, /setPointerCapture/, 'tracking must survive the pointer leaving the panel');
assert.ok(app.includes("touchAction: 'pan-y'"),
'the drawer must hand the vertical axis back to the browser, or its list stops scrolling');
assert.ok(!/display: drawerOpen \? 'block' : 'none'/.test(app),
'the old display toggle must be gone — it is what made the drawer un-grabbable');
// Every programmatic scroll goes through the reduced-motion check.
assert.ok(!app.includes("behavior: 'smooth'"),
"no raw behavior:'smooth' — it must go through scrollBehavior()");
assert.match(app, /const scrollBehavior = \(\) =>/);
// The stylesheet has to reach the page, and it has to come after the one it argues
// with. The sheets are no longer linked individually — scripts/build-css.js concatenates
// them into assets/app.css to save eight render-blocking round trips — so the order that
// matters is the order in that list, which is the order they end up in the file.
assert.match(html, /\/assets\/app\.css/, 'the page must link the bundled stylesheet');
const cssOrder = [...readFileSync(new URL('../scripts/build-css.js', import.meta.url), 'utf8')
.matchAll(/'((?:src|assets)\/[^']+\.css)'/g)].map((m) => m[1]);
const idxMotion = cssOrder.indexOf('src/styles/apple-motion.css');
const idxComponents = cssOrder.indexOf('src/styles/components.css');
assert.ok(idxMotion !== -1, 'apple-motion.css is not in the CSS bundle, so it never reaches the page');
assert.ok(idxMotion > idxComponents,
'apple-motion.css must come after components.css — it settles arguments on source order');
// The three accessibility signals, all three of them.
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(css, /@media \(prefers-reduced-transparency: reduce\)/);
assert.match(css, /@media \(prefers-contrast: more\)/);
// Press feedback exists and lands on :active, which fires on pointer-down.
assert.match(css, /button:not\(:disabled\):active/, 'controls must acknowledge the press itself');
assert.match(css, /touch-action: manipulation/, 'the ~300ms tap delay must be dropped');
// Tracking is a scale, not a constant.
const tracking = [...css.matchAll(/letter-spacing: (-?[\d.]+)em/g)].map((m) => Number(m[1]));
assert.ok(tracking.some((t) => t < -0.01), 'display sizes must tighten');
assert.ok(tracking.some((t) => t > 0), 'small sizes must open up');
console.log('apple-motion: ok');