Skip to content

Commit af5d0e8

Browse files
hyperpolymathclaude
andcommitted
feat(stdlib): motion library binding — animate / await / cancel (bindings #4)
Lands the first tranche of the bindings-roadmap #4 (motion) item: animate, awaitable handle, and cancel. Surface mirrors the Http / Sqlite / Crypto stdlib pattern. Also bundles bindings #5 (wasmCall) which has been waiting on CI in PR #419 — same codegen_deno.ml runtime helper + lowering, plus the test fixture under tests/codegen-deno/wasm_call.* (already merged via #410 era; not duplicated here). Superseding PR #419 keeps the codegen_deno.ml history linear and avoids a parallel-edit merge knot. Files: - stdlib/Motion.affine — extern type AnimationControls + 3 extern fns - lib/codegen_deno.ml — __as_motion* runtime helpers + lowering entries; __as_wasmCall helper restored - tests/codegen-deno/motion_smoke.{affine,harness.mjs} — round-trip fixture with a mocked globalThis.__as_motion - docs/bindings-roadmap.adoc — row #4 status ○ → ◐ scaffold Test plan: all 7 codegen-deno harnesses green, incl. the new motion_smoke (4 assertions: target/keyframes/options pass-through, cancel side-effect, no-op cancel on bare object). Consumer responsibility: production code must set globalThis.__as_motion = motionLibrary (or compatible mock) at module init. Documented in stdlib/Motion.affine's preamble. Follow-ups (deferred, tracked in row #4 rationale): - animateMini, tween, ease, spring - typed keyframe shapes (currently opaque Json) - migrate to dedicated affinescript-motion package (additive, source- compatible) Refs #414 (closes via the wasmCall bundle), bindings #4 in docs/bindings-roadmap.adoc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac6aaae commit af5d0e8

5 files changed

Lines changed: 134 additions & 3 deletions

File tree

docs/bindings-roadmap.adoc

Lines changed: 3 additions & 3 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-
|`○`
71-
|`affinescript-motion`
72-
|idaptik `src/bindings/Motion.res`; player + UI transitions.
70+
|`◐` scaffold (animate / await / cancel landed)
71+
|`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.
7373

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

lib/codegen_deno.ml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ const __as_readDirNames = (p) => {
157157
const __as_isNotFound = (e) => (e instanceof Deno.errors.NotFound);
158158
const __as_wasmInstance = (bytes) =>
159159
new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports;
160+
const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || [])));
161+
// ---- motion (bindings #4): consumer-provided import ----
162+
// Host JS environment must expose globalThis.__as_motion (the motion
163+
// library or a compatible mock). Tests set it in the harness before
164+
// importing the generated module; production consumers typically do
165+
// `import * as m from "motion"; globalThis.__as_motion = m;` once at
166+
// module-init time. The AffineScript-side externs (stdlib/Motion.affine)
167+
// don't see this indirection — they call __as_motion* helpers directly.
168+
const __as_motionAnimate = (target, keyframes, options) =>
169+
globalThis.__as_motion.animate(target, keyframes, options);
170+
const __as_motionAwait = (controls) =>
171+
Promise.resolve(controls).then(() => 0);
172+
const __as_motionCancel = (controls) => {
173+
if (controls && typeof controls.cancel === "function") controls.cancel();
174+
return 0;
175+
};
160176
// `++` is overloaded (string concat / array concat); `a + b` would
161177
// stringify arrays. Dispatch on shape so stdlib/string.affine's
162178
// `result ++ [x]` and `a ++ b` are both correct.
@@ -250,6 +266,11 @@ let () =
250266
(* ---- misc host ---- *)
251267
b "dateNow" (fun _ -> "Date.now()");
252268
b "wasmInstance" (fun a -> Printf.sprintf "__as_wasmInstance(%s)" (arg 0 a));
269+
b "wasmCall" (fun a -> Printf.sprintf "__as_wasmCall(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
270+
(* ---- motion (bindings #4) ---- *)
271+
b "motionAnimate" (fun a -> Printf.sprintf "__as_motionAnimate(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
272+
b "motionAwait" (fun a -> Printf.sprintf "(await __as_motionAwait(%s))" (arg 0 a));
273+
b "motionCancel" (fun a -> Printf.sprintf "__as_motionCancel(%s)" (arg 0 a));
253274
(* Generic JS array push helper (returns the array, fluent). *)
254275
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
255276
(* ---- honest string/number primitives underpinning the

stdlib/Motion.affine

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// Motion.affine — bindings for the `motion` npm library (bindings #4
5+
// in docs/bindings-roadmap.adoc).
6+
//
7+
// Provides a typed surface over motion's `animate()` for tween / spring
8+
// animations. Targets the Deno-ESM backend; the consumer (or its host
9+
// wrapper) is responsible for putting the motion library at
10+
// `globalThis.__as_motion` before any generated module that uses these
11+
// externs runs. The test harness pattern is in
12+
// `tests/codegen-deno/motion_smoke.harness.mjs`.
13+
//
14+
// This file lives in `stdlib/` for parity with Http / Sqlite / Crypto.
15+
// The dedicated `affinescript-motion` package home flagged in the
16+
// bindings roadmap is the long-term destination; the migration from
17+
// here to there is additive and source-compatible.
18+
//
19+
// Surface coverage in this version: `animate` (with await + cancel).
20+
// Follow-ups (deferred): `animateMini`, `tween`, `ease`, `spring`,
21+
// keyframe-typing, transform-property typing. Status row in
22+
// `docs/bindings-roadmap.adoc` (#4) updates with each coverage tranche.
23+
24+
module Motion;
25+
26+
// `Json` from stdlib/Deno.affine is the estate's opaque-host-value
27+
// type. We reuse it rather than declaring a parallel hierarchy of
28+
// Element / Keyframes / AnimationOptions — typed shapes for those
29+
// are tracked separately under the `affinescript-motion` follow-up.
30+
use Deno::{Json};
31+
32+
// Opaque handle to an in-flight motion animation. Underlying value is
33+
// a motion `AnimationPlaybackControls` — thenable + `.cancel()`.
34+
// Treated opaquely at the AS boundary.
35+
pub extern type AnimationControls;
36+
37+
/// `motion.animate(target, keyframes, options) -> AnimationPlaybackControls`.
38+
///
39+
/// `target`, `keyframes`, and `options` cross the boundary as opaque
40+
/// `Json`. The runtime helper forwards them unchanged to the
41+
/// host-provided motion library.
42+
pub extern fn motionAnimate(
43+
target: Json,
44+
keyframes: Json,
45+
options: Json
46+
) -> AnimationControls;
47+
48+
/// Wait for an animation to finish (or be cancelled). Returns 0 on
49+
/// completion. Lowers to `await Promise.resolve(controls).then(...)`,
50+
/// so a controls value whose underlying promise rejects will surface
51+
/// here as a JS exception — consumers wanting Result semantics should
52+
/// wrap in `try`/`catch` at the call site.
53+
pub extern fn motionAwait(controls: AnimationControls) -> Int / { Async };
54+
55+
/// `controls.cancel()` — force-cancel an in-flight animation.
56+
/// Returns 0. A controls value without a `.cancel` method (e.g. a
57+
/// mock) is treated as already-cancelled; this is a no-op return 0.
58+
pub extern fn motionCancel(controls: AnimationControls) -> Int;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// bindings #4 — Motion library smoke test.
3+
//
4+
// The harness installs a mock at globalThis.__as_motion before
5+
// importing the generated module; we re-export thin wrappers so the
6+
// harness can drive `motionAnimate` / `motionCancel` via stable names.
7+
8+
use Deno::{Json};
9+
use Motion::{AnimationControls, motionAnimate, motionCancel};
10+
11+
pub fn smokeAnimate(target: Json, keyframes: Json, options: Json) -> AnimationControls =
12+
motionAnimate(target, keyframes, options);
13+
14+
pub fn smokeCancel(controls: AnimationControls) -> Int =
15+
motionCancel(controls);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// bindings #4 — Node ESM harness for the motion library binding.
3+
//
4+
// 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.
7+
8+
import assert from "node:assert/strict";
9+
10+
let lastAnimateCall = null;
11+
let cancelCount = 0;
12+
13+
globalThis.__as_motion = {
14+
animate(target, keyframes, options) {
15+
lastAnimateCall = { target, keyframes, options };
16+
return {
17+
then(cb) { if (cb) cb(); return this; },
18+
cancel() { cancelCount += 1; },
19+
};
20+
},
21+
};
22+
23+
const { smokeAnimate, smokeCancel } = await import("./motion_smoke.deno.js");
24+
25+
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");
29+
30+
assert.equal(smokeCancel(controls), 0, "cancel returns 0");
31+
assert.equal(cancelCount, 1, "cancel invoked exactly once");
32+
33+
// Cancel a null-controls value is a no-op (mock controls without .cancel)
34+
assert.equal(smokeCancel({}), 0, "cancel on bare object returns 0");
35+
assert.equal(cancelCount, 1, "cancel count unchanged for bare object");
36+
37+
console.log("motion_smoke.harness.mjs OK");

0 commit comments

Comments
 (0)