Skip to content

Commit e7dd3de

Browse files
hyperpolymathclaude
andcommitted
feat(stdlib): motion binding — animateMini/tween/spring/ease (bindings #4 → ●)
Promotes bindings #4 (motion) from ◐ scaffold to ● usable by adding the four deferred surfaces flagged in docs/bindings-roadmap.adoc: - motionAnimateMini — lightweight variant (no autoplay / no thenable). - motionTween — one-shot from/to interpolation. - motionSpring — physics-based spring with stiffness/damping/mass. - motionEase — easing-function constructor + opaque Easing type. stdlib/Motion.affine gains the four extern fns plus the new opaque `Easing` type. The Deno-ESM codegen prelude gains `__as_motionAnimateMini` / `__as_motionTween` / `__as_motionSpring` / `__as_motionEase` runtime helpers (each resolves the host method on globalThis.__as_motion at call time so a partial mock still works); the lowering table gains the four matching extern entries. The motion_smoke fixture is extended (same .affine + .harness.mjs pair — no new files) with smokeAnimateMini / smokeTween / smokeSpring / smokeEase wrappers and arg-routing + return-value assertions for each. All 8 codegen-deno harnesses pass locally. The bindings-roadmap row #4 flips ◐ → ● and the rationale lists the full surface; remaining follow-ups (typed keyframe shapes, typed transform properties, migration to a dedicated affinescript-motion package) are explicitly out of scope for the ● promotion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7deeeb commit e7dd3de

5 files changed

Lines changed: 157 additions & 17 deletions

File tree

docs/bindings-roadmap.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ no further significant ReScript → AffineScript work is tractable.
6767

6868
|4
6969
|*motion* (animate, animateMini, tween, ease, spring)
70-
|`◐` scaffold (animate / await / cancel landed)
70+
|`●` usable
7171
|`stdlib/Motion.affine` (eventual home: `affinescript-motion`)
72-
|idaptik `src/bindings/Motion.res`; player + UI transitions. Initial surface (`motionAnimate` / `motionAwait` / `motionCancel`) lands in `stdlib/` parallel to Http / Sqlite / Crypto; consumer provides `globalThis.__as_motion` at module-init time. Test fixture: `tests/codegen-deno/motion_smoke.{affine,harness.mjs}`. Follow-ups: `animateMini`, `tween`, `ease`, `spring`, typed keyframe shapes.
72+
|idaptik `src/bindings/Motion.res`; player + UI transitions. Full surface (`motionAnimate` / `motionAwait` / `motionCancel` / `motionAnimateMini` / `motionTween` / `motionSpring` / `motionEase`) lives in `stdlib/` parallel to Http / Sqlite / Crypto; consumer provides `globalThis.__as_motion` at module-init time. Test fixture: `tests/codegen-deno/motion_smoke.{affine,harness.mjs}` exercises every extern. Remaining follow-ups (out of scope for `●`): typed keyframe shapes, typed transform-property surface, migration to a dedicated `affinescript-motion` package.
7373

7474
|5
7575
|*WASM-exports calling pattern* — invoke individual `exports.fn_name(args)` from a `WasmExports` value

lib/codegen_deno.ml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ const __as_motionCancel = (controls) => {
173173
if (controls && typeof controls.cancel === "function") controls.cancel();
174174
return 0;
175175
};
176+
// `animateMini` / `tween` / `spring` / `ease` — bindings #4 follow-up
177+
// surface. Each helper resolves the host method on globalThis.__as_motion
178+
// at call time so a mock that only stubs a subset still works for the
179+
// rest (the smoke harness exercises every variant).
180+
const __as_motionAnimateMini = (target, keyframes, options) =>
181+
globalThis.__as_motion.animateMini(target, keyframes, options);
182+
const __as_motionTween = (target, from, to, options) =>
183+
globalThis.__as_motion.tween(target, from, to, options);
184+
const __as_motionSpring = (target, keyframes, springConfig) =>
185+
globalThis.__as_motion.spring(target, keyframes, springConfig);
186+
const __as_motionEase = (name) =>
187+
globalThis.__as_motion.ease(name);
176188
// ---- pixi.js (bindings #1): consumer-provided import ----
177189
// Host JS environment exposes globalThis.__as_pixi (the PIXI namespace
178190
// from `import * as PIXI from "pixi.js"`). Tests set it in the harness
@@ -417,6 +429,11 @@ let () =
417429
b "pixiUiSwitchNew" (fun a -> Printf.sprintf "__as_pixiUiSwitchNew(%s)" (arg 0 a));
418430
b "pixiUiSwitchOnChange" (fun a -> Printf.sprintf "__as_pixiUiSwitchOnChange(%s, %s)" (arg 0 a) (arg 1 a));
419431
b "pixiUiSwitchAsContainer" (fun a -> Printf.sprintf "__as_pixiUiSwitchAsContainer(%s)" (arg 0 a));
432+
(* ---- motion extras (bindings #4 follow-up) ---- *)
433+
b "motionAnimateMini" (fun a -> Printf.sprintf "__as_motionAnimateMini(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
434+
b "motionTween" (fun a -> Printf.sprintf "__as_motionTween(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a));
435+
b "motionSpring" (fun a -> Printf.sprintf "__as_motionSpring(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
436+
b "motionEase" (fun a -> Printf.sprintf "__as_motionEase(%s)" (arg 0 a));
420437
(* Generic JS array push helper (returns the array, fluent). *)
421438
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
422439
(* ---- honest string/number primitives underpinning the

stdlib/Motion.affine

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
// bindings roadmap is the long-term destination; the migration from
1717
// here to there is additive and source-compatible.
1818
//
19-
// Surface coverage in this version: `animate` (with await + cancel).
20-
// Follow-ups (deferred): `animateMini`, `tween`, `ease`, `spring`,
19+
// Surface coverage in this version: `animate` (with await + cancel),
20+
// `animateMini`, `tween`, `spring`, `ease`. Remaining follow-ups:
2121
// keyframe-typing, transform-property typing. Status row in
2222
// `docs/bindings-roadmap.adoc` (#4) updates with each coverage tranche.
2323

@@ -56,3 +56,48 @@ pub extern fn motionAwait(controls: AnimationControls) -> Int / { Async };
5656
/// Returns 0. A controls value without a `.cancel` method (e.g. a
5757
/// mock) is treated as already-cancelled; this is a no-op return 0.
5858
pub extern fn motionCancel(controls: AnimationControls) -> Int;
59+
60+
/// `motion.animateMini(target, keyframes, options)` — lightweight
61+
/// variant of animate (no autoplay, no built-in promise behaviour).
62+
/// Returns an AnimationControls handle. Used when the caller wants
63+
/// to drive playback explicitly rather than relying on motion's
64+
/// default autoplay + thenable semantics.
65+
pub extern fn motionAnimateMini(
66+
target: Json,
67+
keyframes: Json,
68+
options: Json
69+
) -> AnimationControls;
70+
71+
/// `motion.tween(target, from, to, options)` — one-shot interpolation
72+
/// between explicit `from` and `to` values. Distinct from `animate`
73+
/// in that `from` is required rather than inferred from the current
74+
/// state of `target`.
75+
pub extern fn motionTween(
76+
target: Json,
77+
from: Json,
78+
to: Json,
79+
options: Json
80+
) -> AnimationControls;
81+
82+
/// `motion.spring(target, keyframes, springConfig)` — physics-based
83+
/// spring animation. `springConfig` is an opaque record carrying at
84+
/// minimum `stiffness`, `damping`, and `mass`. Returns an
85+
/// AnimationControls handle behaving identically to `animate`'s.
86+
pub extern fn motionSpring(
87+
target: Json,
88+
keyframes: Json,
89+
springConfig: Json
90+
) -> AnimationControls;
91+
92+
/// Opaque easing-function value. Underlying value is whatever the
93+
/// motion library hands back from its easing constructor — typically
94+
/// a JS function or named token. Goes into the `ease` field of an
95+
/// options record.
96+
pub extern type Easing;
97+
98+
/// `motion.ease(name)` — construct an easing function by its canonical
99+
/// motion-library name (`"linear"`, `"easeIn"`, `"easeOut"`,
100+
/// `"backOut"`, etc.). The result is an opaque `Easing` value that the
101+
/// consumer threads into an options record consumed by `motionAnimate`
102+
/// / `motionTween` / `motionAnimateMini`.
103+
pub extern fn motionEase(name: String) -> Easing;

tests/codegen-deno/motion_smoke.affine

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,28 @@
33
//
44
// The harness installs a mock at globalThis.__as_motion before
55
// importing the generated module; we re-export thin wrappers so the
6-
// harness can drive `motionAnimate` / `motionCancel` via stable names.
6+
// harness can drive every motion extern via stable names.
7+
//
8+
// Coverage: animate / cancel (original) + animateMini / tween /
9+
// spring / ease (bindings #4 → ● promotion).
710

811
use Deno::{Json};
9-
use Motion::{AnimationControls, motionAnimate, motionCancel};
12+
use Motion::{AnimationControls, Easing, motionAnimate, motionAnimateMini, motionCancel, motionEase, motionSpring, motionTween};
1013

1114
pub fn smokeAnimate(target: Json, keyframes: Json, options: Json) -> AnimationControls =
1215
motionAnimate(target, keyframes, options);
1316

1417
pub fn smokeCancel(controls: AnimationControls) -> Int =
1518
motionCancel(controls);
19+
20+
pub fn smokeAnimateMini(target: Json, keyframes: Json, options: Json) -> AnimationControls =
21+
motionAnimateMini(target, keyframes, options);
22+
23+
pub fn smokeTween(target: Json, from: Json, to: Json, options: Json) -> AnimationControls =
24+
motionTween(target, from, to, options);
25+
26+
pub fn smokeSpring(target: Json, keyframes: Json, springConfig: Json) -> AnimationControls =
27+
motionSpring(target, keyframes, springConfig);
28+
29+
pub fn smokeEase(name: String) -> Easing =
30+
motionEase(name);

tests/codegen-deno/motion_smoke.harness.mjs

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,99 @@
22
// bindings #4 — Node ESM harness for the motion library binding.
33
//
44
// Installs a globalThis.__as_motion mock before importing the
5-
// generated module, drives smokeAnimate / smokeCancel, and asserts
6-
// the arguments + cancel side-effect were observed.
5+
// generated module, then drives every wrapper (animate / cancel +
6+
// animateMini / tween / spring / ease) and asserts the arguments +
7+
// return values land as expected.
78

89
import assert from "node:assert/strict";
910

1011
let lastAnimateCall = null;
12+
let lastAnimateMiniCall = null;
13+
let lastTweenCall = null;
14+
let lastSpringCall = null;
15+
let lastEaseName = null;
1116
let cancelCount = 0;
1217

18+
const makeControls = (label) => ({
19+
__label: label,
20+
then(cb) { if (cb) cb(); return this; },
21+
cancel() { cancelCount += 1; },
22+
});
23+
1324
globalThis.__as_motion = {
1425
animate(target, keyframes, options) {
1526
lastAnimateCall = { target, keyframes, options };
16-
return {
17-
then(cb) { if (cb) cb(); return this; },
18-
cancel() { cancelCount += 1; },
19-
};
27+
return makeControls("animate");
28+
},
29+
animateMini(target, keyframes, options) {
30+
lastAnimateMiniCall = { target, keyframes, options };
31+
return makeControls("animateMini");
32+
},
33+
tween(target, from, to, options) {
34+
lastTweenCall = { target, from, to, options };
35+
return makeControls("tween");
36+
},
37+
spring(target, keyframes, springConfig) {
38+
lastSpringCall = { target, keyframes, springConfig };
39+
return makeControls("spring");
40+
},
41+
ease(name) {
42+
lastEaseName = name;
43+
// Return a sentinel function so consumers can verify the value
44+
// round-trips through an options record unchanged.
45+
const fn = (t) => t;
46+
fn.__easingName = name;
47+
return fn;
2048
},
2149
};
2250

23-
const { smokeAnimate, smokeCancel } = await import("./motion_smoke.deno.js");
51+
const {
52+
smokeAnimate,
53+
smokeCancel,
54+
smokeAnimateMini,
55+
smokeTween,
56+
smokeSpring,
57+
smokeEase,
58+
} = await import("./motion_smoke.deno.js");
2459

60+
// ---- animate + cancel (original surface) ----
2561
const controls = smokeAnimate("#player", { x: 100, opacity: 0.5 }, { duration: 1.0 });
26-
assert.equal(lastAnimateCall.target, "#player", "target reaches host");
27-
assert.deepEqual(lastAnimateCall.keyframes, { x: 100, opacity: 0.5 }, "keyframes reach host");
28-
assert.deepEqual(lastAnimateCall.options, { duration: 1.0 }, "options reach host");
62+
assert.equal(lastAnimateCall.target, "#player", "animate target reaches host");
63+
assert.deepEqual(lastAnimateCall.keyframes, { x: 100, opacity: 0.5 }, "animate keyframes reach host");
64+
assert.deepEqual(lastAnimateCall.options, { duration: 1.0 }, "animate options reach host");
2965

3066
assert.equal(smokeCancel(controls), 0, "cancel returns 0");
3167
assert.equal(cancelCount, 1, "cancel invoked exactly once");
3268

33-
// Cancel a null-controls value is a no-op (mock controls without .cancel)
3469
assert.equal(smokeCancel({}), 0, "cancel on bare object returns 0");
3570
assert.equal(cancelCount, 1, "cancel count unchanged for bare object");
3671

72+
// ---- animateMini ----
73+
const miniControls = smokeAnimateMini("#hud", { y: 50 }, { duration: 0.25 });
74+
assert.equal(lastAnimateMiniCall.target, "#hud", "animateMini target reaches host");
75+
assert.deepEqual(lastAnimateMiniCall.keyframes, { y: 50 }, "animateMini keyframes reach host");
76+
assert.deepEqual(lastAnimateMiniCall.options, { duration: 0.25 }, "animateMini options reach host");
77+
assert.equal(miniControls.__label, "animateMini", "animateMini returns its controls handle");
78+
79+
// ---- tween ----
80+
const tweenControls = smokeTween("#enemy", { x: 0 }, { x: 200 }, { duration: 0.6 });
81+
assert.equal(lastTweenCall.target, "#enemy", "tween target reaches host");
82+
assert.deepEqual(lastTweenCall.from, { x: 0 }, "tween from reaches host");
83+
assert.deepEqual(lastTweenCall.to, { x: 200 }, "tween to reaches host");
84+
assert.deepEqual(lastTweenCall.options, { duration: 0.6 }, "tween options reach host");
85+
assert.equal(tweenControls.__label, "tween", "tween returns its controls handle");
86+
87+
// ---- spring ----
88+
const springControls = smokeSpring("#bubble", { scale: 1.4 }, { stiffness: 240, damping: 18, mass: 1 });
89+
assert.equal(lastSpringCall.target, "#bubble", "spring target reaches host");
90+
assert.deepEqual(lastSpringCall.keyframes, { scale: 1.4 }, "spring keyframes reach host");
91+
assert.deepEqual(lastSpringCall.springConfig, { stiffness: 240, damping: 18, mass: 1 }, "spring config reaches host");
92+
assert.equal(springControls.__label, "spring", "spring returns its controls handle");
93+
94+
// ---- ease ----
95+
const easing = smokeEase("backOut");
96+
assert.equal(lastEaseName, "backOut", "ease name reaches host");
97+
assert.equal(typeof easing, "function", "ease returns an opaque easing-function value");
98+
assert.equal(easing.__easingName, "backOut", "easing value carries its name through the boundary");
99+
37100
console.log("motion_smoke.harness.mjs OK");

0 commit comments

Comments
 (0)