From b650fd8dd4196391365ec53ab38a3ca9fb595a3c Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Thu, 2 Jul 2026 18:45:49 -0400 Subject: [PATCH] feat: animation groups for choreographing sequences across blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Animation.group / Animation.sequence / Animation.parallel plus an animate.group option on AnimationOptions. Multiple animated blocks can share a group; sequence() wires groups end-to-end so later blocks wait for earlier blocks' animations to complete. Runtime: each animation registers with its group synchronously (plain mutable counter, safe under JS single-threading) before forking. The forked fiber awaits the group's gate Deferred, runs the animation, and signals completion via Effect.ensuring so interruption still decrements. When a group's pending count returns to zero after having been non-zero, its done Deferred resolves, which cascades to open the next group's gate. Late registrations arriving after a group has finalized run immediately — matches the one-shot-intro use case. Closes #28 Co-Authored-By: Claude Opus 4.7 --- .changeset/animation-groups.md | 43 +++++ packages/dom/src/Animation/groups.test.ts | 195 ++++++++++++++++++++++ packages/dom/src/Animation/groups.ts | 159 ++++++++++++++++++ packages/dom/src/Animation/index.ts | 12 ++ packages/dom/src/Animation/types.ts | 13 ++ packages/dom/src/Control/slotAnimation.ts | 37 +++- 6 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 .changeset/animation-groups.md create mode 100644 packages/dom/src/Animation/groups.test.ts create mode 100644 packages/dom/src/Animation/groups.ts diff --git a/.changeset/animation-groups.md b/.changeset/animation-groups.md new file mode 100644 index 00000000..81919e66 --- /dev/null +++ b/.changeset/animation-groups.md @@ -0,0 +1,43 @@ +--- +"@effex/dom": minor +--- + +Add animation groups — declarative sequencing across multiple animated blocks. Solves the "chained word-by-word intro" case where each word is its own `each` and word N should only start after word N-1 finishes. + +```ts +import { $, collect, each, stagger, Animation } from "@effex/dom"; + +const App = () => + Effect.gen(function* () { + const [greeting, name, tagline] = yield* Animation.sequence(3); + return $.div( + {}, + collect( + each(greetingLetters, { + key: (l) => l.id, + render: (l) => $.span({}, $.of(l.char)), + animate: { enter: "letter-in", stagger: stagger(40), group: greeting }, + }), + each(nameLetters, { + key: (l) => l.id, + render: (l) => $.span({}, $.of(l.char)), + animate: { enter: "letter-in", stagger: stagger(40), group: name }, + }), + each(taglineLetters, { + key: (l) => l.id, + render: (l) => $.span({}, $.of(l.char)), + animate: { enter: "letter-in", stagger: stagger(40), group: tagline }, + }), + ), + ); + }); +``` + +New API: + +- `Animation.group()` — creates a group with a gate (unresolved) and a completion signal. +- `Animation.sequence(count)` — returns `count` groups wired end-to-end: group 0's gate is open immediately, group N's gate opens when group N-1's registered animations all complete. +- `Animation.parallel(count)` — returns `count` groups with all gates open (useful nested inside `sequence` for concurrent segments in a follow-up release). +- `animate.group: AnimationGroup` — new option on any animated control (`each`, `when`, `match`, ...). The animation registers with the group synchronously, awaits the gate before starting, and signals completion when finished. + +Groups finalize when their pending count returns to zero for the first time after having been non-zero. Late registrations that arrive after finalization run immediately (gate is already open) — intended semantics for one-shot intros where post-sequence additions behave like ordinary animations. diff --git a/packages/dom/src/Animation/groups.test.ts b/packages/dom/src/Animation/groups.test.ts new file mode 100644 index 00000000..6a2016e4 --- /dev/null +++ b/packages/dom/src/Animation/groups.test.ts @@ -0,0 +1,195 @@ +import { Deferred, Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + _awaitGate, + _complete, + _register, + group, + parallel, + sequence, +} from "./groups.js"; + +describe("Animation groups", () => { + describe("group()", () => { + it("creates a group whose gate is initially closed", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const g = yield* group(); + expect(g._tag).toBe("AnimationGroup"); + expect(g._state.gateResolved).toBe(false); + expect(g._state.doneResolved).toBe(false); + expect(g._state.pending).toBe(0); + const gateOpen = yield* Deferred.isDone(g._gate); + return gateOpen; + }), + ); + expect(result).toBe(false); + }); + }); + + describe("sequence()", () => { + it("opens the first group's gate immediately", async () => { + const gateOpen = await Effect.runPromise( + Effect.gen(function* () { + const [g0] = yield* sequence(1); + return yield* Deferred.isDone(g0._gate); + }), + ); + expect(gateOpen).toBe(true); + }); + + it("keeps subsequent groups' gates closed until the prior finishes", async () => { + const gates = await Effect.runPromise( + Effect.gen(function* () { + const [g0, g1, g2] = yield* sequence(3); + const g0Open = yield* Deferred.isDone(g0._gate); + const g1Open = yield* Deferred.isDone(g1._gate); + const g2Open = yield* Deferred.isDone(g2._gate); + return { g0Open, g1Open, g2Open }; + }), + ); + expect(gates).toEqual({ g0Open: true, g1Open: false, g2Open: false }); + }); + + it("opens the next gate when the prior group completes", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const [g0, g1] = yield* sequence(2); + + // Register two animations under g0 + _register(g0); + _register(g0); + expect(g0._state.pending).toBe(2); + expect(yield* Deferred.isDone(g1._gate)).toBe(false); + + // Complete one — g0 not done yet + yield* _complete(g0); + expect(g0._state.pending).toBe(1); + expect(g0._state.doneResolved).toBe(false); + + // Complete the other — g0 done, g1 gate opens + yield* _complete(g0); + expect(g0._state.doneResolved).toBe(true); + + // Give the daemon fiber a tick to open g1's gate + yield* Deferred.await(g1._gate); + expect(g1._state.gateResolved).toBe(true); + }), + ); + }); + + it("chains multiple gates end-to-end", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const [g0, g1, g2] = yield* sequence(3); + + _register(g0); + yield* _complete(g0); + yield* Deferred.await(g1._gate); + expect(yield* Deferred.isDone(g2._gate)).toBe(false); + + _register(g1); + yield* _complete(g1); + yield* Deferred.await(g2._gate); + expect(g2._state.gateResolved).toBe(true); + }), + ); + }); + + it("returns an empty array for count 0", async () => { + const result = await Effect.runPromise(sequence(0)); + expect(result).toEqual([]); + }); + }); + + describe("parallel()", () => { + it("opens every gate immediately", async () => { + const gates = await Effect.runPromise( + Effect.gen(function* () { + const [g0, g1, g2] = yield* parallel(3); + return [ + yield* Deferred.isDone(g0._gate), + yield* Deferred.isDone(g1._gate), + yield* Deferred.isDone(g2._gate), + ]; + }), + ); + expect(gates).toEqual([true, true, true]); + }); + }); + + describe("_complete()", () => { + it("only resolves done after pending has been non-zero and returns to zero", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const g = yield* group(); + + // Never registered — completing shouldn't resolve done + expect(g._state.doneResolved).toBe(false); + + _register(g); + expect(g._state.started).toBe(true); + yield* _complete(g); + expect(g._state.doneResolved).toBe(true); + }), + ); + }); + + it("is idempotent past the first zero-crossing", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const g = yield* group(); + _register(g); + yield* _complete(g); + expect(g._state.doneResolved).toBe(true); + + // Late registration + completion — pending stays balanced but + // doneResolved doesn't re-fire (Deferred.succeed is a no-op). + _register(g); + yield* _complete(g); + expect(g._state.doneResolved).toBe(true); + }), + ); + }); + }); + + describe("_awaitGate()", () => { + it("returns immediately once the gate is open", async () => { + const value = await Effect.runPromise( + Effect.gen(function* () { + const [g0] = yield* sequence(1); + yield* _awaitGate(g0); + return "unblocked"; + }), + ); + expect(value).toBe("unblocked"); + }); + + it("blocks until the prior group completes", async () => { + const events = await Effect.runPromise( + Effect.gen(function* () { + const [g0, g1] = yield* sequence(2); + const log: string[] = []; + + const g1Fiber = yield* Effect.fork( + Effect.gen(function* () { + yield* _awaitGate(g1); + log.push("g1-open"); + }), + ); + + log.push("registering-g0"); + _register(g0); + yield* Effect.sleep(1); + log.push("completing-g0"); + yield* _complete(g0); + + yield* g1Fiber.await; + return log; + }), + ); + expect(events).toEqual(["registering-g0", "completing-g0", "g1-open"]); + }); + }); +}); diff --git a/packages/dom/src/Animation/groups.ts b/packages/dom/src/Animation/groups.ts new file mode 100644 index 00000000..5c1104b3 --- /dev/null +++ b/packages/dom/src/Animation/groups.ts @@ -0,0 +1,159 @@ +/** + * Animation groups: declarative sequencing across multiple animated blocks. + * + * A group is a shared handle that any animated control (`each`, `when`, + * `match`, ...) can attach to via `animate.group`. When groups are wired + * with `sequence()`, later groups' animations wait for the prior group's + * animations to complete before starting. + * + * @example Chained word-by-word intro: + * ```ts + * const App = () => + * Effect.gen(function* () { + * const [greeting, name, tagline] = yield* Animation.sequence(3); + * return $.div({}, collect( + * each(greetingLetters, { + * key: (l) => l.id, + * render: (l) => $.span({}, $.of(l.char)), + * animate: { enter: "letter-in", stagger: stagger(40), group: greeting }, + * }), + * each(nameLetters, { + * key: (l) => l.id, + * render: (l) => $.span({}, $.of(l.char)), + * animate: { enter: "letter-in", stagger: stagger(40), group: name }, + * }), + * each(taglineLetters, { + * key: (l) => l.id, + * render: (l) => $.span({}, $.of(l.char)), + * animate: { enter: "letter-in", stagger: stagger(40), group: tagline }, + * }), + * )); + * }); + * ``` + * + * A group's `done` signal fires the first time its pending count returns + * to zero after having been non-zero. Late registrations that arrive after + * the group is already done run immediately without gating and don't + * re-open the signal — intended for one-shot intros where new items added + * after the sequence completes should behave like ordinary animations. + */ + +import { Deferred, Effect } from "effect"; + +/** + * Opaque handle representing a group of animations that can be gated and + * awaited together. Construct via `group()`, `sequence()`, or `parallel()`. + */ +export interface AnimationGroup { + readonly _tag: "AnimationGroup"; + readonly _gate: Deferred.Deferred; + readonly _done: Deferred.Deferred; + readonly _state: { + pending: number; + started: boolean; + doneResolved: boolean; + gateResolved: boolean; + }; +} + +/** + * Create a fresh group whose gate is closed. Use `sequence()` or `parallel()` + * for the common case of building wired chains; call `group()` directly only + * if you need custom gating logic. + */ +export const group = (): Effect.Effect => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const done = yield* Deferred.make(); + return { + _tag: "AnimationGroup" as const, + _gate: gate, + _done: done, + _state: { + pending: 0, + started: false, + doneResolved: false, + gateResolved: false, + }, + }; + }); + +/** + * Create `count` groups where each opens after the prior's animations + * complete. Group 0's gate is open immediately; groups 1..N-1 gate on the + * prior group's `_done`. + */ +export const sequence = (count: number): Effect.Effect => + Effect.gen(function* () { + const groups: AnimationGroup[] = []; + for (let i = 0; i < count; i++) { + groups.push(yield* group()); + } + if (count > 0) { + yield* openGate(groups[0]); + } + for (let i = 1; i < count; i++) { + const prev = groups[i - 1]; + const curr = groups[i]; + // Daemon so the wiring outlives the render scope — deferreds get GC'd + // when the groups are unreachable. + yield* Effect.forkDaemon( + Deferred.await(prev._done).pipe(Effect.andThen(() => openGate(curr))), + ); + } + return groups; + }); + +/** + * Create `count` groups whose gates are all open immediately. Useful nested + * inside `sequence` for concurrent segments. + */ +export const parallel = (count: number): Effect.Effect => + Effect.gen(function* () { + const groups: AnimationGroup[] = []; + for (let i = 0; i < count; i++) { + const g = yield* group(); + yield* openGate(g); + groups.push(g); + } + return groups; + }); + +const openGate = (g: AnimationGroup): Effect.Effect => + Effect.gen(function* () { + if (g._state.gateResolved) return; + g._state.gateResolved = true; + yield* Deferred.succeed(g._gate, void 0); + }); + +/** + * Register an animation with the group synchronously — must be called from + * the calling fiber before its animation fiber is forked, so the counter + * reflects all sibling animations before any completion decrements it. + * Internal — invoked by the ControlCtx slot machinery. + */ +export const _register = (g: AnimationGroup): void => { + g._state.pending += 1; + g._state.started = true; +}; + +/** + * Signal that a registered animation has finished. When the pending count + * hits zero (having previously been non-zero), the group's `_done` fires. + * Idempotent past the first zero-crossing. Internal. + */ +export const _complete = (g: AnimationGroup): Effect.Effect => + Effect.gen(function* () { + g._state.pending -= 1; + if (g._state.pending === 0 && g._state.started && !g._state.doneResolved) { + g._state.doneResolved = true; + yield* Deferred.succeed(g._done, void 0); + } + }); + +/** + * Wait until the group's gate is open. Once open, returns immediately on + * subsequent calls. Internal. + */ +export const _awaitGate = (g: AnimationGroup): Effect.Effect => + Deferred.await(g._gate); diff --git a/packages/dom/src/Animation/index.ts b/packages/dom/src/Animation/index.ts index cc67ffeb..ecc96918 100644 --- a/packages/dom/src/Animation/index.ts +++ b/packages/dom/src/Animation/index.ts @@ -1,5 +1,6 @@ import { Effect } from "effect"; +import * as Groups from "./groups.js"; import type { StaggerFunction } from "./types.js"; // Re-export types @@ -11,6 +12,17 @@ export type { StaggerFunction, } from "./types.js"; +export type { AnimationGroup } from "./groups.js"; + +/** + * Group choreography primitives — see `./groups.ts` for the full contract. + */ +export const Animation = { + group: Groups.group, + sequence: Groups.sequence, + parallel: Groups.parallel, +} as const; + // Re-export core functions export { runEnterAnimation, runExitAnimation } from "./core.js"; diff --git a/packages/dom/src/Animation/types.ts b/packages/dom/src/Animation/types.ts index 7aabed51..a641e9e6 100644 --- a/packages/dom/src/Animation/types.ts +++ b/packages/dom/src/Animation/types.ts @@ -2,6 +2,8 @@ import type { Effect, Scope } from "effect"; import type { RendererContext } from "@effex/core"; +import type { AnimationGroup } from "./groups.js"; + /** * @module Animation * @@ -172,6 +174,17 @@ export interface AnimationOptions { * Called after exit animation completes, before element is removed from DOM */ onExit?: AnimationHook; + + /** + * Attach this animation to a shared {@link AnimationGroup} — the animation + * waits for the group's gate before starting, and signals completion when + * it finishes. Combined with `Animation.sequence()`, this lets you + * choreograph animations across multiple blocks (e.g. one `each` per word + * in a headline). + * + * @see {@link ./groups.ts} + */ + group?: AnimationGroup; } /** diff --git a/packages/dom/src/Control/slotAnimation.ts b/packages/dom/src/Control/slotAnimation.ts index ed89fca2..1c93311f 100644 --- a/packages/dom/src/Control/slotAnimation.ts +++ b/packages/dom/src/Control/slotAnimation.ts @@ -19,16 +19,26 @@ import { Effect, Exit, Option, Scope } from "effect"; +import { + _awaitGate, + _complete, + _register, + type AnimationGroup, +} from "../Animation/groups.js"; import { runEnterAnimation, runExitAnimation } from "../Animation/index.js"; import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; type DOMElement = HTMLElement | SVGElement; -const readAnimateOption = (): Effect.Effect => +type AnimateWithGroup = T & { group?: AnimationGroup }; + +const readAnimateOption = (): Effect.Effect< + AnimateWithGroup | undefined +> => Effect.gen(function* () { const configOpt = yield* Effect.serviceOption(AnimationConfigCtx); const config = Option.getOrUndefined(configOpt); - return (config?.list ?? config?.single) as T | undefined; + return (config?.list ?? config?.single) as AnimateWithGroup | undefined; }); /** @@ -36,6 +46,11 @@ const readAnimateOption = (): Effect.Effect => * animation plays in the background and gets interrupted if the slot scope * closes before it finishes. No-op if the element is not an HTMLElement * (SVG doesn't support the CSS-class animation path). + * + * If the animation is attached to an {@link AnimationGroup}, the group is + * registered synchronously (before the fiber forks) so its pending count + * reflects every sibling animation before any completion decrements it. + * The forked fiber then awaits the group's gate before playing. */ export const forkSlotEnter = ( element: DOMElement, @@ -45,10 +60,22 @@ export const forkSlotEnter = ( return Effect.gen(function* () { const animate = yield* readAnimateOption[1]>(); - if (animate) { - yield* runEnterAnimation(Effect.succeed(element), animate); + if (!animate) return; + + const grp = animate.group; + if (grp) { + _register(grp); } - }).pipe(Effect.forkIn(slotScope), Effect.asVoid); + + yield* Effect.gen(function* () { + if (grp) { + yield* _awaitGate(grp); + } + yield* runEnterAnimation(Effect.succeed(element), animate).pipe( + Effect.ensuring(grp ? _complete(grp) : Effect.void), + ); + }).pipe(Effect.forkIn(slotScope)); + }).pipe(Effect.asVoid); }; /**