From e7dd3deda840183baa765d4cbc6a798dc1cdcf02 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 28 May 2026 13:57:09 +0100 Subject: [PATCH] =?UTF-8?q?feat(stdlib):=20motion=20binding=20=E2=80=94=20?= =?UTF-8?q?animateMini/tween/spring/ease=20(bindings=20#4=20=E2=86=92=20?= =?UTF-8?q?=E2=97=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/bindings-roadmap.adoc | 4 +- lib/codegen_deno.ml | 17 +++++ stdlib/Motion.affine | 49 +++++++++++- tests/codegen-deno/motion_smoke.affine | 19 ++++- tests/codegen-deno/motion_smoke.harness.mjs | 85 ++++++++++++++++++--- 5 files changed, 157 insertions(+), 17 deletions(-) diff --git a/docs/bindings-roadmap.adoc b/docs/bindings-roadmap.adoc index fdaa1c91..6b804606 100644 --- a/docs/bindings-roadmap.adoc +++ b/docs/bindings-roadmap.adoc @@ -67,9 +67,9 @@ no further significant ReScript → AffineScript work is tractable. |4 |*motion* (animate, animateMini, tween, ease, spring) -|`◐` scaffold (animate / await / cancel landed) +|`●` usable |`stdlib/Motion.affine` (eventual home: `affinescript-motion`) -|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. +|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. |5 |*WASM-exports calling pattern* — invoke individual `exports.fn_name(args)` from a `WasmExports` value diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 2552d477..dbbb6048 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -173,6 +173,18 @@ const __as_motionCancel = (controls) => { if (controls && typeof controls.cancel === "function") controls.cancel(); return 0; }; +// `animateMini` / `tween` / `spring` / `ease` — bindings #4 follow-up +// surface. Each helper resolves the host method on globalThis.__as_motion +// at call time so a mock that only stubs a subset still works for the +// rest (the smoke harness exercises every variant). +const __as_motionAnimateMini = (target, keyframes, options) => + globalThis.__as_motion.animateMini(target, keyframes, options); +const __as_motionTween = (target, from, to, options) => + globalThis.__as_motion.tween(target, from, to, options); +const __as_motionSpring = (target, keyframes, springConfig) => + globalThis.__as_motion.spring(target, keyframes, springConfig); +const __as_motionEase = (name) => + globalThis.__as_motion.ease(name); // ---- pixi.js (bindings #1): consumer-provided import ---- // Host JS environment exposes globalThis.__as_pixi (the PIXI namespace // from `import * as PIXI from "pixi.js"`). Tests set it in the harness @@ -417,6 +429,11 @@ let () = b "pixiUiSwitchNew" (fun a -> Printf.sprintf "__as_pixiUiSwitchNew(%s)" (arg 0 a)); b "pixiUiSwitchOnChange" (fun a -> Printf.sprintf "__as_pixiUiSwitchOnChange(%s, %s)" (arg 0 a) (arg 1 a)); b "pixiUiSwitchAsContainer" (fun a -> Printf.sprintf "__as_pixiUiSwitchAsContainer(%s)" (arg 0 a)); + (* ---- motion extras (bindings #4 follow-up) ---- *) + b "motionAnimateMini" (fun a -> Printf.sprintf "__as_motionAnimateMini(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); + b "motionTween" (fun a -> Printf.sprintf "__as_motionTween(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)); + b "motionSpring" (fun a -> Printf.sprintf "__as_motionSpring(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); + b "motionEase" (fun a -> Printf.sprintf "__as_motionEase(%s)" (arg 0 a)); (* Generic JS array push helper (returns the array, fluent). *) b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a)); (* ---- honest string/number primitives underpinning the diff --git a/stdlib/Motion.affine b/stdlib/Motion.affine index b612aac7..c7479f87 100644 --- a/stdlib/Motion.affine +++ b/stdlib/Motion.affine @@ -16,8 +16,8 @@ // bindings roadmap is the long-term destination; the migration from // here to there is additive and source-compatible. // -// Surface coverage in this version: `animate` (with await + cancel). -// Follow-ups (deferred): `animateMini`, `tween`, `ease`, `spring`, +// Surface coverage in this version: `animate` (with await + cancel), +// `animateMini`, `tween`, `spring`, `ease`. Remaining follow-ups: // keyframe-typing, transform-property typing. Status row in // `docs/bindings-roadmap.adoc` (#4) updates with each coverage tranche. @@ -56,3 +56,48 @@ pub extern fn motionAwait(controls: AnimationControls) -> Int / { Async }; /// Returns 0. A controls value without a `.cancel` method (e.g. a /// mock) is treated as already-cancelled; this is a no-op return 0. pub extern fn motionCancel(controls: AnimationControls) -> Int; + +/// `motion.animateMini(target, keyframes, options)` — lightweight +/// variant of animate (no autoplay, no built-in promise behaviour). +/// Returns an AnimationControls handle. Used when the caller wants +/// to drive playback explicitly rather than relying on motion's +/// default autoplay + thenable semantics. +pub extern fn motionAnimateMini( + target: Json, + keyframes: Json, + options: Json +) -> AnimationControls; + +/// `motion.tween(target, from, to, options)` — one-shot interpolation +/// between explicit `from` and `to` values. Distinct from `animate` +/// in that `from` is required rather than inferred from the current +/// state of `target`. +pub extern fn motionTween( + target: Json, + from: Json, + to: Json, + options: Json +) -> AnimationControls; + +/// `motion.spring(target, keyframes, springConfig)` — physics-based +/// spring animation. `springConfig` is an opaque record carrying at +/// minimum `stiffness`, `damping`, and `mass`. Returns an +/// AnimationControls handle behaving identically to `animate`'s. +pub extern fn motionSpring( + target: Json, + keyframes: Json, + springConfig: Json +) -> AnimationControls; + +/// Opaque easing-function value. Underlying value is whatever the +/// motion library hands back from its easing constructor — typically +/// a JS function or named token. Goes into the `ease` field of an +/// options record. +pub extern type Easing; + +/// `motion.ease(name)` — construct an easing function by its canonical +/// motion-library name (`"linear"`, `"easeIn"`, `"easeOut"`, +/// `"backOut"`, etc.). The result is an opaque `Easing` value that the +/// consumer threads into an options record consumed by `motionAnimate` +/// / `motionTween` / `motionAnimateMini`. +pub extern fn motionEase(name: String) -> Easing; diff --git a/tests/codegen-deno/motion_smoke.affine b/tests/codegen-deno/motion_smoke.affine index 489f4ff1..1ab20005 100644 --- a/tests/codegen-deno/motion_smoke.affine +++ b/tests/codegen-deno/motion_smoke.affine @@ -3,13 +3,28 @@ // // The harness installs a mock at globalThis.__as_motion before // importing the generated module; we re-export thin wrappers so the -// harness can drive `motionAnimate` / `motionCancel` via stable names. +// harness can drive every motion extern via stable names. +// +// Coverage: animate / cancel (original) + animateMini / tween / +// spring / ease (bindings #4 → ● promotion). use Deno::{Json}; -use Motion::{AnimationControls, motionAnimate, motionCancel}; +use Motion::{AnimationControls, Easing, motionAnimate, motionAnimateMini, motionCancel, motionEase, motionSpring, motionTween}; pub fn smokeAnimate(target: Json, keyframes: Json, options: Json) -> AnimationControls = motionAnimate(target, keyframes, options); pub fn smokeCancel(controls: AnimationControls) -> Int = motionCancel(controls); + +pub fn smokeAnimateMini(target: Json, keyframes: Json, options: Json) -> AnimationControls = + motionAnimateMini(target, keyframes, options); + +pub fn smokeTween(target: Json, from: Json, to: Json, options: Json) -> AnimationControls = + motionTween(target, from, to, options); + +pub fn smokeSpring(target: Json, keyframes: Json, springConfig: Json) -> AnimationControls = + motionSpring(target, keyframes, springConfig); + +pub fn smokeEase(name: String) -> Easing = + motionEase(name); diff --git a/tests/codegen-deno/motion_smoke.harness.mjs b/tests/codegen-deno/motion_smoke.harness.mjs index 05db08a8..7b9abe39 100644 --- a/tests/codegen-deno/motion_smoke.harness.mjs +++ b/tests/codegen-deno/motion_smoke.harness.mjs @@ -2,36 +2,99 @@ // bindings #4 — Node ESM harness for the motion library binding. // // Installs a globalThis.__as_motion mock before importing the -// generated module, drives smokeAnimate / smokeCancel, and asserts -// the arguments + cancel side-effect were observed. +// generated module, then drives every wrapper (animate / cancel + +// animateMini / tween / spring / ease) and asserts the arguments + +// return values land as expected. import assert from "node:assert/strict"; let lastAnimateCall = null; +let lastAnimateMiniCall = null; +let lastTweenCall = null; +let lastSpringCall = null; +let lastEaseName = null; let cancelCount = 0; +const makeControls = (label) => ({ + __label: label, + then(cb) { if (cb) cb(); return this; }, + cancel() { cancelCount += 1; }, +}); + globalThis.__as_motion = { animate(target, keyframes, options) { lastAnimateCall = { target, keyframes, options }; - return { - then(cb) { if (cb) cb(); return this; }, - cancel() { cancelCount += 1; }, - }; + return makeControls("animate"); + }, + animateMini(target, keyframes, options) { + lastAnimateMiniCall = { target, keyframes, options }; + return makeControls("animateMini"); + }, + tween(target, from, to, options) { + lastTweenCall = { target, from, to, options }; + return makeControls("tween"); + }, + spring(target, keyframes, springConfig) { + lastSpringCall = { target, keyframes, springConfig }; + return makeControls("spring"); + }, + ease(name) { + lastEaseName = name; + // Return a sentinel function so consumers can verify the value + // round-trips through an options record unchanged. + const fn = (t) => t; + fn.__easingName = name; + return fn; }, }; -const { smokeAnimate, smokeCancel } = await import("./motion_smoke.deno.js"); +const { + smokeAnimate, + smokeCancel, + smokeAnimateMini, + smokeTween, + smokeSpring, + smokeEase, +} = await import("./motion_smoke.deno.js"); +// ---- animate + cancel (original surface) ---- const controls = smokeAnimate("#player", { x: 100, opacity: 0.5 }, { duration: 1.0 }); -assert.equal(lastAnimateCall.target, "#player", "target reaches host"); -assert.deepEqual(lastAnimateCall.keyframes, { x: 100, opacity: 0.5 }, "keyframes reach host"); -assert.deepEqual(lastAnimateCall.options, { duration: 1.0 }, "options reach host"); +assert.equal(lastAnimateCall.target, "#player", "animate target reaches host"); +assert.deepEqual(lastAnimateCall.keyframes, { x: 100, opacity: 0.5 }, "animate keyframes reach host"); +assert.deepEqual(lastAnimateCall.options, { duration: 1.0 }, "animate options reach host"); assert.equal(smokeCancel(controls), 0, "cancel returns 0"); assert.equal(cancelCount, 1, "cancel invoked exactly once"); -// Cancel a null-controls value is a no-op (mock controls without .cancel) assert.equal(smokeCancel({}), 0, "cancel on bare object returns 0"); assert.equal(cancelCount, 1, "cancel count unchanged for bare object"); +// ---- animateMini ---- +const miniControls = smokeAnimateMini("#hud", { y: 50 }, { duration: 0.25 }); +assert.equal(lastAnimateMiniCall.target, "#hud", "animateMini target reaches host"); +assert.deepEqual(lastAnimateMiniCall.keyframes, { y: 50 }, "animateMini keyframes reach host"); +assert.deepEqual(lastAnimateMiniCall.options, { duration: 0.25 }, "animateMini options reach host"); +assert.equal(miniControls.__label, "animateMini", "animateMini returns its controls handle"); + +// ---- tween ---- +const tweenControls = smokeTween("#enemy", { x: 0 }, { x: 200 }, { duration: 0.6 }); +assert.equal(lastTweenCall.target, "#enemy", "tween target reaches host"); +assert.deepEqual(lastTweenCall.from, { x: 0 }, "tween from reaches host"); +assert.deepEqual(lastTweenCall.to, { x: 200 }, "tween to reaches host"); +assert.deepEqual(lastTweenCall.options, { duration: 0.6 }, "tween options reach host"); +assert.equal(tweenControls.__label, "tween", "tween returns its controls handle"); + +// ---- spring ---- +const springControls = smokeSpring("#bubble", { scale: 1.4 }, { stiffness: 240, damping: 18, mass: 1 }); +assert.equal(lastSpringCall.target, "#bubble", "spring target reaches host"); +assert.deepEqual(lastSpringCall.keyframes, { scale: 1.4 }, "spring keyframes reach host"); +assert.deepEqual(lastSpringCall.springConfig, { stiffness: 240, damping: 18, mass: 1 }, "spring config reaches host"); +assert.equal(springControls.__label, "spring", "spring returns its controls handle"); + +// ---- ease ---- +const easing = smokeEase("backOut"); +assert.equal(lastEaseName, "backOut", "ease name reaches host"); +assert.equal(typeof easing, "function", "ease returns an opaque easing-function value"); +assert.equal(easing.__easingName, "backOut", "easing value carries its name through the boundary"); + console.log("motion_smoke.harness.mjs OK");