diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aa47b18..60bf0dd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,11 @@ jobs:
name: Lint, Test & Build
runs-on: ubuntu-latest
+ env:
+ # The type-aware ESLint pass (and tsc/vitest) exceed the runner's
+ # default Node heap on this codebase; raise it to avoid OOM (exit 134).
+ NODE_OPTIONS: --max-old-space-size=8192
+
steps:
- name: Checkout code
uses: actions/checkout@v6
diff --git a/CLAUDE.md b/CLAUDE.md
index d007a51..ade2a54 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,6 +37,7 @@ CI (`.github/workflows/ci.yml`) gates on: lint → format:check → typecheck
| `./forms` | Forms & validation (spec 039) — the interactive layer: `ngModel` two-way binding + the `NgModelController` value pipeline (`$viewValue`/`$modelValue`, `$parsers`/`$formatters`/`$render`/`$setViewValue`/`$commitViewValue`/`$rollbackViewValue`, state + state-classes), the `form`/`ngForm` `FormController` (per-key control-failure-set aggregation, named form/control publish, submit + native suppression), the SINGLE `input` directive + internal `inputType` registry (`text`/`email`/`url`/`number`/`range`/`checkbox`/`radio`/`date`-family/no-model) + `textarea` + `select`/`ngOptions`/`ngList`, built-in validators (`required`/`ngRequired`/`ngMinlength`/`ngMaxlength`/`pattern`/`ngPattern` + type `email`/`number`/`url` + `min`/`max`) and custom sync/async `$validators`/`$asyncValidators` (sync-before-async, tri-state `$setValidity`, `$pending`+`ng-pending`, stale-async generation guard), `ngModelOptions` (`updateOn`/`debounce`/`allowInvalid`/`getterSetter`/`timezone`), `ngChange`. Directives are DI-only core `ng` (registered on `ngModule` via `registerForms`, NOT exported as a factory barrel — only the CONTRACT types are public). `EXCEPTION_HANDLER_CAUSES` STAYS 13 (forms errors reuse `'$compile'`). | types `NgModelController`, `FormController`, `FormControlLike`, `NgModelOptions`, `ModelOptions`, `ModelParser`, `ModelFormatter`, `SyncValidator`, `AsyncValidator`, `SelectController`, `NgOptionsDescriptor`; values `NgModelControllerImpl`, `FormControllerImpl`, `nullFormCtrl`, `SelectControllerImpl`, `createModelOptions`, `defaultModelOptions`, `resolveDebounceDelay`, `registerForms`, error classes `NgModelNonAssignableError`, `NgOptionsBadExpressionError` |
| `./location` | Browser URL service on core `ng` (spec 040) — `$location` with the full parity surface (`path`/`search`/`hash`/`url`/`absUrl`/`protocol`/`host`/`port`/`state`/`replace`, fluent setters), hashbang default (`#!` prefix, configurable via `$locationProvider.hashPrefix`) + HTML5 mode (`html5Mode(true)` → History API; `state()` only here), digest-synced via a `$rootScope.$watch` flush with the CANCELABLE `$locationChangeStart` / `$locationChangeSuccess` pair (veto → revert; initial pass fires with `newUrl === oldUrl`), browser `hashchange`/`popstate` events through injectable seams + guarded `$apply`. PURE ESM-first factory (`createLocation`, injectable `LocationRef`/`HistoryRef`/`AddEventListenerRef` seams — no `$browser` layer); LAZY DI registration on `ngModule`. Standard `encodeURIComponent` encoding (documented divergence from AngularJS's relaxed encoders); `html5Mode` `requireBase`/`rewriteLinks` accepted-not-honored — no `` detection; a subpath app configures `$locationProvider.basePath('/app')` explicitly (default `'/'`, HTML5 mode only). | `createLocation`, `$LocationProvider`, `DEFAULT_HASH_PREFIX`, `STATE_REQUIRES_HTML5_MODE_MESSAGE`, URL helpers (`parseAppUrl`, `parseHashbangUrl`, `parseHtml5Url`, `parseServerUrl`, `parseSearchString`, `serializeSearch`, `composeAppUrl`, `composeHashbangHash`, `composeHtml5Url`, `normalizePath`, `normalizeBasePath`), types `LocationService`, `SearchParams`, `SearchValue`, `SearchSetValue`, `LocationRef`, `HistoryRef`, `AddEventListenerRef`, `BrowserUrlEvent`, `CreateLocationArgs`, `Html5ModeConfig`, `ParsedAppUrl`, `ParsedServerUrl` |
| `./route` | Opt-in `ngRoute` module (spec 040) — `$routeProvider` (`when`/`otherwise` with `:name`/`:name?`/`:name*` patterns, `caseInsensitiveMatch`, trailing-slash tolerance folded into the compiled regexp), `$route` (the navigation pipeline on `$locationChangeSuccess`: update-only `$routeUpdate` branch → `redirectTo` (event-silent, `.replace()`, loop guard) → cancelable `$routeChangeStart` → template/`resolve` via ONE object-keyed `$q.all` (sync fast path for inline-template/no-resolve routes) → commit + `$routeChangeSuccess`; failures → `$routeChangeError`, never `$exceptionHandler`; plus `reload()`, `updateParams()`, `reloadOnSearch`/`reloadOnUrl`), `$routeParams` (repopulated IN PLACE), `ngView` (DI-only directive — `transclude: 'element'`, wrapper-`
` render + per-route `$controller` with resolve locals). `$RouteProvider` exported from the `@route` barrel but NOT the root barrel (the `$SanitizeProvider` precedent). | `createRoute`, `ngRoute`, `$RouteProvider` (barrel-only), `OTHERWISE_ROUTE_KEY`, `REDIRECT_LOOP_THRESHOLD`, `RouteRedirectionLoopError`, types `RouteService`, `RouteDefinition`, `Route`, `RouteParams`, `RouteParamValue`, `RoutePathKey`, `CompiledRouteEntry`, `CreateRouteArgs` |
+| `./animate` | Animations (spec 041) — `$animate` (the always-on-`ng` façade: `enter`/`leave`/`move` + `addClass`/`removeClass`/`setClass` + `enabled()` overloads + `on`/`off`, each returning a `$q` completion promise), `$$animateQueue` (the internal engine seam — core `ng` registers the SYNCHRONOUS instant engine `createCoreAnimateQueue`; the opt-in `ngAnimate` module re-registers it with the full engine via DI last-wins), `$AnimateProvider` (config-phase `register('.class', factory)` under a `-animation` provider key + `classNameFilter(regexp?)` frozen at `$get`; barrel-only, NOT in the root barrel — the `$RouteProvider` precedent), the JS driver (`createJsDriver` — class → `.animation` matching, per-op `done` aggregation, throw → `'$animate'`), the CSS driver (`createCssDriver` — `ng-enter`/`-active` prep→reflow→active choreography, duration detection, stagger, injectable `raf`/`now`/`computeStyle`/`setTimer` seams), the pre-handled runner (`createAnimateRunner`), `ngAnimateChildren` (DI-only directive on `ngAnimate`), and the `.animation(name, factory)` module DSL (config-block sugar forwarding to `$animateProvider.register`, non-widening on `TypedModule` — the `.controller` precedent). `EXCEPTION_HANDLER_CAUSES` grew 13→14 here (`'$animate'`). | `createAnimate`, `createCoreAnimateQueue`, `createAnimateQueue`, `createAnimateRunner`, `ANIMATION_CANCELLED_REASON`, `createJsDriver`, `createCssDriver`, `ngAnimate`, `$AnimateProvider` (barrel-only), types `AnimateService`, `AnimateQueue`, `AnimateRunner`, `AnimationDefinition`, `AnimationFactory`, `AnimateNodes`, `AnimateOptions`, `AnimateEventName`, `AnimateEventCallback`, `AnimatePhase`, `AnimateQueuePushOptions`, `AnimateRegistry`, `AnimationCallbackResult`, `CssDriver`, `CssDriverAnimation`, `CssDriverPayload`, `CssComputedStyle`, `JsDriver`, `JsDriverAnimation`, `JsDriverPayload`, `CreateAnimateArgs`, `CreateAnimateQueueArgs`, `CreateAnimateRunnerArgs`, `CreateCoreAnimateQueueArgs`, `CreateCssDriverArgs`, `CreateJsDriverArgs` |
## Non-obvious invariants
@@ -161,6 +162,13 @@ CI (`.github/workflows/ci.yml`) gates on: lint → format:check → typecheck
- **`$routeUpdate` is the in-place update-only branch — `pathParams` deliberately stays stale (spec 040).** Same route-definition identity (`$$route`) + rebuild opted out (`reloadOnUrl: false` for any same-route change; or `reloadOnSearch: false` with `isEqual`-unchanged path params for a query-only change) → update `current.params` + `$routeParams` IN PLACE and broadcast `$routeUpdate(current)` — no Start/Success, no teardown; `ngView` reacts ONLY to Success so the screen persists BY DESIGN. Upstream copies only `params`, leaving `current.pathParams` stale on a `reloadOnUrl: false` path change — matched exactly. `reload()` sets `forceReload` (bypassing this branch) and schedules `$evalAsync(update)` — always a full rebuild with fresh `resolve` invocations.
- **The `locals.$template` contract is the single template surface (spec 040).** On a COMMITTED route `next.locals` is ALWAYS an object; `locals.$template` is present ONLY when a template resolved to text (inline string, inline fn's string return, or the fetched `templateUrl` body), and `$template` WINS over a same-named resolve entry. `ngView` renders that slot and instantiates the route controller with `{ ...current.locals, $scope: newScope }` (`$scope` wins on collision), so resolve entries are injectable by name. An inline `template` FUNCTION that throws/returns a non-string still COMMITS (without `$template`) and `ngView`'s render-time fallback re-invokes it, routing the throw via `'$compile'` (upstream fails the whole navigation instead — documented divergence); a `templateUrl` fn throw DOES fail the navigation via `$routeChangeError`.
- **`EXCEPTION_HANDLER_CAUSES` STILL 13 after spec 040 — the `$routeChangeError` broadcast IS the routing failure channel.** Resolve rejections, template-fetch failures, and the redirect-loop error are delivered as `$routeChangeError` payloads (handled by the aggregate's `.then` arms, so no unhandled-`$q` report fires) — never via `$exceptionHandler`. The only handler-routed sites reuse existing tokens: `$location` browser-event throws → `'eventListener'`, `ngView` render-body throws → `'$compile'`. Bootstrap-style programmer errors (`$routeProvider.when` non-string path, `updateParams` with no matched route, `state()` outside HTML5 mode) throw synchronously to the caller.
+- **The instant `$animate` engine is SYNCHRONOUS — a deliberate divergence from upstream (spec 041).** Core `ng` registers `createCoreAnimateQueue` as `$$animateQueue`: every operation (`enter`/`leave`/`move`/`addClass`/`removeClass`/`setClass`) applies its end state SYNCHRONOUSLY at `push` time — byte-identical to the direct DOM code the built-in directives used before routing through `$animate` — and resolves its `$q` promise on the next digest turn. No animation classes, no delays, no per-element state; `on`/`off`/`enabled` are surface-parity no-ops (the instant engine never animates, so nothing to disable, no events to fire). Upstream core coalesces class changes post-digest even without ngAnimate; this project's instant engine does NOT, which is what makes the shipped `ng-show`/`ng-class`/forms suites pass UNEDITED once the directives are rewired through `$animate` (tech spec §1 approved decision). Only with `ngAnimate` loaded do animations start at digest end (parity there).
+- **`ngAnimate` upgrades `$animate` via the DI last-wins `$$animateQueue` override seam — no decorator, no lazy probe (spec 041).** The whole upgrade is ONE re-registration: `ngAnimate` (`src/animate/ng-animate-module.ts`) registers `$$animateQueue` with `createAnimateQueue` (the full engine), and DI last-wins across the requires chain (`src/di/registration.ts`) replaces core `ng`'s instant engine automatically when an app declares `'ngAnimate'`. The `$animate` façade, `$AnimateProvider`, and every directive call site are UNTOUCHED — swapping the engine swaps all behavior (the upstream override seam reproduced exactly). `ngAnimate` is opt-in (the `ngRoute`/`ngSanitize` precedent — omit it and pay nothing). Run-phase factories reach `$AnimateProvider`'s config-phase state (the registered-animation map + `classNameFilter` pattern) through the `$$animateRegistry` internal service the provider's constructor registers via `$provide.value` (the `$provide.$$getPhase` cross-provider-read precedent); registered `.animation` factories resolve through `$injector.get` ONCE, lazily, on first `match` — a throwing factory is reported via `'$animate'` and skipped, never taking the page down.
+- **Runner promises are PRE-HANDLED — a cancelled+unobserved animation never reaches the `'$q'` unhandled channel (spec 041).** `createAnimateRunner` (`src/animate/animate-runner.ts`) wraps a `$q` deferred and attaches ONE internal no-op rejection follow-up (`deferred.promise.then(undefined, noop)`) AT CONSTRUCTION, flipping the promise's `handled` flag synchronously (verified against `@async/q.ts`). So when `cancel()` rejects with `ANIMATION_CANCELLED_REASON` (`'cancelled'`) and `scheduleUnhandledCheck` fires next digest turn, it finds `handled === true` and reports nothing; the internal derived chain tip RESOLVES (the no-op is a function-typed `onRejected`), so it is never checked either. A caller attaching its OWN `.then`/`.catch` gets an independent derived promise and still observes the rejection normally. This is upstream parity — AngularJS's `AnimateRunner` is not a `$q` promise at all. Settlement is FINAL/idempotent: whichever of `complete`/`end`/`cancel` fires first decides; `end()` settles identically to `complete()` (the engine owns applying the end-state DOM before calling it).
+- **`'$animate'` is the 14th `EXCEPTION_HANDLER_CAUSES` token — the first length change since spec 037 (spec 041).** `EXCEPTION_HANDLER_CAUSES` grew 13→14 (`src/exception-handler/exception-handler-types.ts`); `ExceptionHandlerCause` widens automatically (append-only public-API change, the spec-037 precedent). Routed via `'$animate'`: JS animation callback (and cancel-function) throws, JS factory resolution failures at run time, the engine-level driver-`start` guard, and `$animate.on` listener callback throws. Cancelled-animation rejections are NEVER reported (pre-handled). Synchronous programmer errors (`register` name not starting with `'.'`, non-`RegExp` `classNameFilter`) throw to the CALLER, not through `$exceptionHandler` (the provider-validation precedent). The `q-surface.test.ts` guard now pins `EXCEPTION_HANDLER_CAUSES.length === 14`; grep the suite for any hardcoded `=== 13` before changing.
+- **The classList-preserving append-only guarantee is carried through `$animate` (spec 041).** The CSS driver's `addTracked` (`src/animate/css-driver.ts`) records ONLY the classes it actually added and cleanup removes exactly those on EVERY exit path (close/cancel/no-op re-check/throw), so a pre-existing author class of the same name as an animation class (`ng-enter`, `-add`, …) is never stripped — the spec-024 `ng-class`/`ng-style` `appliedClasses` mechanism reproduced on the animation surface. `setClass` (the `ng-class` flip form) is ONE coalesced animation rather than two competing ones, so consumer classes are never clobbered mid-flip. The instant engine's `applyClasses` (`src/animate/animate-dom.ts`) is a plain per-class `classList.add`/`remove` — the same append-only semantics the directives had before.
+- **Stagger is a CSS-timing offset; `ng-animate-children` gates nested animations under a structural parent (spec 041).** STAGGER (FS §2.7): at flush the batch is grouped by event and each LIVE member of a 2+ same-event group gets a 0-based `staggerIndex` passed into the CSS driver, which offsets the prep→active choreography by `staggerIndex × -stagger` delay (a lone animation gets index 0 → no offset; the JS driver ignores stagger — upstream stagger is a CSS concept). PARENT/CHILD (FS §2.8): a descendant under an ancestor running a STRUCTURAL (`enter`/`leave`/`move`, tracked in the `structurallyAnimating` `Set`) animation is SUPPRESSED (takes the instant/skip path) unless an intervening `ng-animate-children` marker re-enables it — the ancestor walk (`ancestorBlocksChild` in `animate-queue.ts`, the `$$ngControllers` walk precedent) honors the NEAREST marker at-or-below the animating parent. Class-op animations do NOT gate descendants — only structural parents do. The `ngAnimateChildren` directive (DI-only on `ngAnimate`) stashes the resolved flag on a non-enumerable `$$ngAnimateChildren` element slot: empty/`'on'`/`'true'` → enable, `'off'`/`'false'` → disable, any other value → a scope expression watched reactively.
+- **`$animate.on`/`off` dispatch is container-scoped and digest-guarded; the instant engine ignores it (spec 041).** `on(event, container, cb)` (`src/animate/animate-events.ts`) registers a listener that fires ONLY for animations on `container` or elements INSIDE it — dispatch walks from the animating element up the `parentElement` chain and matches registered containers by IDENTITY (so a listener on `C` never fires for animations elsewhere). Callbacks run `(element, phase)` with `phase` being `'start'` (ceremony begins) then `'close'` (settles) — the queue guarantees pairing by notifying `'start'` and hooking `'close'` onto the runner's `done` (which fires on EVERY termination path incl. cancel); a genuinely-performed INSTANT skip-path op notifies start-then-close back-to-back, while a coalesced-away push fires NEITHER. Callbacks dispatch inside the digest via the `$$phase`-guarded `$apply`/`$evalAsync` seam (the `$timeout` precedent); a throw routes `'$animate'` per-callback without suppressing the others. `off` has three granularities: `off(event)` / `off(event, container)` / `off(event, container, callback)`. Under the INSTANT engine `on`/`off` are documented no-ops.
## Coding conventions
@@ -169,7 +177,7 @@ CI (`.github/workflows/ci.yml`) gates on: lint → format:check → typecheck
- **No explicit return types** when TS inference handles them — let inference do the work. Annotate only on exported public-API boundaries where the declared shape is part of the contract.
- **Imports**: use path aliases (`@core`, `@parser`, `@di`, `@compiler`). `no-restricted-imports` blocks `../*` relative climbing.
- **File naming**: kebab-case (`scope-watch-delegates.ts`, `ast-flags.ts`). Tests under `src//__tests__/*.test.ts`.
-- **File size target**: under 500 lines per source file. 18 source files currently exceed it (2026-07 audit); the standout refactor candidates: `src/compiler/compile.ts` (~2300 — 4.6× the target), `src/compiler/compile-provider.ts` (1281), `src/di/module.ts` (1269), `src/compiler/compile-error.ts` (1136), `src/compiler/directive-types.ts` (978), `src/core/scope.ts` (950), `src/compiler/attributes.ts` (846), `src/di/injector.ts` (750), `src/core/ng-module.ts` (676), `src/route/route.ts` (586 — ~40% file-header TSDoc; the pipeline body itself is well under target). A dedicated `compile.ts`-split spec is the highest-value refactor.
+- **File size target**: under 500 lines per source file. 19 source files currently exceed it (2026-07 audit, post-spec-041); the standout refactor candidates: `src/compiler/compile.ts` (~2300 — 4.6× the target), `src/di/module.ts` (1373), `src/compiler/compile-provider.ts` (1281), `src/compiler/compile-error.ts` (1136), `src/compiler/directive-types.ts` (978), `src/core/scope.ts` (950), `src/compiler/attributes.ts` (846), `src/di/injector.ts` (750), `src/core/ng-module.ts` (721), `src/animate/animate-queue.ts` (613 — the ngAnimate engine, already split across `animate-queue-coalescer.ts` / `-support.ts` / `animate-events.ts` / `animate-dom.ts` siblings), `src/route/route.ts` (610 — ~40% file-header TSDoc; the pipeline body itself is well under target), `src/animate/css-driver.ts` (546). A dedicated `compile.ts`-split spec is the highest-value refactor.
## Git + spec workflow
@@ -293,6 +301,17 @@ CI (`.github/workflows/ci.yml`) gates on: lint → format:check → typecheck
| How do `redirectTo` and the redirect-loop guard work? | `src/route/route.ts` (`runRedirect`) + `src/route/route-redirect.ts` (`interpolateRedirect` — consumes path keys) + `src/route/route-error.ts` (`REDIRECT_LOOP_THRESHOLD` / `RouteRedirectionLoopError`) |
| How does `ngView` render the active route's screen? | `src/route/ng-view.ts` (Comment placeholder + wrapper `
`, the pinned render order, per-route `$controller` with `{ ...locals, $scope }`, dual cleanup registration) |
| Worked routing examples + the digest-sync / pipeline / divergence contracts? | `src/location/README.md` + `src/route/README.md` |
+| How does the `$animate` façade normalize + delegate to the engine? | `src/animate/animate.ts` (`createAnimate` — `Element \| Node[]` → `readonly Node[]`, forward to `$$animateQueue.push`) + `src/animate/animate-types.ts` (`AnimateService` / `AnimateQueue` contracts) |
+| How does the INSTANT (core `ng`) engine apply changes synchronously? | `src/animate/core-animate-queue.ts` (`createCoreAnimateQueue` — sync insert/remove/class + immediately-resolved `$q` promise, `on`/`off`/`enabled` parity no-ops) + `src/animate/animate-dom.ts` (shared node-group DOM: `insertNodes` / `removeNodes` / `applyClasses` / `splitClasses`) |
+| How does the `ngAnimate` engine queue, coalesce, cancel + gate animations? | `src/animate/animate-queue.ts` (`createAnimateQueue` — digest-end flush, per-element `WeakMap` active record, `shouldAnimate` skip gates, `startAnimation` / `closeAnimation`) + `src/animate/animate-queue-coalescer.ts` (pending-batch matrix) + `src/animate/animate-queue-support.ts` (`combineDriverAnimations` / `applyEndState`) |
+| How does `$animateProvider.register` / `classNameFilter` / `.animation` DSL work? | `src/animate/animate-provider.ts` (`$AnimateProvider` — `register` → `$provide.factory('-animation', …)`, `classNameFilter` getter/setter, the `$$animateRegistry` bridge) + `src/di/module.ts` (search for `.animation` — config-block sugar forwarding to `$animateProvider.register`) |
+| How does the runner deliver + pre-handle the completion promise? | `src/animate/animate-runner.ts` (`createAnimateRunner` — `$q` deferred, the construction-time no-op reject follow-up flipping `handled`, `complete`/`end`/`cancel` settle-once, `ANIMATION_CANCELLED_REASON`) |
+| How does the JS driver match classes + run `.animation` callbacks? | `src/animate/js-driver.ts` (`createJsDriver` — `element.classList` vs registered animations, per-op `done` aggregation, cancel-fn capture, throw → `'$animate'`) |
+| How does the CSS driver run the `ng-enter`/`-active` choreography + detect duration? | `src/animate/css-driver.ts` (`createCssDriver` — `match` sync probe, prep→reflow→raf→active, `readTimings` list-cycling parse, fallback timer, stagger offset) + `src/animate/animate-stagger.ts` (`readStaggerStepMs`) |
+| How are `$animate.on`/`off` listeners registered + dispatched? | `src/animate/animate-events.ts` (`createAnimateEvents` — the container-scoped registry, three `off` granularities, `$$phase`-guarded `(element, phase)` dispatch) |
+| How does `ng-animate-children` gate nested animations? | `src/animate/ng-animate-children.ts` (`ngAnimateChildrenDirective` — the `$$ngAnimateChildren` element stash + value forms; read by the queue's `ancestorBlocksChild` walk) |
+| How does `ngAnimate` re-register the engine + bind the seams to globals? | `src/animate/ng-animate-module.ts` (`createModule('ngAnimate', [])` re-registering `$$animateQueue` via DI last-wins; `raf`/`computeStyle`/`setTimer` global bindings; lazy `$injector.get` animation resolution) |
+| Worked animation examples + the digest/promise/cancellation + divergence contracts? | `src/animate/README.md` |
| How do all Phase 2 services fit together (whole-picture diagrams)? | context/diagrams/README.md |
| How does the Scope / digest cycle work end-to-end (diagram)? | context/diagrams/scope-and-digest.md |
| How does the expression parser work end-to-end (diagram)? | context/diagrams/expression-parser.md |
@@ -307,4 +326,5 @@ CI (`.github/workflows/ci.yml`) gates on: lint → format:check → typecheck
| How does $controller instantiate & bind controllers end-to-end (diagram)? | context/diagrams/controller.md |
| How do the built-in directives work end-to-end (diagram)? | context/diagrams/built-in-directives.md |
| How does routing ($location + ngRoute) work end-to-end (diagram)? | context/diagrams/routing.md |
+| How do animations ($animate + ngAnimate) work end-to-end (diagram)? | context/diagrams/animate.md |
| Why is a commit structured this way? | the corresponding `context/spec/-*/` directory |
diff --git a/context/diagrams/README.md b/context/diagrams/README.md
index 8ef4551..5833b96 100644
--- a/context/diagrams/README.md
+++ b/context/diagrams/README.md
@@ -51,6 +51,7 @@ watcher or expression never crashes the loop.
| [DOM compiler ($compile)](./compile.md) | `$compile(element)` walk → directive collect/sort → compile → three-phase link (pre/child/post); the controller seam, transclusion, isolate bindings, text/attr interpolation, `templateUrl`, errors via `$exceptionHandler('$compile')` |
| [Built-in directives](./built-in-directives.md) | The shared directive mechanism (restrict / priority / compile / link / scope kinds) plus per-category sub-sections: structural, visibility & binding, class & style, attribute helpers, events, pluralization, CSP/template-cache/element overrides |
| [Routing ($location + ngRoute)](./routing.md) | `$location` digest sync + cancelable `$locationChange*` events, the `$route` navigation pipeline (match → redirect → Start → template/resolve → commit), `$routeParams`, `ngView` render |
+| [Animations ($animate + ngAnimate)](./animate.md) | The `$animate` façade → `$$animateQueue` engine seam (instant core vs the opt-in `ngAnimate` full engine), the JS / CSS drivers, the pre-handled runner promise, the digest-end flush, `enabled` / `classNameFilter` / `on` / `off`, stagger + `ng-animate-children` |
## Maintenance
diff --git a/context/diagrams/animate.md b/context/diagrams/animate.md
new file mode 100644
index 0000000..aa9b971
--- /dev/null
+++ b/context/diagrams/animate.md
@@ -0,0 +1,164 @@
+# Animations ($animate + ngAnimate)
+
+## Purpose
+
+`$animate` is the animation façade every built-in structural directive and class
+toggler routes its DOM-visible changes through: `enter` / `leave` / `move` for
+insertion / removal / reposition, `addClass` / `removeClass` / `setClass` for
+class changes. It is ALWAYS available on core `ng`, wired by default to the
+synchronous **instant engine** (`$$animateQueue` = `createCoreAnimateQueue`) —
+every operation applies its end state immediately and resolves at once. Adding
+the opt-in **`ngAnimate`** module re-registers `$$animateQueue` with the full
+animation engine (DI last-wins — the override seam), and the same operations
+become animated: the JS driver runs registered `.animation` callbacks, the CSS
+driver runs the `ng-enter` / `-active` class choreography, a runner carries the
+completion promise, and `on` / `off` listeners observe start / close.
+
+## Collaborators & call order
+
+```text
+ directive call site (ng-if / ng-repeat / ng-switch / ng-include / ng-view;
+ ng-show / ng-hide / ng-class family; forms state-classes.ts)
+ │ $animate.enter/leave/move · addClass/removeClass/setClass
+ ▼
+ ┌──────────────────────────────────────────────────────────────────┐
+ │ $animate façade (createAnimate) — normalize Element|Node[] → │
+ │ readonly Node[], delegate to $$animateQueue.push(nodes, event, …) │
+ └──────────────────────────────┬───────────────────────────────────┘
+ ▼
+ ┌────────────── which $$animateQueue did DI resolve? ──────────────┐
+ │ │
+ ▼ core ng (instant) ▼ ngAnimate (full engine)
+ ┌───────────────────────────┐ ┌──────────────────────────────────────────┐
+ │ createCoreAnimateQueue │ │ createAnimateQueue │
+ │ enter/move → insertNodes │ │ enter/move insert SYNC (layout correct) │
+ │ leave → removeNodes │ │ shouldAnimate(el)? gates: │
+ │ add/remove/set → classList│ │ startup-grace · enabled() · subtree · │
+ │ q.resolve(undefined) │ │ ng-animate-children · classNameFilter │
+ │ on/off = documented NO-OP │ │ coalescer: pending-batch matrix │
+ └───────────────────────────┘ │ (class ops → 1 setClass; net-empty DROP;│
+ │ structural BEATS class; later SUPERSEDES)│
+ │ cancel in-flight active record (WeakMap) │
+ │ OR-match the two drivers ─────────────┐ │
+ └────────────────────────────────────────┼───┘
+ │
+ ┌──────────────────────────────┬──────────────────────────────┘
+ ▼ ▼
+ ┌───────────────────┐ ┌──────────────────────────────────────┐
+ │ js-driver.ts │ │ css-driver.ts │
+ │ match el.classList │ │ match: probe prep+active, read timing │
+ │ vs .animation regs │ │ (zero duration → null / skip) │
+ │ start(done): run │ │ start: +ng-animate +prep → reflow → │
+ │ per-op callbacks │ │ raf → +active → wait max(delay+dur) │
+ │ throw → '$animate' │ │ transitionend / fallback timer; │
+ │ cancel() fns │ │ stagger offset = idx × step │
+ └─────────┬──────────┘ └───────────────────┬──────────────────┘
+ └─────────── combineDriverAnimations ──────┘
+ │ (both matched → joint close)
+ ▼
+ digest-end flush: $$postDigest(fn) → raf(fn) — ONE per batch
+ │ assign stagger indices per same-event group
+ ▼
+ startAnimation(item): addElementCleanup(el, runner.end)
+ │ fireStart → events.notify('start') (Slice 9)
+ │ animation.start(closeAnimation, staggerIndex)
+ ▼
+ closeAnimation: applyEndState (leave removes / classes apply) →
+ │ structurallyAnimating.delete → runner.complete()
+ ▼
+ ┌──────────────────────────────────────────────────────────────────┐
+ │ animate-runner.ts — $q-backed handle │
+ │ promise (PRE-HANDLED: no-op reject follow-up at construction) │
+ │ complete() / end() → resolve · cancel() → reject 'cancelled'│
+ └──────────────────────────────────────────────────────────────────┘
+ │ resolve/reject schedules a digest via $q → $evalAsync
+ ▼
+ caller's .then / .catch runs on the scheduled digest turn
+```
+
+Collaborators: **`$q`** (the runner deferred; every method's returned promise),
+**`$rootScope`** (`$$postDigest` for the flush, `$$phase`-guarded `$apply` /
+`$evalAsync` for listener dispatch, startup grace), **`$injector`** (resolving
+registered `.animation` factories once, lazily, via the `$$animateRegistry`
+bridge), **`$exceptionHandler`** (JS callback throws, factory-resolution
+failures, listener throws — all cause `'$animate'`, the 14th token), the
+**`raf`** / **`computeStyle`** / **`setTimer`** globals (seam-bound by
+`ng-animate-module.ts`), and the compiler's **`addElementCleanup`** contract
+(destroy-mid-animation finalization). A cancelled runner nobody observes is
+pre-handled, so it NEVER reaches `$q`'s unhandled-rejection channel;
+`EXCEPTION_HANDLER_CAUSES` grows to 14.
+
+## Using it the primary way
+
+The ESM-first API: `createAnimate` / `createCoreAnimateQueue` /
+`createAnimateQueue` / the two drivers / `createAnimateRunner` are pure
+factories with injected seams — usable (and testable) without an injector or a
+real browser.
+
+```typescript
+import { createAnimate, createCoreAnimateQueue } from 'my-own-angularjs/animate';
+
+// The instant engine — every op applies synchronously, resolves at once.
+const $animate = createAnimate({ queue: createCoreAnimateQueue({ q }) });
+
+void $animate.enter(clone, parentEl, placeholder).then(() => {
+ // runs after the enter completes (immediately under the instant engine)
+});
+$animate.addClass(el, 'active'); // classList.add now; promise settles next digest
+```
+
+```typescript
+import { createAnimateQueue, createJsDriver, createCssDriver } from 'my-own-angularjs/animate';
+
+// The full engine with stubbed seams — unit-testable in jsdom.
+const queue = createAnimateQueue({
+ q,
+ exceptionHandler,
+ postDigest: (fn) => fn(),
+ raf: (fn) => fn(),
+ jsDriver: createJsDriver({ getAnimations, exceptionHandler }),
+ cssDriver: createCssDriver({ raf, now, computeStyle, setTimer, clearTimer }),
+ classNameFilter: () => null,
+});
+```
+
+## Using it the dependency-injection way
+
+`$animate` is registered on the core `ng` module (wired to the instant engine).
+`ngAnimate` is OPT-IN: compose it alongside the core and declare `'ngAnimate'`
+in the app's deps chain — DI last-wins re-registers `$$animateQueue` with the
+full engine, upgrading `$animate` automatically. No decorator, no lazy probe.
+
+```typescript
+import { createInjector, createModule } from 'my-own-angularjs';
+import { ngModule } from 'my-own-angularjs/core';
+import { ngAnimate } from 'my-own-angularjs/animate';
+
+const app = createModule('app', ['ng', 'ngAnimate']).animation('.fade', [
+ () => ({
+ enter(element, done) {
+ element.classList.add('fading-in');
+ done();
+ },
+ }),
+]);
+
+const injector = createInjector([ngModule, ngAnimate, app]);
+const $animate = injector.get('$animate');
+const $rootScope = injector.get('$rootScope');
+
+$rootScope.$apply(() => (scope.show = true)); // ng-if enters
+// under ngAnimate the ceremony starts after this digest settles + one raf tick
+```
+
+`$animateProvider.classNameFilter(/animate-me/)` and
+`$animateProvider.register('.slide', factory)` configure the service in a
+`config()` block; `ng-animate-children` in compiled markup opts a container's
+descendants back into animating under a structural parent.
+
+## Related diagrams
+
+- [Scopes & digest cycle](./scope-and-digest.md) — the `$$postDigest` flush and `$evalAsync` digest scheduling the runner promise leans on (`$q` backs the runner; its unhandled-rejection channel is what pre-handled promises bypass)
+- [Built-in directives](./built-in-directives.md) — the structural / class-toggling directives that call `$animate`
+- [DOM compiler ($compile)](./compile.md) — the `addElementCleanup` contract that finalizes a destroyed animation
+- [Diagram index](./README.md)
diff --git a/context/product/roadmap.md b/context/product/roadmap.md
index 5e128cd..04ba124 100644
--- a/context/product/roadmap.md
+++ b/context/product/roadmap.md
@@ -151,11 +151,11 @@ _Features that complete the full framework experience._
- [x] **ng-view:** Implement the view directive that renders route templates. _(spec 040 — `restrict: 'ECA'`, `transclude: 'element'`; renders `locals.$template` into a wrapper `
` against a fresh child scope with the per-route controller (resolve locals injectable by name, controller stashed for `require: '^ngController'`); emits `$viewContentLoaded`; render throws route via the existing `'$compile'` cause.)_
- [x] **Route Lifecycle:** Support `resolve`, route change events (`$routeChangeStart`, `$routeChangeSuccess`, `$routeChangeError`), and `$routeParams`. _(spec 040 — cancelable `$routeChangeStart`; `resolve` + template aggregated through one object-keyed `$q.all` with a staleness token (inline-template/no-resolve routes commit synchronously — a documented divergence); `$routeParams` repopulated in place; plus `redirectTo` (event-silent, `.replace()`, hardened with a 10-hop loop guard upstream lacks), `reloadOnSearch`/`reloadOnUrl` + `$routeUpdate`, `reload()`, and `updateParams()`.)_
-- [ ] **Animations**
- - [ ] **$animate Service:** Implement animation hooks for `enter`, `leave`, `move`, `addClass`, `removeClass`.
- - [ ] **CSS Animations:** Support CSS transition and keyframe-based animations triggered by directive lifecycle.
- - [ ] **JavaScript Animations:** Support programmatic animation definitions via `$animateProvider.register`.
- - [ ] **Module DSL `.animation(name, fn)`:** Expose `.animation` on `createModule(...)` as a thin wrapper over `$animateProvider.register` — ng-module parity, shared registry, no duplicated state.
+- [x] **Animations** _(spec 041 — shipped.)_
+ - [x] **$animate Service:** Implement animation hooks for `enter`, `leave`, `move`, `addClass`, `removeClass`. _(spec 041 — opt-in `ngAnimate` module upgrading an always-on core `$animate` façade + instant engine via the DI last-wins `$$animateQueue` seam; plus `enabled()`/`classNameFilter`/startup grace, completion promises, cancel/join, `on`/`off` listeners, staggering, `ng-animate-children`.)_
+ - [x] **CSS Animations:** Support CSS transition and keyframe-based animations triggered by directive lifecycle. _(spec 041 — parity `ng-enter`/`-active` + `-add`/`-active` class conventions, `ng-animate` marker, injectable `computeStyle`/`raf`/timer seams.)_
+ - [x] **JavaScript Animations:** Support programmatic animation definitions via `$animateProvider.register`. _(spec 041.)_
+ - [x] **Module DSL `.animation(name, fn)`:** Expose `.animation` on `createModule(...)` as a thin wrapper over `$animateProvider.register` — ng-module parity, shared registry, no duplicated state. _(spec 041 — config-block sugar forwarding to `$animateProvider.register`, the `.filter` precedent.)_
- [ ] **Package & Distribution**
- [ ] **npm Package:** Bundle and publish as an installable npm package with full TypeScript type declarations.
diff --git a/context/spec/041-animations/functional-spec.md b/context/spec/041-animations/functional-spec.md
new file mode 100644
index 0000000..89d9a27
--- /dev/null
+++ b/context/spec/041-animations/functional-spec.md
@@ -0,0 +1,154 @@
+# Functional Specification: Animations ($animate, CSS & JavaScript Animations)
+
+- **Roadmap Item:** Animations — `$animate` service with `enter` / `leave` / `move` / `addClass` / `removeClass` hooks, CSS transition & keyframe animations triggered by directive lifecycle, JavaScript animations via `$animateProvider.register`, and the `.animation(name, fn)` module DSL.
+- **Status:** Completed
+- **Author:** Mgrdich
+
+---
+
+## 1. Overview and Rationale (The "Why")
+
+Today, every visual change in an application built with this framework happens instantly: list items appear and vanish abruptly, views swap with no transition, and show/hide toggles flip with no visual feedback. Several already-shipped features explicitly deferred their animation behavior to this item — list iteration, visibility toggling, class changes, and form validation state classes all change synchronously with no way to ease the user's eye through the change.
+
+This feature gives application developers the classic AngularJS animation experience:
+
+- A single **animation service** that all built-in directives route their DOM-visible changes through, exposing five operations: an element **entering** the page, **leaving** the page, **moving** within a list, and a CSS class being **added** or **removed**.
+- **CSS-driven animations:** a developer writes ordinary CSS transitions or keyframes against a documented set of class names, and the framework applies those classes at the right moments — no JavaScript required.
+- **JavaScript-driven animations:** a developer registers a named animation with the module system and receives programmatic callbacks for each operation, for effects CSS cannot express.
+- **AngularJS 1.x parity** is the guiding standard: markup, CSS conventions, and registration patterns written for classic AngularJS with ngAnimate should behave the same here.
+
+Success is measured the same way as the rest of the project: full observable parity with the classic behavior, backed by a comprehensive test suite (90%+ coverage target), with a clean typed implementation that reads as a reference.
+
+---
+
+## 2. Functional Requirements (The "What")
+
+### 2.1. The animation service is always available; the opt-in module makes it animate
+
+- The animation service exists in every application with the same five operations, whether or not the animations module is loaded.
+- **Without** the animations module: every operation applies its end state **instantly** — the element appears, disappears, moves, or changes class immediately, exactly as the framework behaves today. No CSS classes for animation purposes are added, no delays are introduced.
+- **With** the animations module added to the application's dependency list: the same operations become animated — the framework looks for matching CSS transitions/keyframes and registered JavaScript animations and runs them before settling the element into its end state.
+- **Acceptance Criteria:**
+ - [x] Given an app that does NOT list the animations module, when a list item is added, it appears in its final position immediately with no animation-related classes ever visible on it.
+ - [x] Given the same app WITH the animations module listed, and CSS transition rules for the documented "enter" classes, when a list item is added, it visibly transitions (e.g., fades in) and ends in the same final state as the non-animated app.
+ - [x] An app that loads the animations module but defines no CSS rules and registers no JavaScript animations behaves exactly like an app without the module (instant changes) — the framework detects that nothing would visibly animate and skips the ceremony.
+
+### 2.2. The five operations and who triggers them
+
+The animation service exposes five operations, and the built-in directives route through them as follows (full parity set):
+
+| Operation | Meaning | Triggered by |
+| --- | --- | --- |
+| **enter** | A new element is inserted into the page | `ng-if` becoming true, new `ng-repeat` items, `ng-switch` case activating, `ng-include` content loading, `ng-view` route content rendering |
+| **leave** | An element is removed from the page | `ng-if` becoming false, removed `ng-repeat` items, `ng-switch` case deactivating, `ng-include` content replacement, `ng-view` route change |
+| **move** | An existing element is repositioned | `ng-repeat` items reordered |
+| **addClass** | A CSS class is added to an element | `ng-class` (and variants) adding a class, `ng-show` hiding / `ng-hide` showing (the hiding class being added), form fields gaining a state class (e.g., becoming invalid or dirty) |
+| **removeClass** | A CSS class is removed from an element | The reverse of each addClass trigger |
+
+- **Acceptance Criteria:**
+ - [x] With the animations module loaded and matching CSS defined, toggling `ng-if` on/off visibly runs the enter and leave animations respectively.
+ - [x] Adding, removing, and reordering `ng-repeat` items runs enter, leave, and move animations respectively.
+ - [x] Toggling `ng-show` / `ng-hide` animates via the add/remove of the hiding class — an element can fade out before it disappears and fade in when it reappears (the element remains visible during the hide animation and is only actually hidden at the end).
+ - [x] Changing an `ng-class` expression so a class appears/disappears runs the addClass/removeClass animation for that class.
+ - [x] A form field transitioning between validation states (e.g., valid → invalid) can be animated via its state classes.
+ - [x] Switching routes animates the old view leaving and the new view entering.
+
+### 2.3. CSS animation conventions (parity class names)
+
+- For structural operations, the framework applies the classic two-step class pairs: a preparation class when the operation starts (`ng-enter`, `ng-leave`, `ng-move`), then an activation class one frame later (`ng-enter-active`, `ng-leave-active`, `ng-move-active`) so CSS transitions fire. Both classes are removed when the animation completes.
+- For class-change operations, the pair is derived from the class being changed: adding class `shrink` applies `shrink-add` then `shrink-add-active`; removing it applies `shrink-remove` / `shrink-remove-active`.
+- Both CSS **transitions** and CSS **keyframe animations** attached to these classes are honored; the framework waits for the longest declared duration/delay on the element before finalizing.
+- While any animation is running, the element carries the marker class `ng-animate` (so authors can scope rules to "while animating").
+- **Acceptance Criteria:**
+ - [x] Given CSS defining a 0.5s opacity transition on `.fade.ng-enter { opacity: 0 }` → `.fade.ng-enter-active { opacity: 1 }`, when an element with class `fade` enters, it fades in over 0.5s, and afterwards carries none of the animation classes.
+ - [x] The same behavior works when the CSS uses a keyframe animation instead of a transition.
+ - [x] An element whose CSS declares no transition/animation for the applied classes settles instantly (no lingering animation classes, no waiting).
+ - [x] During an in-flight animation the element carries `ng-animate`; after completion it does not.
+
+### 2.4. JavaScript animations (`$animateProvider.register` + `.animation` module DSL)
+
+- A developer can register a JavaScript animation keyed by a CSS class selector (e.g., `.slide`) either directly through the animation provider or via the module DSL method `.animation(name, factory)` — both register into the same place with no duplicated state.
+- The registered factory returns an object with optional callbacks per operation (enter, leave, move, addClass, removeClass). Each callback receives the element and a completion callback (`done`); the animation is considered finished when `done` is called.
+- JavaScript animations only run on elements that carry the registered class, and only when the animations module is loaded.
+- A JavaScript animation may be cancelled (e.g., a second operation interrupts it); the registered animation is told to end and the element still reaches the correct final state.
+- If a registered animation throws, the error is reported through the framework's standard error-reporting channel and the element still ends in its correct final state — a broken animation never leaves the page stuck.
+- **Acceptance Criteria:**
+ - [x] Given `.animation('.slide', ...)` registered with an `enter` callback, when an element with class `slide` enters, the callback runs and the element reaches its final state once the animation signals completion.
+ - [x] The same registration made via the provider (rather than the module DSL) behaves identically.
+ - [x] An element without the `slide` class never triggers that animation.
+ - [x] A JavaScript animation that throws is reported as an error, and the element still ends up correctly inserted/removed/classed.
+
+### 2.5. Completion promises
+
+- Every animation-service operation returns a promise that resolves when the animation completes — including the instant no-animation case, where it resolves immediately after the change applies.
+- If an animation is cancelled by a newer operation on the same element, the earlier operation's promise is rejected (the classic "animation was cancelled" outcome), while the newer operation proceeds normally.
+- **Acceptance Criteria:**
+ - [x] Code awaiting the result of an enter operation runs its follow-up only after the visible animation finishes.
+ - [x] Without the animations module, the same code still runs its follow-up (immediately) — no hangs.
+ - [x] Rapidly toggling `ng-show` twice results in the first animation's promise rejecting and the element honoring the latest toggle.
+
+### 2.6. Enable/disable controls
+
+- **Global toggle:** `$animate.enabled(false)` turns all animations off (instant behavior everywhere); `$animate.enabled(true)` re-enables. Callable at runtime; querying with no argument returns the current setting.
+- **Per-element toggle:** `$animate.enabled(element, false)` disables animations for that element (and its subtree) while the rest of the app keeps animating.
+- **Class-name filter:** at configuration time, the app can set a pattern (`classNameFilter`) so only elements whose classes match the pattern ever animate — everything else is instant.
+- **Startup grace:** animations do not run during the application's initial page render — the first appearance of the app's content is instant; animations begin once the app has settled. (Classic AngularJS parity; avoids a wall of enter-animations on page load.)
+- **Acceptance Criteria:**
+ - [x] After `$animate.enabled(false)`, toggling `ng-if` applies instantly even with matching CSS present; after re-enabling, it animates again.
+ - [x] With animations disabled on a specific container element, elements inside it change instantly while identical elements outside it animate.
+ - [x] With a class-name filter configured to match only `animate-me`, an element carrying that class animates and an otherwise-identical element without it changes instantly.
+ - [x] On initial page load, the app's first render appears instantly (no enter animations); a change made after the app settles animates normally.
+
+### 2.7. Staggering
+
+- When many sibling elements start the same animation in the same moment (the classic `ng-repeat` batch case), authors can declare a stagger delay via the companion stagger classes (e.g., the `ng-enter-stagger` convention with a `transition-delay`), and the framework offsets each successive element's start by that delay so items cascade.
+- **Acceptance Criteria:**
+ - [x] Given a stagger delay of 0.1s and five items added at once, each item visibly starts its enter animation ~0.1s after the previous one, and all five end in their correct final positions.
+ - [x] Without a stagger rule, all five animate simultaneously.
+
+### 2.8. Parent/child coordination (`ng-animate-children`)
+
+- By default, while a structural animation is running on an element, animations on elements **inside** it do not additionally run (avoiding chaotic nested effects).
+- An author can opt a container's children back in by marking it with `ng-animate-children` (value `true`/omitted enables; an expression evaluating to false disables), letting nested animations run alongside the parent's.
+- **Acceptance Criteria:**
+ - [x] A view entering via route change does not simultaneously run enter animations for every animated element inside it, by default.
+ - [x] The same view marked with `ng-animate-children` runs the inner animations together with its own.
+
+### 2.9. Animation event listeners (`$animate.on` / `$animate.off`)
+
+- App code can subscribe to animation notifications: `$animate.on('enter', containerElement, callback)` invokes the callback whenever an enter animation involving that container (or elements within it) starts and when it closes; the callback is told which phase (`start` / `close`) it is observing.
+- `$animate.off(...)` removes listeners — by event name, by event name + container, or by exact event/container/callback triple.
+- **Acceptance Criteria:**
+ - [x] A listener registered for `enter` on a list container fires (start, then close) when a new item animates in, and does not fire for animations elsewhere in the page.
+ - [x] After `$animate.off` with the same arguments, the callback no longer fires.
+
+### 2.10. Interruption & consistency guarantees
+
+- A new operation on an element that is mid-animation cancels the in-flight animation and takes over; the element never ends in a half-animated state — the final DOM state always reflects the **latest** requested operation.
+- Removing an element (or destroying the part of the page it belongs to) while it is animating cleans the animation up — no orphaned timers, classes, or listeners remain.
+- **Acceptance Criteria:**
+ - [x] Toggling `ng-if` rapidly (true → false → true) ends with the element present and displaying its fully-settled appearance.
+ - [x] Destroying a view mid-animation leaves no `ng-animate` (or other animation) classes anywhere and no delayed side effects firing later.
+
+---
+
+## 3. Scope and Boundaries
+
+### In-Scope
+
+- The animation service with the five operations (enter, leave, move, addClass, removeClass) and its always-available instant fallback.
+- The opt-in animations module (the classic ngAnimate role) that upgrades those operations to animated behavior.
+- CSS transition and keyframe animations via the parity class-name conventions (§2.3), including the `ng-animate` marker class.
+- JavaScript animations via `$animateProvider.register` and the `.animation(name, fn)` module DSL.
+- Wiring the full built-in directive parity set (§2.2), including form validation state classes.
+- Completion promises, cancellation semantics, and interruption consistency.
+- Enable/disable controls: global, per-element, `classNameFilter`, and the instant-first-render startup grace.
+- Staggering and `ng-animate-children`.
+- Animation event listeners (`$animate.on` / `$animate.off`).
+
+### Out-of-Scope
+
+- **Anchored / shared-element transitions (`ng-animate-ref`)** — deferred.
+- **The standalone `$animateCss` programmatic helper** — deferred; JavaScript animations manipulate styles directly.
+- **Animations for `ngMessages`** — that module is not part of this project.
+- Other roadmap items, which are separate specifications: **Package & Distribution** (npm packaging, API docs, examples folder) and the entire **Phase 5 AngularJS Compatibility Layer** (`angular` namespace, `angular.element`/jqLite, migration guide).
diff --git a/context/spec/041-animations/tasks.md b/context/spec/041-animations/tasks.md
new file mode 100644
index 0000000..6048d57
--- /dev/null
+++ b/context/spec/041-animations/tasks.md
@@ -0,0 +1,84 @@
+# Tasks: Animations ($animate, ngAnimate, CSS & JS drivers) — spec 041
+
+- **Functional Specification:** [functional-spec.md](./functional-spec.md)
+- **Technical Considerations:** [technical-considerations.md](./technical-considerations.md)
+
+_Each slice leaves the repo green (`pnpm test` / `pnpm typecheck` / `pnpm lint` pass) and adds a runnable, verifiable increment._
+
+---
+
+## Slice 1: `$animate` resolvable on core `ng` — instant engine, provider surface, `.animation` DSL
+
+- [x] Create the `src/animate/` subpath skeleton and packaging: `@animate/*` tsconfig alias, `./animate` entry in `package.json` `exports`, `animate/index` Rollup entry (copy the `./route` template). **[Agent: rollup-build]**
+- [x] Implement `animate-types.ts` (`AnimateService`, `AnimationDefinition`, `AnimateOptions`, event/phase types) and `core-animate-queue.ts` — the synchronous instant engine: enter/leave/move DOM ops, class application, immediately-resolved `$q` promises. **[Agent: typescript-framework]**
+- [x] Implement `animate.ts` (`createAnimate` façade delegating to the injected `$$animateQueue`; `Element | Node[]` group normalization) and `animate-provider.ts` (`$AnimateProvider` — `register('.class', factory)` storing as `-animation` factory provider, `classNameFilter(regexp?)` getter/setter frozen at `$get`). **[Agent: typescript-framework]**
+- [x] Register `$animate` + instant `$$animateQueue` on `ngModule` (`src/core/ng-module.ts`), importing only the light files. **[Agent: typescript-framework]**
+- [x] Append `'$animate'` as the 14th `EXCEPTION_HANDLER_CAUSES` token; update the `q-surface.test.ts` length guard and grep the suite for hardcoded `13`s. **[Agent: typescript-framework]**
+- [x] Add the `.animation(name, factory)` module DSL to `src/di/module.ts` — one config block forwarding to `$animateProvider.register`, `import type`-only `@animate` reference (document the widened `@di` type-only exception). **[Agent: typescript-framework]**
+- [x] Unit tests: instant-engine ops + immediate promise resolution, façade delegation, `register` → `injector.get('.fade-animation')` lookup, `.animation` DSL ↔ provider parity + decorator wrap, `classNameFilter` validation, cause-token guard. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test`, `pnpm typecheck`, `pnpm lint` all green; `injector.get('$animate')` resolves in a bare `[ngModule]` injector. **[Agent: vitest-testing]**
+
+## Slice 2: Structural directives route through `$animate` (zero behavior change)
+
+- [x] Rewire `ng-if` (`ng-if.ts:248, 293-303`): `$animate.enter(nodes, parent, placeholder)`; synchronous `cloneScope.$destroy()` then `$animate.leave(nodes)` owns removal. **[Agent: typescript-framework]**
+- [x] Rewire `ng-repeat` (`ng-repeat.ts:434-437, 471-475, 493-498`): `enter` on fresh build, `move` on reorder, `leave` after `$destroy()`; leaving rows evicted from `currentRows` synchronously. **[Agent: typescript-framework]**
+- [x] Rewire `ng-switch` (`clearSelected` + case-group install) and `ng-include` / `ng-view` (`clearCurrentClone` + container insert) the same way; widen each directive's DI array with `'$animate'`. **[Agent: typescript-framework]**
+- [x] Tests: full existing structural-directive suites pass **unedited** (the regression gate); new spy tests (decorate `$animate`) assert enter/leave/move calls with correct node groups and anchors. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 3: Class togglers route through `$animate` (zero behavior change)
+
+- [x] Rewire `ng-show` / `ng-hide` to `$animate.addClass/removeClass(el, 'ng-hide')`; `ng-class` family `applyDiff` to one `$animate.setClass(el, added, removed)` per fire. **[Agent: typescript-framework]**
+- [x] Rewire `src/forms/state-classes.ts` — `applyClasses` takes the `$animate` reference (threaded through the forms directives' injection); pairs go through `setClass`. **[Agent: typescript-framework]**
+- [x] Tests: existing ng-show/ng-hide/ng-class/forms suites pass **unedited**; spy tests assert the routed calls. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 4: `ngAnimate` module — runner, queue, JS animations end-to-end
+
+- [x] Implement `animate-runner.ts`: `$q`-backed completion handle with `cancel`/`end`/`done`, **pre-handled** returned promise (cancelled + unobserved → no `'$q'` unhandled report). **[Agent: typescript-framework]**
+- [x] Implement `animate-queue.ts` (first cut): digest-end start (`$$postDigest` + one `raf` tick), per-element `WeakMap` animation record, basic cancel-on-new-op, skip detection (no JS match → finalize instantly), leave defers DOM removal to animation end, startup grace (suppressed until first digest settles + one `raf`). **[Agent: typescript-framework]**
+- [x] Implement `js-driver.ts`: class → registered `-animation` matching (resolved once at `$get`), per-operation callbacks `(element, [className,] done)`, all-`done` aggregation, throw → `$exceptionHandler('$animate')` + finalize. **[Agent: typescript-framework]**
+- [x] Implement `ng-animate-module.ts`: `createModule('ngAnimate', [])` re-registering `$$animateQueue` (DI last-wins), `ModuleRegistry` augmentation, barrel export. **[Agent: typescript-framework]**
+- [x] Tests: app with `[ngModule, ngAnimate]` + `.animation('.fade', …)` observes enter/leave callbacks on `ng-if`/`ng-repeat`; leave removal deferred until `done`; unmatched elements instant; scope destroy stays synchronous; startup grace (no animation on first render); JS throw routes `'$animate'`; last-wins override verified. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 5: CSS driver — transition & keyframe choreography
+
+- [x] Implement `css-driver.ts` with injectable seams (`raf`, `now`, `computeStyle`, `setTimer`/`clearTimer`): `ng-animate` marker + prep class → reflow → `raf` → active class → duration/delay parse (`max(transition, animation)`) → `transitionend`/`animationend` + ~1.5× fallback timer → class cleanup + finalize. Structural pairs (`ng-enter`/`-active` etc.) and class-change pairs (`-add`/`-add-active` etc.). **[Agent: typescript-framework]**
+- [x] Integrate into `animate-queue.ts` skip detection: CSS duration > 0 counts as a match; CSS + JS animations for one operation run together and close jointly. Bind the seams to globals in `ng-animate-module.ts` (the `$timeout` seam precedent). **[Agent: typescript-framework]**
+- [x] Tests (stubbed seams): exact class sequence per operation, transition vs keyframe paths, zero-duration instant finalize, fallback-timer path with fake timers, synthetic `transitionend`/`animationend` dispatch, `ng-animate` present only in flight. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 6: Interruption, coalescing & completion promises (FS §2.5, §2.10)
+
+- [x] Full cancel/join matrix in `animate-queue.ts`: new op cancels in-flight (old runner rejects, element driven to end state), structural-beats-class within one digest, same-digest `addClass`+`removeClass` of one class cancels out, `setClass` as one animation. **[Agent: typescript-framework]**
+- [x] Wire `addElementCleanup` finalization: destroying a placeholder/view mid-animation ends the runner instantly — no orphaned timers, classes, or listeners. **[Agent: typescript-framework]**
+- [x] Tests: rapid `ng-if` true→false→true and double `ng-show` toggle end fully settled; first promise rejects (and is pre-handled — assert zero `$exceptionHandler('$q')` calls), latest wins; destroy-mid-animation leaves no `ng-animate` classes and no late side effects; completion promise resolves after visible animation, immediately under core engine. Coalescing matrix (direct queue + stub drivers) + destroy/rapid-toggle (DI harness) in `src/animate/__tests__/animate-interruption.test.ts`. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 7: Enable/disable controls (FS §2.6)
+
+- [x] Implement `enabled()` overloads (0–2 args: global get/set, per-element subtree set) and the `classNameFilter` gate in the queue's skip detection. **[Agent: typescript-framework]**
+- [x] Tests: global off → instant even with matching CSS/JS, re-enable restores; per-container off leaves siblings animating; `classNameFilter` matches animate, non-matches instant. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 8: Staggering + `ng-animate-children` (FS §2.7, §2.8)
+
+- [x] Implement stagger in `css-driver.ts`: read `-stagger` transition-delay once per batch, offset successive siblings' starts. **[Agent: typescript-framework]**
+- [x] Implement the parent/child rule in `animate-queue.ts` (`parentElement` walk against the `WeakMap`: child animations skipped under a running structural parent) + the `ngAnimateChildren` directive (`ng-animate-children.ts`, registered on `ngAnimate`). **[Agent: typescript-framework]**
+- [x] Tests: five items + 0.1s stagger cascade with correct offsets (stubbed seams), no stagger → simultaneous; nested animations skipped by default and re-enabled under `ng-animate-children`. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 9: Animation event listeners (FS §2.9)
+
+- [x] Implement `on(event, container, cb)` / `off(...)` (three removal granularities) in the queue: start/close notifications, container match via `parentElement` walk, callbacks `(element, phase)` dispatched inside the digest with the `$$phase` guard; callback throws route `'$animate'`. Core engine keeps the documented no-op behavior. **[Agent: typescript-framework]**
+- [x] Tests: listener fires start then close for an item entering its container, silent for animations elsewhere; each `off` overload stops the right callbacks. **[Agent: vitest-testing]**
+- [x] Verify slice: `pnpm test` + `typecheck` + `lint` green. **[Agent: vitest-testing]**
+
+## Slice 10: Parity, docs & wrap-up
+
+- [x] Un-skip the deferred `$animate` tests in `spec023-parity.test.ts` / `spec024-parity.test.ts`; port applicable upstream `test/ngAnimate/*Spec.js` scenarios (queue rules, class choreography where jsdom-representable). **[Agent: vitest-testing]**
+- [x] Add `animate` to the 90% coverage threshold in `vitest.config.ts`; fill any gaps. **[Agent: vitest-testing]**
+- [x] Write `src/animate/README.md` (worked CSS + JS examples, digest/promise/cancellation contracts, documented divergences: synchronous instant engine, pre-handled rejections) and `context/diagrams/animate.md` + index link + `diagrams-structure.test.ts` entry. **[Agent: typedoc-docs]**
+- [x] Update `CLAUDE.md`: `./animate` module-table row, new invariants (engine seam, sync instant queue, 14-token tuple, pre-handled runner promises), "Where to look when…" entries. **[Agent: typedoc-docs]**
+- [x] Verify slice: `pnpm build` succeeds with the new subpath (ESM+CJS+`.d.ts`); full `pnpm test` + `typecheck` + `lint` + `format:check` green. **[Agent: rollup-build]**
diff --git a/context/spec/041-animations/technical-considerations.md b/context/spec/041-animations/technical-considerations.md
new file mode 100644
index 0000000..32edebf
--- /dev/null
+++ b/context/spec/041-animations/technical-considerations.md
@@ -0,0 +1,136 @@
+# Technical Specification: Animations ($animate, ngAnimate module, CSS & JS drivers)
+
+- **Functional Specification:** [functional-spec.md](./functional-spec.md)
+- **Status:** Completed
+- **Author(s):** Mgrdich
+
+---
+
+## 1. High-Level Technical Approach
+
+Mirror upstream AngularJS's two-layer animation architecture, adapted to this project's conventions:
+
+1. **Core layer (always on `ng`):** a new `src/animate/` subpath ships the `$animate` façade service and `$AnimateProvider` (owning `register(name, factory)` and `classNameFilter(pattern?)`), registered on core `ngModule`. The façade delegates every operation to an internal engine service, `$$animateQueue`. Core registers the **instant engine** — synchronous DOM ops and class changes, byte-identical to today's directive behavior.
+2. **Opt-in layer (`ngAnimate` module):** the same subpath exports `ngAnimate = createModule('ngAnimate', [])` which re-registers `$$animateQueue` (plus internal collaborators) with the full animation engine — queue rules, CSS transition/keyframe driver, JS animation driver, staggering, parent/child coordination, event listeners. **DI last-wins across the requires chain** (confirmed at `src/di/registration.ts:90-103`) makes the upgrade automatic when an app lists `ngAnimate`; no decorator, no lazy probe needed — this is the upstream `$$animateQueue` override seam reproduced exactly.
+3. **Directive rewiring:** the five structural directives (`ng-if`, `ng-repeat`, `ng-switch`, `ng-include`, `ng-view`) route DOM insertion/removal/move through `$animate.enter/leave/move`; the class togglers (`ng-show`, `ng-hide`, `ng-class` family, forms state classes) route through `$animate.addClass/removeClass/setClass`. Under the instant engine these calls behave identically to the current direct DOM code, so the existing test suite passes unchanged.
+4. **Pure ESM-first factories with injectable seams** (`raf`, `now`, `computeStyle`, timer fns) following the `$q`/`$timeout`/`$httpBackend` precedent — the CSS driver is fully unit-testable in jsdom, where real transitions never fire.
+
+Affected systems: new `src/animate/` module; edits to `src/core/ng-module.ts` (registration), the seven directive files + `src/forms/state-classes.ts` (rewiring), `src/di/module.ts` (`.animation` DSL), `src/exception-handler/exception-handler-types.ts` (14th cause token), packaging (`package.json` exports, `rollup.config.mjs`, `tsconfig.json` aliases).
+
+### Approved design decisions
+
+- **Instant engine stays synchronous** — a documented divergence from upstream core's post-digest coalescing of class changes, preserving this project's shipped synchronous contracts (ng-show/ng-class/forms suites pass unedited). With `ngAnimate` loaded, animations DO start at digest end (parity there).
+- **New `'$animate'` cause token** — `EXCEPTION_HANDLER_CAUSES` grows 13 → 14 (append-only, the spec-037 precedent).
+- **Pre-handled runner promises** — a cancelled animation nobody listens to never reaches the `'$q'` unhandled-rejection channel (upstream parity, where `AnimateRunner` is not a `$q` promise). App code that attaches `.catch` still sees the rejection.
+- **Single `src/animate/` subpath** houses both the core pieces (imported by `src/core/ng-module.ts`) and the opt-in `ngAnimate` module; core imports only the light files.
+
+---
+
+## 2. Proposed Solution & Implementation Plan
+
+### 2.1. Module layout & packaging
+
+New subpath following the `ngRoute` template (`src/route/`):
+
+| File | Responsibility |
+| --- | --- |
+| `src/animate/index.ts` | Barrel: `ngAnimate`, `createAnimate`, `$AnimateProvider`, contract types, error classes |
+| `src/animate/animate.ts` | `createAnimate(...)` — the `$animate` façade (pure factory); delegates to the injected engine |
+| `src/animate/animate-provider.ts` | `$AnimateProvider` — config-phase `register('.class', factory)` (stores as `-animation` factory provider, the upstream naming that makes decorators work for free) + `classNameFilter(regexp?)` getter/setter frozen at `$get` |
+| `src/animate/core-animate-queue.ts` | Instant engine (core default `$$animateQueue`): synchronous insert/remove/move + `classList` application; resolves the returned promise immediately |
+| `src/animate/animate-queue.ts` | ngAnimate engine: per-element animation bookkeeping, cancel/join rules, `enabled()` state (global + per-element), startup grace, parent/child (`ng-animate-children`) coordination, `on`/`off` listener registry, `classNameFilter` gate, digest-end batching |
+| `src/animate/animate-runner.ts` | Completion handle: wraps a `$q` deferred; exposes cancel/end/`done`; the returned `QPromise` is **pre-handled** (a cancelled animation nobody listens to never reaches the `'$q'` unhandled channel) |
+| `src/animate/css-driver.ts` | CSS transition/keyframe driver: class choreography (prep → reflow → active), duration/delay detection via injected `computeStyle`, `transitionend`/`animationend` listeners + fallback timer, stagger computation |
+| `src/animate/js-driver.ts` | JS animation driver: matches element classes against registered animations, invokes per-operation callbacks with `done`, cancellation dispatch, throw → `$exceptionHandler('$animate')` |
+| `src/animate/ng-animate-children.ts` | The `ngAnimateChildren` directive (registered on `ngAnimate`) |
+| `src/animate/ng-animate-module.ts` | `createModule('ngAnimate', [])` + provider re-registrations + `ModuleRegistry` augmentation (the `ngRoute` precedent) |
+| `src/animate/animate-types.ts` | `AnimateService`, `AnimationDefinition` (per-operation callbacks), `AnimateOptions`, event/phase types |
+
+Packaging: add `./animate` to `package.json` `exports`, an `animate/index` Rollup entry, and `@animate/*` path alias — copying the `./route` entries verbatim.
+
+**Core wiring:** `src/core/ng-module.ts` registers `.provider('$animate', $AnimateProvider)` and the instant `$$animateQueue` factory, importing only the light files (`animate.ts`, `animate-provider.ts`, `core-animate-queue.ts`, types) — never `ng-animate-module.ts` or the drivers.
+
+### 2.2. `$animate` public surface (contract)
+
+```
+enter(nodes, parent, after?, options?) → QPromise
+leave(nodes, options?) → QPromise
+move(nodes, parent, after?, options?) → QPromise
+addClass(element, className, options?) → QPromise
+removeClass(element, className, options?) → QPromise
+setClass(element, add, remove, options?) → QPromise // single coalesced op for ng-class flips
+enabled(enabledOrElement?, enabled?) → boolean // 0–2 arg overloads per FS §2.6
+on(event, container, callback) → void // callback(element, phase: 'start' | 'close')
+off(event, container?, callback?) → void
+```
+
+- `nodes` is `Element | Node[]` — the structural directives manage multi-node clone groups (spec 033 ranges), so group forms are first-class; the whole group animates as one unit keyed off its first element.
+- All methods return a `QPromise` that resolves on completion (instantly under the core engine) and **rejects on cancellation**, pre-handled per §2.1.
+- `setClass` exists because `ng-class` flips (`remove A, add B`) must be one animation, not two competing ones.
+
+### 2.3. Engine seam contract (`$$animateQueue`)
+
+Internal service (registered but `$$`-prefixed, not in the root barrel — the `$$sanitizeUri` precedent). Shape: `{ push(element|nodes, event, options) → AnimateRunner-backed promise, enabled(...), on(...), off(...) }`. The façade owns nothing but argument normalization and delegation, so swapping the engine swaps all behavior. Core engine ignores `on`/`off` registrations (no animations ever run → no events; documented) and honors `enabled()` state trivially (nothing to disable).
+
+### 2.4. ngAnimate engine behavior
+
+- **Digest-end start:** structural and class animations are queued during the digest and kick off in a `$$postDigest` + one `raf` tick, coalescing same-element operations (an `addClass` + `removeClass` of the same class in one digest cancels out — FS §2.1's "skips the ceremony"). DOM **insertion** (enter/move) still happens synchronously at call time so layout is correct; only the *animation ceremony* is deferred. DOM **removal** (leave) is deferred to animation end.
+- **Skip detection:** before starting, the engine checks (a) global/per-element `enabled` state, (b) `classNameFilter` match, (c) whether any JS animation matches or any CSS duration > 0 (via `computeStyle` after applying prep classes). No match → immediately finalize (apply end state, resolve) — the "behaves exactly like no module" criterion.
+- **Cancel/join rules (FS §2.10):** one active animation record per element, held in a `WeakMap`. A new operation on an animating element cancels the runner (reject → pre-handled), drives the old animation to its end state, then proceeds. Structural beats class-based when both queue in one digest.
+- **Parent/child rule (FS §2.8):** an element whose ancestor has a running *structural* animation is skipped unless an ancestor `ng-animate-children` (parsed as an interpolation-aware attribute, `true`/empty enables) re-enables. Walk is via `parentElement` against the `WeakMap`, mirroring the `$$ngControllers` walk precedent.
+- **Startup grace (FS §2.6):** engine starts with animations globally suppressed; enables after the first digest settles plus one `raf` tick (upstream parity). Implemented inside the ngAnimate engine — the core engine needs no grace since it never animates.
+- **CSS choreography (FS §2.3):** add `ng-animate` + prep class (`ng-enter` / `-add` / …) → force reflow → next `raf` adds active class → wait `max(transition, animation)` duration+delay parsed from `computeStyle` → fallback timeout at ~1.5× duration guards missing `transitionend` → remove all animation classes, finalize. Stagger: read `-stagger` class durations once per batch; offset each subsequent sibling's start by the stagger delay (FS §2.7).
+- **JS driver (FS §2.4):** for each class on the element with a registered `-animation` factory (resolved once at `$get` via `$injector`), invoke the matching operation callback `(element, [className,] done)`. JS and CSS animations for the same operation run together; the animation closes when all `done` callbacks fire. Throws route via `invokeExceptionHandler(handler, err, '$animate')` and finalize the element.
+- **Events (FS §2.9):** `on(event, container, cb)` registry; at animation start/close the engine walks from the animating element up through the `parentElement` chain matching registered containers, invoking callbacks with `(element, phase)` inside the digest (`$applyAsync`-style guard).
+
+### 2.5. Directive & forms rewiring
+
+| Site | Today | Becomes |
+| --- | --- | --- |
+| `ng-if` (`ng-if.ts:248, 293-303`) | `insertBefore` / `$destroy` + `removeChild` loop | `$animate.enter(nodes, parent, placeholder)`; `cloneScope.$destroy()` stays synchronous, then `$animate.leave(nodes)` owns removal |
+| `ng-repeat` (`ng-repeat.ts:434-437, 471-475, 493-498`) | insert / move / remove | `enter` on fresh build, `move` on reorder, `leave` after `$destroy()` on teardown; leaving nodes are dropped from `currentRows` immediately so reconciliation anchors ignore them |
+| `ng-switch` (`ng-switch.ts:269-283, 357-360`) | insert / clear loop | `enter` per case group; `leave` in `clearSelected` |
+| `ng-include` / `ng-view` (`ng-include.ts:461, 256-260`; `ng-view.ts:345, 232-239`) | insert container / `remove()` | `enter(container, …)`; `leave(container)` in `clearCurrentClone` |
+| `ng-show` / `ng-hide` (`ng-show.ts:91`, `ng-hide.ts:93`) | `classList.toggle('ng-hide', …)` | `$animate.addClass(el,'ng-hide')` / `removeClass` |
+| `ng-class` family (`ng-class.ts:166-178`) | `classList.add/remove` in `applyDiff` | one `$animate.setClass(el, added, removed)` per diff |
+| forms (`state-classes.ts:50-56`) | `applyClasses` direct toggles | `applyClasses` takes the `$animate` reference; pairs go through `setClass` |
+
+Each rewired directive widens its DI array with `'$animate'` (the spec-026 `['$exceptionHandler', factory]` pattern). Forms threading: `registerForms` already receives `$compileProvider` via a config block; the `$animate` reference reaches `state-classes.ts` through the directives' own injection at link time.
+
+**Leave-in-flight tolerance:** with ngAnimate, a leaving clone lingers in the DOM. Structural directives must (a) forget the clone in their own bookkeeping synchronously, (b) tolerate re-entry (rapid `ng-if` re-toggle) by cancelling the in-flight leave via the engine's per-element record (the engine drives it to removal or the directive's new enter cancels it). Cleanup callbacks registered via `addElementCleanup(placeholder, …)` additionally call the runner's end so a destroyed view finalizes instantly (FS §2.10, "no orphaned timers").
+
+### 2.6. `.animation(name, factory)` module DSL
+
+`src/di/module.ts` grows `.animation` as pure config-block sugar forwarding to `$animateProvider.register` — the `.filter`/`.directive` precedent exactly: one config block pushed onto `$$configBlocks`, `import type`-only reference to `@animate` (the documented `@di → @compiler` type-only exception widens by one entry — spec-level note required), no local registry, validation inherited from the provider (name must start with `.`; invalid → synchronous throw, matching `.directive` name validation). Registered name is `-animation`, so `module.decorator('.fade-animation', …)` works for free. Non-widening on `TypedModule` (the `.controller` precedent — document as non-widening).
+
+### 2.7. Error routing
+
+- Append `'$animate'` as the **14th** `EXCEPTION_HANDLER_CAUSES` token (`src/exception-handler/exception-handler-types.ts`) — public additive change, the spec-037 precedent. Update the `q-surface.test.ts` length guard (`=== 13` → `=== 14`) and grep for other hardcoded `13`s.
+- Routed via `'$animate'`: JS animation callback throws, JS animation factory resolution failures at run time, listener (`on`) callback throws.
+- Synchronous programmer errors (bad `register` name, bad `classNameFilter` arg) throw to the caller — the provider-validation precedent.
+- Cancelled-animation rejections: never reported (pre-handled runner promises).
+
+### 2.8. Injectable seams (testability contract)
+
+`createAnimateQueue` / `createCssDriver` accept: `raf(cb)` (+ cancel), `now()`, `computeStyle(el)`, `setTimer`/`clearTimer`, `postDigest(fn)` (→ `$rootScope.$$postDigest`), `apply(fn)`/`rootPhase()` (the `$timeout` pair), `exceptionHandler`, `q`. `ngAnimate`'s registrations bind them to the globals (`requestAnimationFrame`, `performance.now`, `getComputedStyle`, `setTimeout`) — the `src/core/ng-module.ts:222-238` precedent. `TimerId`-style environment-correct types reused from `@async`.
+
+---
+
+## 3. Impact and Risk Analysis
+
+- **System Dependencies:** consumes `$q` (runner promises), `$rootScope` (`$$postDigest`, `$applyAsync`-style guards), `$exceptionHandler`, `$injector` (resolving registered animation factories), the compiler cleanup contract (`addElementCleanup`), and the DI last-wins override rule. Touches seven directive files + forms state classes; all other directives are unaffected.
+- **Risk: regressions in the no-ngAnimate path.** The whole existing suite exercises synchronous behavior. Mitigation: the instant engine is deliberately synchronous (approved divergence from upstream's post-digest coalescing); acceptance = the full existing suite passes with zero edits after the directive rewiring lands.
+- **Risk: leave-in-flight corrupting `ng-repeat` reconciliation** (anchors, duplicate identities when an item is re-added while its old row is animating out). Mitigation: leaving rows are evicted from `currentRows` synchronously; re-added identities get fresh clones; the engine cancels a leave when the same element receives a new structural op; dedicated rapid-toggle tests.
+- **Risk: unhandled-rejection noise / TTL pressure.** Pre-handled runner promises (approved); animation completion resolves via `$q` → one digest per settle, coalesced by the runner batching completions per `raf` tick.
+- **Risk: jsdom cannot run real transitions.** Mitigation: seam injection (§2.8) — unit tests stub `computeStyle` durations and dispatch synthetic `transitionend`/`animationend` `Event`s; the fallback-timer path is covered with vitest fake timers. No test depends on real rendering.
+- **Risk: `EXCEPTION_HANDLER_CAUSES` growth breaks consumers.** Append-only; `ExceptionHandlerCause` widens automatically (the spec-037 precedent).
+- **Public-API/doc obligations:** new subpath in `package.json`/Rollup/tsconfig; CLAUDE.md module table + invariants; `context/diagrams/` gets an `animate` diagram (structural test `diagrams-structure.test.ts` guard); `src/animate/README.md` worked examples (the `@async`/`@http` precedent).
+
+---
+
+## 4. Testing Strategy
+
+- **Unit (pure factories, no injector):** instant engine (sync semantics, immediate resolve); queue rules (cancel/join, enabled overloads, classNameFilter, startup grace, parent/child walk); CSS driver choreography with stubbed `computeStyle`/`raf`/timers (class sequence, duration detection, stagger offsets, fallback timer); JS driver (matching, `done` aggregation, throw routing, cancellation); runner (resolve/reject/pre-handled).
+- **Integration (injector + jsdom):** each rewired directive with core engine only (existing suites must pass unedited — the regression gate); each with `ngAnimate` + a registered JS animation (enter/leave/move/class events observed via callbacks — no real CSS needed); completion promises; `$animate.on/off`; `.animation` DSL ↔ provider parity + decorator wrap; module packaging (injector resolves `$animate` with and without `ngAnimate`; last-wins override verified).
+- **Parity tests:** port applicable scenarios from upstream `test/ngAnimate/*Spec.js` (queue, animateCss class choreography where representable) and un-skip the deferred spec-023/024 `$animate` tests flagged in `spec023-parity.test.ts` / `spec024-parity.test.ts`.
+- **Coverage:** `animate` joins the 90% per-module threshold in `vitest.config.ts`.
diff --git a/package.json b/package.json
index 14cb32c..07064e1 100644
--- a/package.json
+++ b/package.json
@@ -99,6 +99,11 @@
"import": "./dist/esm/route/index.mjs",
"require": "./dist/cjs/route/index.cjs",
"types": "./dist/types/route/index.d.ts"
+ },
+ "./animate": {
+ "import": "./dist/esm/animate/index.mjs",
+ "require": "./dist/cjs/animate/index.cjs",
+ "types": "./dist/types/animate/index.d.ts"
}
},
"repository": "https://github.com/Mgrdich/my_own_angularjs.git",
diff --git a/rollup.config.mjs b/rollup.config.mjs
index 770d794..cca77c8 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -43,6 +43,7 @@ const entries = [
{ name: 'forms/index', input: 'src/forms/index.ts' },
{ name: 'location/index', input: 'src/location/index.ts' },
{ name: 'route/index', input: 'src/route/index.ts' },
+ { name: 'animate/index', input: 'src/animate/index.ts' },
];
// Path aliases declared in `tsconfig.json` are used across the codebase
@@ -70,6 +71,7 @@ const tsPathAliases = {
'@forms/*': ['src/forms/*'],
'@location/*': ['src/location/*'],
'@route/*': ['src/route/*'],
+ '@animate/*': ['src/animate/*'],
};
const bundleConfigs = entries.map((entry) => ({
diff --git a/src/__tests__/diagrams-structure.test.ts b/src/__tests__/diagrams-structure.test.ts
index eee19cc..0ed560a 100644
--- a/src/__tests__/diagrams-structure.test.ts
+++ b/src/__tests__/diagrams-structure.test.ts
@@ -78,6 +78,7 @@ const EXPECTED_DIAGRAMS = [
'controller.md',
'built-in-directives.md',
'routing.md',
+ 'animate.md',
] as const;
/** The fixed five-section layout every service diagram must carry, in order. */
diff --git a/src/animate/README.md b/src/animate/README.md
new file mode 100644
index 0000000..dd2b10c
--- /dev/null
+++ b/src/animate/README.md
@@ -0,0 +1,293 @@
+# `@animate` — animations: `$animate` + the opt-in `ngAnimate` module
+
+`$animate` is the framework's animation façade: every built-in structural
+directive routes its DOM insert / remove / move through
+`$animate.enter` / `leave` / `move`, and every class toggler routes through
+`$animate.addClass` / `removeClass` / `setClass`. Each method returns a `$q`
+promise that resolves when the change completes.
+
+The service is **always available on core `ng`** — but by default it is wired
+to the synchronous **instant engine**: every operation applies its end state
+immediately, byte-identical to the direct DOM code the directives used before
+routing through `$animate`, and no animation classes are ever added. Adding
+the **opt-in `ngAnimate` module** to the app's dependency list re-registers the
+internal engine (`$$animateQueue`) with the full animation engine, and the same
+operations become animated — CSS transitions / keyframes and registered
+JavaScript animations run before the element settles into its end state.
+
+```ts
+const injector = createInjector(['ng']); // instant engine — no ngAnimate
+const $animate = injector.get('$animate');
+
+void $animate.enter(clone, parentEl, placeholder).then(() => {
+ // runs after the enter completes (immediately under the instant engine)
+});
+```
+
+## The two layers
+
+- **`$animate`** — the façade (`createAnimate`), always on `ng`. Owns nothing
+ but argument normalization (`Element | Node[]` group → `readonly Node[]`) and
+ delegation to the engine seam.
+- **`$$animateQueue`** — the internal engine seam behind the façade (a
+ `$$`-prefixed internal service, the `$$sanitizeUri` precedent). Core `ng`
+ registers `createCoreAnimateQueue` (instant); `ngAnimate` re-registers
+ `createAnimateQueue` (full engine).
+- **`$AnimateProvider`** — the config-phase configurator: `register('.class',
+ factory)` and `classNameFilter(pattern?)`. Reachable via
+ `injector.get('$animateProvider')` inside `config()`; deliberately NOT in the
+ root barrel (the `$RouteProvider` / `$SanitizeProvider` precedent).
+
+`ngAnimate` is opt-in — compose it alongside the core:
+
+```ts
+import { createInjector, createModule } from 'my-own-angularjs';
+import { ngModule } from 'my-own-angularjs/core';
+import { ngAnimate } from 'my-own-angularjs/animate';
+
+const app = createModule('app', ['ng', 'ngAnimate']);
+const injector = createInjector([ngModule, ngAnimate, app]);
+// injector.get('$animate') is now the animated engine — DI last-wins upgraded it.
+```
+
+## The five operations (plus `setClass`)
+
+| Operation | Meaning | Triggered by |
+| --- | --- | --- |
+| `enter(nodes, parent, after?, options?)` | insert into the page | `ng-if` true, new `ng-repeat` items, `ng-switch` case activating, `ng-include` / `ng-view` content |
+| `leave(nodes, options?)` | remove from the page | `ng-if` false, removed `ng-repeat` items, case deactivating, content replacement |
+| `move(nodes, parent, after?, options?)` | reposition within a list | `ng-repeat` reorder |
+| `addClass(element, className, options?)` | add a class | `ng-class`, `ng-hide` showing, form state classes |
+| `removeClass(element, className, options?)` | remove a class | the reverse of each `addClass` |
+| `setClass(element, add, remove, options?)` | add AND remove in one coalesced op | `ng-class` flips (`remove A, add B` is ONE animation, not two competing ones) |
+
+`nodes` is `Element | Node[]` — the structural directives manage multi-node
+clone groups (spec 033 ranges), so the group form is first-class; the whole
+group animates as one unit keyed off its first element. `after` is the anchor
+node (`nodes` insert as its next siblings); `null` / absent appends as the last
+children of `parent`.
+
+## The digest-integration + completion-promise contract
+
+Every operation returns a `$q` promise, so digest integration is FREE — a
+resolution schedules a digest through `$q`'s `$rootScope.$evalAsync` seam.
+
+- **Instant engine:** the DOM change applies SYNCHRONOUSLY at call time; the
+ promise resolves on the next digest turn (`$q` continuations are
+ digest-scheduled). Follow-ups always run — no hangs, even with no `ngAnimate`.
+- **`ngAnimate` engine:** insertion (`enter` / `move`) still happens
+ synchronously at call time so layout is correct; the animation ceremony is
+ deferred to the digest end (a `$$postDigest` + one `raf` tick). `leave`
+ removal and class application defer to animation CLOSE. The promise resolves
+ when the animation completes, and REJECTS (with the `'cancelled'` reason) when
+ a newer operation on the same element cancels it.
+
+```ts
+$rootScope.$apply(() => (scope.show = true)); // ng-if enters
+// under ngAnimate the enter ceremony starts after this digest settles + one raf
+```
+
+## CSS animations (parity class conventions)
+
+With `ngAnimate` loaded, write ordinary CSS against the documented class names —
+no JavaScript. Structural operations apply a two-step pair: a preparation class
+when the op starts, then an activation class one frame later so the transition
+fires; both are removed at the end. Class-change operations derive the pair from
+the class being changed.
+
+| Operation | Prep class | Active class |
+| --- | --- | --- |
+| `enter` | `ng-enter` | `ng-enter-active` |
+| `leave` | `ng-leave` | `ng-leave-active` |
+| `move` | `ng-move` | `ng-move-active` |
+| add class `shrink` | `shrink-add` | `shrink-add-active` |
+| remove class `shrink` | `shrink-remove` | `shrink-remove-active` |
+
+While any animation runs the element carries the marker class `ng-animate`.
+
+```css
+.fade.ng-enter {
+ opacity: 0;
+ transition: opacity 0.5s;
+}
+.fade.ng-enter-active {
+ opacity: 1;
+}
+```
+
+```html
+
…
+
+```
+
+Both transitions AND keyframe animations are honored; the driver waits the
+longest declared `delay + duration` (transition or animation, whichever is
+larger), with a fallback timer at `delay + 1.5 × duration` guarding a missing
+`transitionend` / `animationend`. An element whose CSS declares no
+transition / keyframe for the applied classes settles instantly — the driver's
+synchronous match probe detects zero duration and takes the skip path.
+
+> **jsdom note:** `getComputedStyle` reports zero durations under jsdom, so the
+> CSS driver is a permanent no-op there. Tests exercise it by stubbing the
+> `computeStyle` / `raf` / `setTimer` seams and dispatching synthetic
+> `transitionend` events — no real rendering.
+
+## JavaScript animations (`.animation` + `$animateProvider.register`)
+
+Register a JS animation keyed by a CSS class selector — via the `.animation`
+module DSL or the provider directly (both install under the same
+`-animation` provider name, so there is no duplicated state). The factory
+returns an object with optional per-operation callbacks; each receives the
+element and a completion callback (`done`), and the animation closes when `done`
+is called. A callback may return a CANCEL function the engine invokes when a
+newer operation interrupts the in-flight animation.
+
+```ts
+createModule('app', ['ng', 'ngAnimate']).animation('.slide', [
+ () => ({
+ enter(element, done) {
+ element.classList.add('sliding-in');
+ const timer = setTimeout(done, 300);
+ return () => {
+ clearTimeout(timer); // cancellation hook
+ };
+ },
+ }),
+]);
+```
+
+The provider form is identical:
+
+```ts
+createModule('app', ['ng', 'ngAnimate']).config([
+ '$animateProvider',
+ ($ap) => {
+ $ap.register('.slide', [() => ({ enter(element, done) { done(); } })]);
+ },
+]);
+```
+
+The name MUST start with `'.'` (it is a class selector) — anything else throws
+synchronously to the caller (the provider-validation precedent, never routed
+through `$exceptionHandler`). A JS animation only runs on elements carrying the
+registered class, and only when `ngAnimate` is loaded. `injector.get('.slide-animation')`
+resolves the definition, and `module.decorator('.slide-animation', …)` wraps it
+for free (the `Filter` channel reproduced). CSS and JS animations for the
+same operation run TOGETHER and close jointly.
+
+A callback (or its cancel function) that THROWS is reported via
+`$exceptionHandler` with cause `'$animate'`, and the operation still counts as
+DONE — the element reaches its correct final state (FS §2.4: a broken animation
+never leaves the page stuck).
+
+## Enable / disable controls
+
+- **Global:** `$animate.enabled(false)` turns all animations off (instant
+ everywhere); `$animate.enabled(true)` re-enables; `$animate.enabled()` queries.
+- **Per-element subtree:** `$animate.enabled(element, false)` disables `element`
+ and its descendants while siblings keep animating; `$animate.enabled(element)`
+ reports the EFFECTIVE answer (global flag AND no disabled ancestor).
+- **`classNameFilter(pattern)`:** a config-phase `$AnimateProvider` getter/setter
+ — only elements whose class attribute matches `pattern` ever animate. Frozen
+ at `$get`.
+- **Startup grace:** animations are globally suppressed until the first digest
+ settles plus one `raf` tick, so the app's initial render appears instantly (no
+ wall of enter animations on page load).
+
+## `$animate.on` / `$animate.off` — event listeners
+
+Subscribe to animation notifications, scoped to a container:
+
+```ts
+$animate.on('enter', listContainer, (element, phase) => {
+ // phase is 'start' when the animation begins, 'close' when it completes
+});
+```
+
+The callback fires only for animations on `container` or elements INSIDE it
+(the walk matches `container` up the `parentElement` chain from the animating
+element), never for animations elsewhere. Callbacks run inside the digest under
+the `$$phase` guard. `off` has three removal granularities: `off(event)` (every
+listener for the event), `off(event, container)` (every listener on that
+container), or `off(event, container, callback)` (the exact triple). Under the
+INSTANT engine `on` / `off` are documented no-ops — no animations ever run, so
+no events ever fire.
+
+## Staggering + `ng-animate-children`
+
+- **Stagger:** when several sibling elements start the same operation in one
+ batch (the `ng-repeat` case), declare a delay via the companion stagger class
+ (`ng-enter-stagger` with a `transition-delay`); the engine offsets each
+ successive element's start by `staggerIndex × step` so items cascade. Only a
+ group of 2+ staggers (a lone animation gets index 0 → no offset).
+- **`ng-animate-children`:** by default, while an ancestor runs a STRUCTURAL
+ animation, animations on elements INSIDE it are suppressed (avoiding chaotic
+ nested effects). Marking an intervening container with `ng-animate-children`
+ (empty / `'on'` / `'true'` enables; `'off'` / `'false'` disables; any other
+ value is a scope expression watched reactively) opts the subtree back in.
+
+```html
+
+
…
+
+
+```
+
+## Interruption & consistency
+
+One in-flight animation record per element (a `WeakMap`) plus one pending
+(queued, not-yet-started) record per element. A new push on an element with an
+already-STARTED animation cancels it (the old runner rejects — pre-handled) and
+the new op wins, so the final DOM always reflects the LATEST requested
+operation. A new push on a still-PENDING op COALESCES: same-element class ops
+merge into one `setClass` (a net-empty `addClass` + `removeClass` of one class
+DROPS the ceremony, both promises resolve, the element is untouched); a
+structural op BEATS pending class ops; a later structural op SUPERSEDES an
+earlier one. Destroying a placeholder / view mid-animation (via the compiler's
+`addElementCleanup` contract) ENDS the runner instantly — driver cancelled, end
+state applied, no orphaned timers / classes / listeners.
+
+## The classList-preserving guarantee, carried through `$animate`
+
+The class togglers routed through `$animate` keep the spec-024 append-only
+guarantee: the CSS driver's `addTracked` records ONLY the classes it actually
+added, and cleanup removes exactly those — a pre-existing author class of the
+same name is never stripped. `setClass` (the `ng-class` flip form) is a single
+coalesced animation rather than two competing ones, so consumer classes on the
+element are never clobbered mid-flip.
+
+## Documented divergences from AngularJS
+
+- **The instant engine is SYNCHRONOUS.** Upstream core coalesces class changes
+ post-digest even without ngAnimate; this project's instant engine applies
+ every operation synchronously at call time. This deliberate divergence
+ preserves the project's shipped synchronous contracts — the existing
+ `ng-show` / `ng-class` / forms suites pass unedited once the directives are
+ rewired. With `ngAnimate` loaded, animations DO start at digest end (parity
+ there).
+- **Pre-handled runner promises.** A cancelled animation nobody listens to never
+ reaches `$q`'s always-on unhandled-rejection channel (upstream parity — its
+ `AnimateRunner` is not a `$q` promise at all). The runner attaches one
+ internal no-op rejection follow-up at construction, flipping the promise's
+ `handled` flag; a caller attaching its own `.catch` still observes the
+ rejection normally.
+- **`'$animate'` is the 14th `EXCEPTION_HANDLER_CAUSES` token** — the first
+ length change since spec 037 (`EXCEPTION_HANDLER_CAUSES` grew 13 → 14).
+ Routed via `'$animate'`: JS animation callback throws, JS factory resolution
+ failures at run time, and `on` listener callback throws. Cancelled-animation
+ rejections are NEVER reported (pre-handled). Synchronous programmer errors
+ (bad `register` name, non-`RegExp` `classNameFilter`) throw to the caller.
+- **`ng-include` / `ng-view` wrapper-`
` insertion** is inherited unchanged;
+ animations run against the wrapper container.
+- **Deferred (NOT this spec):** anchored / shared-element transitions
+ (`ng-animate-ref`), the standalone `$animateCss` helper, and `$animate`
+ integration for `ngMessages` (that module is not part of this project).
+
+## Forward-pointers
+
+- **`@async`** — `$q` is the promise toolkit every `$animate` method returns;
+ the runner is a pre-handled `$q` deferred.
+- **`@compiler`** — the structural / class-toggling directives that call
+ `$animate`, and the `addElementCleanup` contract that finalizes a destroyed
+ animation.
+- **`context/diagrams/animate.md`** — the collaborator graph and call order.
diff --git a/src/animate/__tests__/animate-children.test.ts b/src/animate/__tests__/animate-children.test.ts
new file mode 100644
index 0000000..032d0e8
--- /dev/null
+++ b/src/animate/__tests__/animate-children.test.ts
@@ -0,0 +1,397 @@
+/**
+ * Parent/child coordination + `ngAnimateChildren` tests (spec 041 Slice 8 /
+ * FS §2.8).
+ *
+ * Two layers, matched to what each proves best:
+ *
+ * - **Queue mechanism (direct `createAnimateQueue` harness)**: hand-placed DOM
+ * with a real `parentElement` chain and stub drivers. A parent STRUCTURAL
+ * animation is started (flushed) but NOT closed, so `structurallyAnimating`
+ * genuinely contains the ancestor when a descendant op is pushed — the exact
+ * condition `ancestorBlocksChild` gates on. The witness is observable: a
+ * suppressed child takes the INSTANT path (its stub driver never started,
+ * end state applied at push, promise resolves immediately); a re-enabled
+ * child DEFERS (stub started on the flush tick). This staging is chosen over
+ * the DI harness because the parent must be mid-flight (started, un-`done()`)
+ * at the precise moment the child pushes — trivially controllable here,
+ * fragile through the digest → `$$postDigest` → raf schedule.
+ * - **`ngAnimateChildren` directive (DI `[ngModule, ngAnimate]` harness)**:
+ * compiles the attribute in its real registration and asserts the resolved
+ * `$$ngAnimateChildren` flag the queue's ancestor walk reads — constant,
+ * disabling, and reactive-expression forms.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createAnimateQueue } from '@animate/animate-queue';
+import type { CssDriver, CssDriverAnimation } from '@animate/css-driver';
+import type { JsDriver } from '@animate/js-driver';
+import { getAnimateChildren, setAnimateChildren } from '@animate/ng-animate-children';
+import { ngAnimate } from '@animate/ng-animate-module';
+import { ngModule } from '@core/ng-module';
+import type { Scope } from '@core/scope';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import { createQ } from '@async/q';
+import type { QService } from '@async/q-types';
+import { noopExceptionHandler } from '@exception-handler/index';
+
+// ────────────────────────────────────────────────────────────────────────────
+// Direct queue harness — the parent/child suppression mechanism (FS §2.8)
+// ────────────────────────────────────────────────────────────────────────────
+
+/** A controllable stub animation recording start/cancel + capturing done. */
+function makeStubAnimation() {
+ const stub = {
+ started: false,
+ cancelled: false,
+ done: null as (() => void) | null,
+ start(onDone: () => void): void {
+ stub.started = true;
+ stub.done = onDone;
+ },
+ cancel(): void {
+ stub.cancelled = true;
+ },
+ };
+ return stub;
+}
+
+type StubAnimation = ReturnType;
+
+/** A pure `$q` with a synchronous drain (the `css-queue-integration` pattern). */
+function makePureQ(): { q: QService; flushQ: () => void } {
+ let queue: Array<() => void> = [];
+ const q = createQ({
+ exceptionHandler: noopExceptionHandler,
+ scheduleDigest: (fn) => {
+ queue.push(fn);
+ },
+ });
+ const flushQ = (): void => {
+ while (queue.length > 0) {
+ const batch = queue;
+ queue = [];
+ for (const fn of batch) {
+ fn();
+ }
+ }
+ };
+ return { q, flushQ };
+}
+
+/**
+ * Build the engine over an always-matching CSS stub driver (a fresh stub per
+ * push) and a never-matching JS driver, with manual scheduling seams. The CSS
+ * stub is the "would animate" witness — under jsdom the real CSS driver reports
+ * zero durations, so a controllable stub stands in.
+ */
+function makeQueueHarness() {
+ const { q, flushQ } = makePureQ();
+ const postDigestQueue: Array<() => void> = [];
+ const rafQueue: Array<() => void> = [];
+ const cssAnimations: StubAnimation[] = [];
+
+ const cssDriver: CssDriver = {
+ match: (): CssDriverAnimation => {
+ const animation = makeStubAnimation();
+ cssAnimations.push(animation);
+ return animation;
+ },
+ };
+ const jsDriver: JsDriver = { match: () => null };
+
+ const queue = createAnimateQueue({
+ q,
+ exceptionHandler: noopExceptionHandler,
+ postDigest: (fn) => {
+ postDigestQueue.push(fn);
+ },
+ raf: (callback) => {
+ rafQueue.push(callback);
+ },
+ jsDriver,
+ cssDriver,
+ classNameFilter: () => null,
+ });
+
+ const flushTick = (): void => {
+ while (postDigestQueue.length > 0) {
+ postDigestQueue.shift()?.();
+ }
+ while (rafQueue.length > 0) {
+ rafQueue.shift()?.();
+ }
+ };
+
+ flushTick(); // lift the startup grace
+
+ return { queue, flushQ, flushTick, cssAnimations };
+}
+
+/**
+ * Stage a mid-flight parent structural animation: push `parent`'s enter and
+ * flush the tick so it STARTS (populating `structurallyAnimating`) but do NOT
+ * fire its `done` — the child then pushes while the parent is genuinely
+ * in-flight. Returns the parent's still-open stub for the caller to close.
+ */
+function startParentEnter(
+ harness: ReturnType,
+ container: Element,
+ grandparent: Element,
+): StubAnimation {
+ void harness.queue.push([container], 'enter', { parent: grandparent });
+ harness.flushTick();
+ const parentStub = harness.cssAnimations[harness.cssAnimations.length - 1];
+ if (parentStub === undefined || !parentStub.started) {
+ throw new Error('expected the parent enter to have started');
+ }
+ return parentStub;
+}
+
+describe('ngAnimate queue — parent/child coordination (FS §2.8)', () => {
+ it('by default a descendant under a mid-flight structural parent is suppressed (instant path)', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const container = document.createElement('div');
+ const child = document.createElement('p');
+ grandparent.appendChild(container);
+ container.appendChild(child);
+
+ startParentEnter(harness, container, grandparent);
+ const startsBeforeChild = harness.cssAnimations.length;
+
+ // Push a class op on the descendant while the parent enter is in flight.
+ const resolved = vi.fn();
+ void harness.queue.push([child], 'addClass', { addClass: 'active' }).then(resolved);
+
+ // Suppressed: the child took the instant path — end state applied at push,
+ // NO new driver animation matched/started, promise resolves immediately.
+ expect(child.classList.contains('active')).toBe(true);
+ expect(harness.cssAnimations).toHaveLength(startsBeforeChild); // no new stub
+ harness.flushQ();
+ expect(resolved).toHaveBeenCalledTimes(1);
+ });
+
+ it('a truthy ng-animate-children marker on an intervening ancestor re-enables the descendant', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const container = document.createElement('div');
+ const child = document.createElement('p');
+ grandparent.appendChild(container);
+ container.appendChild(child);
+
+ // Mark the animating container itself as opting children back in.
+ setAnimateChildren(container, true);
+
+ startParentEnter(harness, container, grandparent);
+ const startsBeforeChild = harness.cssAnimations.length;
+
+ const resolved = vi.fn();
+ void harness.queue.push([child], 'addClass', { addClass: 'active' }).then(resolved);
+
+ // Re-enabled: a NEW driver animation matched and is DEFERRED to the flush
+ // tick (not started yet, end state not applied at push).
+ expect(harness.cssAnimations).toHaveLength(startsBeforeChild + 1);
+ const childStub = harness.cssAnimations[startsBeforeChild];
+ expect(childStub?.started).toBeFalsy();
+ expect(child.classList.contains('active')).toBe(false); // deferred to close
+
+ harness.flushTick();
+ expect(childStub?.started).toBe(true);
+ childStub?.done?.();
+ expect(child.classList.contains('active')).toBe(true);
+ harness.flushQ();
+ expect(resolved).toHaveBeenCalledTimes(1);
+ });
+
+ it('ng-animate-children resolved false keeps the descendant suppressed', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const container = document.createElement('div');
+ const child = document.createElement('p');
+ grandparent.appendChild(container);
+ container.appendChild(child);
+
+ // Explicit disable on the animating ancestor.
+ setAnimateChildren(container, false);
+
+ startParentEnter(harness, container, grandparent);
+ const startsBeforeChild = harness.cssAnimations.length;
+
+ void harness.queue.push([child], 'addClass', { addClass: 'active' });
+
+ // Still suppressed — a `false` marker does not re-enable.
+ expect(child.classList.contains('active')).toBe(true);
+ expect(harness.cssAnimations).toHaveLength(startsBeforeChild);
+ });
+
+ it('a suppressed child leave still removes the node and its promise resolves', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const container = document.createElement('div');
+ const child = document.createElement('p');
+ grandparent.appendChild(container);
+ container.appendChild(child);
+
+ startParentEnter(harness, container, grandparent);
+ const startsBeforeChild = harness.cssAnimations.length;
+
+ const resolved = vi.fn();
+ void harness.queue.push([child], 'leave', {}).then(resolved);
+
+ // Suppressed leave takes the instant path: node removed NOW, no ceremony,
+ // promise resolves immediately (FS §2.1 instant-engine parity).
+ expect(child.isConnected).toBe(false);
+ expect(child.parentNode).toBeNull();
+ expect(harness.cssAnimations).toHaveLength(startsBeforeChild);
+ harness.flushQ();
+ expect(resolved).toHaveBeenCalledTimes(1);
+ });
+
+ it('the nearest marker below the animating parent wins — a true marker between them re-enables', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const animatingParent = document.createElement('div');
+ const marked = document.createElement('div'); // intervening ng-animate-children="true"
+ const child = document.createElement('p');
+ grandparent.appendChild(animatingParent);
+ animatingParent.appendChild(marked);
+ marked.appendChild(child);
+
+ // The animating ancestor has NO marker; an intervening node between it and
+ // the child opts the subtree back in.
+ setAnimateChildren(marked, true);
+
+ startParentEnter(harness, animatingParent, grandparent);
+ const startsBeforeChild = harness.cssAnimations.length;
+
+ void harness.queue.push([child], 'addClass', { addClass: 'active' });
+
+ // Re-enabled by the nearer `true` marker — a new deferred ceremony.
+ expect(harness.cssAnimations).toHaveLength(startsBeforeChild + 1);
+ expect(harness.cssAnimations[startsBeforeChild]?.started).toBeFalsy();
+ });
+
+ it('a child with no structurally-animating ancestor is never suppressed', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const container = document.createElement('div');
+ const child = document.createElement('p');
+ grandparent.appendChild(container);
+ container.appendChild(child);
+
+ // NO parent animation staged → structurallyAnimating is empty.
+ const startsBefore = harness.cssAnimations.length;
+ void harness.queue.push([child], 'addClass', { addClass: 'active' });
+
+ // Animates: a fresh deferred ceremony, end state not applied at push.
+ expect(harness.cssAnimations).toHaveLength(startsBefore + 1);
+ expect(child.classList.contains('active')).toBe(false);
+ });
+
+ it('once the parent structural animation closes, descendants animate again', () => {
+ const harness = makeQueueHarness();
+ const grandparent = document.createElement('div');
+ const container = document.createElement('div');
+ const child = document.createElement('p');
+ grandparent.appendChild(container);
+ container.appendChild(child);
+
+ const parentStub = startParentEnter(harness, container, grandparent);
+
+ // Close the parent — structurallyAnimating clears.
+ parentStub.done?.();
+
+ const startsBefore = harness.cssAnimations.length;
+ void harness.queue.push([child], 'addClass', { addClass: 'active' });
+
+ // No longer suppressed — the child animates.
+ expect(harness.cssAnimations).toHaveLength(startsBefore + 1);
+ expect(child.classList.contains('active')).toBe(false);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// ngAnimateChildren directive — flag resolution (DI harness, FS §2.8)
+// ────────────────────────────────────────────────────────────────────────────
+
+/** Bootstrap a `[ngModule, ngAnimate, app]` injector and compile markup. */
+function bootstrap() {
+ const injector = createInjector([ngModule, ngAnimate, createModule('ng-animate-children-app', [])]);
+ return {
+ injector,
+ $compile: injector.get('$compile'),
+ $rootScope: injector.get('$rootScope') as Scope,
+ };
+}
+
+/** Compile a fragment against `$rootScope` and return the marked element. */
+function compileMarkup(html: string, selector: string): { element: Element; $rootScope: Scope } {
+ const { $compile, $rootScope } = bootstrap();
+ const root = document.createElement('div');
+ root.innerHTML = html;
+ $compile(root)($rootScope);
+ const element = root.querySelector(selector);
+ if (element === null) {
+ throw new Error(`no element matched ${selector}`);
+ }
+ return { element, $rootScope };
+}
+
+afterEach(() => {
+ resetRegistry();
+});
+
+describe('ngAnimateChildren directive — resolved flag (FS §2.8)', () => {
+ it('an empty ng-animate-children attribute enables (stashes true)', () => {
+ const { element, $rootScope } = compileMarkup('', '#m');
+ $rootScope.$digest();
+ expect(getAnimateChildren(element)).toBe(true);
+ });
+
+ it('ng-animate-children="on" and ="true" both enable', () => {
+ const on = compileMarkup('', '#m');
+ on.$rootScope.$digest();
+ expect(getAnimateChildren(on.element)).toBe(true);
+ resetRegistry();
+
+ const trueForm = compileMarkup('', '#m');
+ trueForm.$rootScope.$digest();
+ expect(getAnimateChildren(trueForm.element)).toBe(true);
+ });
+
+ it('ng-animate-children="false" and ="off" both disable', () => {
+ const off = compileMarkup('', '#m');
+ off.$rootScope.$digest();
+ expect(getAnimateChildren(off.element)).toBe(false);
+ resetRegistry();
+
+ const falseForm = compileMarkup('', '#m');
+ falseForm.$rootScope.$digest();
+ expect(getAnimateChildren(falseForm.element)).toBe(false);
+ });
+
+ it('a non-constant value is watched as an expression and updates reactively', () => {
+ const { element, $rootScope } = compileMarkup('', '#m');
+
+ $rootScope.enabled = true;
+ $rootScope.$digest();
+ expect(getAnimateChildren(element)).toBe(true);
+
+ // Flip the scope value — the stashed flag follows on the next digest.
+ $rootScope.enabled = false;
+ $rootScope.$digest();
+ expect(getAnimateChildren(element)).toBe(false);
+
+ $rootScope.enabled = 'truthy-string';
+ $rootScope.$digest();
+ expect(getAnimateChildren(element)).toBe(true);
+ });
+
+ it('an element with no ng-animate-children marker reads undefined', () => {
+ const { element, $rootScope } = compileMarkup('', '#m');
+ $rootScope.$digest();
+ expect(getAnimateChildren(element)).toBeUndefined();
+ });
+});
diff --git a/src/animate/__tests__/animate-coalesce-support.test.ts b/src/animate/__tests__/animate-coalesce-support.test.ts
new file mode 100644
index 0000000..a2b73b3
--- /dev/null
+++ b/src/animate/__tests__/animate-coalesce-support.test.ts
@@ -0,0 +1,283 @@
+/**
+ * Direct unit coverage for the two pure coalescing / driver-support
+ * helper modules (`animate-coalesce.ts` + `animate-queue-support.ts`,
+ * spec 041 Slices 4/6).
+ *
+ * These functions are the queue's decision core — `classifyCoalesce`
+ * (the cancel/join matrix), `foldClassOp` (the self-cancelling
+ * add/remove accumulator), `combineDriverAnimations` (the JS+CSS joint
+ * close), `applyEndState` (the deferred end-state applier), and
+ * `firstElement` (the group-keying helper). The DI-harness suites
+ * (`animate-interruption.test.ts` / `class-routing.test.ts`) exercise
+ * them end-to-end; this file pins the individual branches directly so
+ * each decision path is guarded in isolation, matching the pure-factory
+ * unit-test precedent (`css-driver.test.ts`, `js-driver.test.ts`).
+ *
+ * @see src/animate/animate-coalesce.ts
+ * @see src/animate/animate-queue-support.ts
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ classifyCoalesce,
+ deltaFromOptions,
+ emptyClassDelta,
+ foldClassOp,
+ isClassEvent,
+ isEmptyClassDelta,
+} from '@animate/animate-coalesce';
+import type { AnimateQueuePushOptions } from '@animate/animate-types';
+import {
+ type DriverAnimation,
+ applyEndState,
+ combineDriverAnimations,
+ firstElement,
+} from '@animate/animate-queue-support';
+
+// ────────────────────────────────────────────────────────────────────────────
+// isClassEvent
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('isClassEvent', () => {
+ it('is true for the three class ops, false for the structural ops', () => {
+ expect(isClassEvent('addClass')).toBe(true);
+ expect(isClassEvent('removeClass')).toBe(true);
+ expect(isClassEvent('setClass')).toBe(true);
+ expect(isClassEvent('enter')).toBe(false);
+ expect(isClassEvent('leave')).toBe(false);
+ expect(isClassEvent('move')).toBe(false);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// foldClassOp — the self-cancelling accumulator
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('foldClassOp', () => {
+ it('add-then-remove of the same class nets to NEITHER set (self-cancel)', () => {
+ const delta = emptyClassDelta();
+ foldClassOp(delta, 'x', undefined);
+ expect(delta.add.has('x')).toBe(true);
+ // Removing X that is pending-addition cancels the pair.
+ foldClassOp(delta, undefined, 'x');
+ expect(delta.add.has('x')).toBe(false);
+ expect(delta.remove.has('x')).toBe(false);
+ expect(isEmptyClassDelta(delta)).toBe(true);
+ });
+
+ it('remove-then-add of the same class nets to NEITHER set (the inverse continue branch)', () => {
+ const delta = emptyClassDelta();
+ foldClassOp(delta, undefined, 'y');
+ expect(delta.remove.has('y')).toBe(true);
+ // Adding Y that is pending-removal cancels the pair (the `continue`).
+ foldClassOp(delta, 'y', undefined);
+ expect(delta.add.has('y')).toBe(false);
+ expect(delta.remove.has('y')).toBe(false);
+ });
+
+ it('distinct add + remove accumulate into their respective disjoint sets', () => {
+ const delta = emptyClassDelta();
+ foldClassOp(delta, 'a b', 'c');
+ expect([...delta.add].sort()).toEqual(['a', 'b']);
+ expect([...delta.remove]).toEqual(['c']);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// deltaFromOptions
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('deltaFromOptions', () => {
+ it('seeds the delta from a setClass-shaped options bag', () => {
+ const delta = deltaFromOptions({ addClass: 'on hot', removeClass: 'off' });
+ expect([...delta.add].sort()).toEqual(['hot', 'on']);
+ expect([...delta.remove]).toEqual(['off']);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// classifyCoalesce — the full cancel/join matrix
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('classifyCoalesce', () => {
+ const opts = (o: Partial): AnimateQueuePushOptions => o;
+
+ it('class + class that stay non-empty → merge-class with the combined delta', () => {
+ const existing = deltaFromOptions({ addClass: 'a' });
+ const outcome = classifyCoalesce('addClass', existing, 'addClass', opts({ addClass: 'b' }));
+ expect(outcome.kind).toBe('merge-class');
+ if (outcome.kind === 'merge-class') {
+ expect([...outcome.delta.add].sort()).toEqual(['a', 'b']);
+ }
+ });
+
+ it('class + class that cancel out → drop (empty delta)', () => {
+ const existing = deltaFromOptions({ addClass: 'a' });
+ const outcome = classifyCoalesce('addClass', existing, 'removeClass', opts({ removeClass: 'a' }));
+ expect(outcome.kind).toBe('drop');
+ if (outcome.kind === 'drop') {
+ expect(isEmptyClassDelta(outcome.delta)).toBe(true);
+ }
+ });
+
+ it('pending class + NEW structural → absorb-into-new carrying a COPY of the class delta', () => {
+ const existing = deltaFromOptions({ addClass: 'fade-in' });
+ const outcome = classifyCoalesce('addClass', existing, 'leave', opts({}));
+ expect(outcome.kind).toBe('absorb-into-new');
+ if (outcome.kind === 'absorb-into-new') {
+ expect([...outcome.delta.add]).toEqual(['fade-in']);
+ // The returned delta is a COPY — mutating the source does not leak.
+ existing.add.add('leaked');
+ expect(outcome.delta.add.has('leaked')).toBe(false);
+ }
+ });
+
+ it('pending structural + NEW class → fold-into-existing with the new class delta', () => {
+ const outcome = classifyCoalesce('enter', emptyClassDelta(), 'addClass', opts({ addClass: 'shown' }));
+ expect(outcome.kind).toBe('fold-into-existing');
+ if (outcome.kind === 'fold-into-existing') {
+ expect([...outcome.delta.add]).toEqual(['shown']);
+ }
+ });
+
+ it('structural + structural → supersede (the later wins)', () => {
+ const outcome = classifyCoalesce('enter', emptyClassDelta(), 'leave', opts({}));
+ expect(outcome.kind).toBe('supersede');
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// firstElement
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('firstElement', () => {
+ it('returns the first Element in a mixed node group', () => {
+ const text = document.createTextNode('t');
+ const el = document.createElement('span');
+ expect(firstElement([text, el])).toBe(el);
+ });
+
+ it('returns null for an element-less group (all text / comment nodes)', () => {
+ const text = document.createTextNode('t');
+ const comment = document.createComment('c');
+ expect(firstElement([text, comment])).toBeNull();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// combineDriverAnimations — the JS + CSS joint close
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('combineDriverAnimations', () => {
+ function stubDriver(): { anim: DriverAnimation; onDone: () => void; started: boolean; cancelCount: number } {
+ const rec: { anim: DriverAnimation; onDone: () => void; started: boolean; cancelCount: number } = {
+ anim: {} as DriverAnimation,
+ onDone: () => undefined,
+ started: false,
+ cancelCount: 0,
+ };
+ rec.anim = {
+ start(onDone) {
+ rec.started = true;
+ rec.onDone = onDone;
+ },
+ cancel() {
+ rec.cancelCount += 1;
+ },
+ };
+ return rec;
+ }
+
+ it('one null match → the other driver runs alone', () => {
+ const js = stubDriver();
+ expect(combineDriverAnimations(js.anim, null)).toBe(js.anim);
+ const css = stubDriver();
+ expect(combineDriverAnimations(null, css.anim)).toBe(css.anim);
+ expect(combineDriverAnimations(null, null)).toBeNull();
+ });
+
+ it('both present → the joint close fires ONLY after BOTH report done', () => {
+ const js = stubDriver();
+ const css = stubDriver();
+ const combined = combineDriverAnimations(js.anim, css.anim);
+ expect(combined).not.toBeNull();
+
+ const closed = vi.fn();
+ combined?.start(closed);
+ expect(js.started).toBe(true);
+ expect(css.started).toBe(true);
+
+ js.onDone();
+ expect(closed).not.toHaveBeenCalled();
+ css.onDone();
+ expect(closed).toHaveBeenCalledTimes(1);
+ });
+
+ it('cancel fans out to BOTH drivers', () => {
+ const js = stubDriver();
+ const css = stubDriver();
+ const combined = combineDriverAnimations(js.anim, css.anim);
+ combined?.cancel();
+ expect(js.cancelCount).toBe(1);
+ expect(css.cancelCount).toBe(1);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// applyEndState — the deferred end-state applier
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('applyEndState', () => {
+ it('enter / move are no-ops (insertion already happened at push time)', () => {
+ const el = document.createElement('div');
+ const parent = document.createElement('div');
+ parent.appendChild(el);
+ applyEndState([el], 'enter', {});
+ applyEndState([el], 'move', {});
+ // Neither op touches the node group — the element keeps its parent.
+ expect(el.parentNode).toBe(parent);
+ });
+
+ it('leave removes the node group', () => {
+ const parent = document.createElement('div');
+ const el = document.createElement('div');
+ parent.appendChild(el);
+ applyEndState([el], 'leave', {});
+ expect(el.isConnected).toBe(false);
+ });
+
+ it('addClass adds; removeClass removes; setClass does both', () => {
+ const el = document.createElement('div');
+ el.className = 'seed';
+
+ applyEndState([el], 'addClass', { addClass: 'on' });
+ expect(el.classList.contains('on')).toBe(true);
+
+ applyEndState([el], 'removeClass', { removeClass: 'on' });
+ expect(el.classList.contains('on')).toBe(false);
+
+ applyEndState([el], 'setClass', { addClass: 'a', removeClass: 'seed' });
+ expect(el.classList.contains('a')).toBe(true);
+ expect(el.classList.contains('seed')).toBe(false);
+ });
+
+ it('a structural op with an extraDelta folds the absorbed class change AFTER the event', () => {
+ const parent = document.createElement('div');
+ const el = document.createElement('div');
+ el.className = 'old';
+ parent.appendChild(el);
+ // enter absorbs a class delta (add `new`, remove `old`).
+ applyEndState([el], 'enter', {}, { add: new Set(['new']), remove: new Set(['old']) });
+ expect(el.classList.contains('new')).toBe(true);
+ expect(el.classList.contains('old')).toBe(false);
+ });
+
+ it('a CLASS op ignores extraDelta (only structural ops absorb deltas)', () => {
+ const el = document.createElement('div');
+ const extra: { add: Set; remove: Set } = { add: new Set(['ignored']), remove: new Set() };
+ applyEndState([el], 'addClass', { addClass: 'real' }, extra);
+ expect(el.classList.contains('real')).toBe(true);
+ expect(el.classList.contains('ignored')).toBe(false);
+ });
+});
diff --git a/src/animate/__tests__/animate-di.test.ts b/src/animate/__tests__/animate-di.test.ts
new file mode 100644
index 0000000..17031fd
--- /dev/null
+++ b/src/animate/__tests__/animate-di.test.ts
@@ -0,0 +1,189 @@
+/**
+ * `$animate` DI integration tests (spec 041 Slice 1 / FS §2.1, tech spec
+ * §2.1, §2.3).
+ *
+ * Exercises the real registration path on core `ng`:
+ *
+ * - `injector.get('$animate')` resolves in a BARE `[ngModule]` injector (the
+ * "always available" contract — no opt-in module needed), as a singleton.
+ * - The internal `$$animateQueue` engine seam is registered and resolvable.
+ * - End-to-end operations through DI mutate the DOM synchronously (instant
+ * engine) and their `$q` promises resolve once a digest runs (FS §2.5).
+ * - The engine seam is swappable: a later module re-registering
+ * `$$animateQueue` wins (DI last-wins) and the façade delegates to it —
+ * the upstream `ngAnimate` override seam verified at the DI level.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ngModule } from '@core/ng-module';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import type { AnimateQueue } from '@animate/animate-types';
+
+afterEach(() => {
+ resetRegistry();
+});
+
+describe('$animate — DI resolution (tech spec §2.1)', () => {
+ it("injector.get('$animate') resolves in a bare [ngModule] injector", () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+
+ expect(typeof $animate.enter).toBe('function');
+ expect(typeof $animate.leave).toBe('function');
+ expect(typeof $animate.move).toBe('function');
+ expect(typeof $animate.addClass).toBe('function');
+ expect(typeof $animate.removeClass).toBe('function');
+ expect(typeof $animate.setClass).toBe('function');
+ expect(typeof $animate.enabled).toBe('function');
+ expect(typeof $animate.on).toBe('function');
+ expect(typeof $animate.off).toBe('function');
+ });
+
+ it('$animate is a singleton — repeated gets return the SAME reference', () => {
+ const injector = createInjector([ngModule]);
+ expect(injector.get('$animate')).toBe(injector.get('$animate'));
+ });
+
+ it('the internal $$animateQueue engine seam is registered and resolvable', () => {
+ const injector = createInjector([ngModule]);
+ expect(injector.has('$$animateQueue')).toBe(true);
+ const queue = injector.get('$$animateQueue');
+ expect(typeof queue.push).toBe('function');
+ });
+});
+
+describe('$animate — end-to-end operations through DI (FS §2.1, §2.5)', () => {
+ it('enter inserts after the anchor synchronously and the promise resolves on digest', () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+ const $rootScope = injector.get('$rootScope');
+
+ const parent = document.createElement('div');
+ const anchor = document.createComment(' ngIf: show ');
+ parent.appendChild(anchor);
+ const entering = document.createElement('p');
+ const done = vi.fn();
+
+ const promise = $animate.enter(entering, parent, anchor);
+
+ // Instant engine: DOM applied the moment the call returns.
+ expect(anchor.nextSibling).toBe(entering);
+
+ promise.then(done);
+ expect(done).not.toHaveBeenCalled(); // digest-scheduled, never sync
+
+ $rootScope.$digest();
+ expect(done).toHaveBeenCalledTimes(1);
+ });
+
+ it('leave removes the node synchronously and the promise resolves on digest', () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+ const $rootScope = injector.get('$rootScope');
+
+ const parent = document.createElement('div');
+ const leaving = document.createElement('p');
+ parent.appendChild(leaving);
+ const done = vi.fn();
+
+ $animate.leave(leaving).then(done);
+
+ expect(leaving.parentNode).toBeNull();
+
+ $rootScope.$digest();
+ expect(done).toHaveBeenCalledTimes(1);
+ });
+
+ it('move repositions a multi-node group as one unit (spec 033 ranges)', () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+
+ const parent = document.createElement('ul');
+ const a = document.createElement('li');
+ const b = document.createElement('li');
+ const c = document.createElement('li');
+ parent.append(a, b, c);
+
+ $animate.move([a, b], parent, c);
+
+ expect(Array.from(parent.childNodes)).toEqual([c, a, b]);
+ });
+
+ it('addClass / removeClass / setClass apply via classList synchronously', () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+
+ const el = document.createElement('div');
+ el.className = 'card old';
+
+ $animate.addClass(el, 'ng-hide');
+ expect(el.classList.contains('ng-hide')).toBe(true);
+
+ $animate.removeClass(el, 'ng-hide');
+ expect(el.classList.contains('ng-hide')).toBe(false);
+
+ $animate.setClass(el, 'new', 'old');
+ expect(el.classList.contains('new')).toBe(true);
+ expect(el.classList.contains('old')).toBe(false);
+ // Author classes are never stripped.
+ expect(el.classList.contains('card')).toBe(true);
+ });
+
+ it('never leaves animation classes behind (the no-ngAnimate contract, FS §2.1)', () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+ const $rootScope = injector.get('$rootScope');
+
+ const parent = document.createElement('div');
+ const entering = document.createElement('p');
+
+ $animate.enter(entering, parent);
+ $rootScope.$digest();
+
+ expect(entering.className).toBe('');
+ });
+
+ it('enabled() round-trips through the façade to the engine', () => {
+ const injector = createInjector([ngModule]);
+ const $animate = injector.get('$animate');
+
+ expect($animate.enabled()).toBe(true);
+ expect($animate.enabled(false)).toBe(false);
+ expect($animate.enabled()).toBe(false);
+ expect($animate.enabled(true)).toBe(true);
+ });
+});
+
+describe('$animate — the $$animateQueue override seam (DI last-wins, tech spec §1)', () => {
+ it('a later module re-registering $$animateQueue swaps ALL façade behavior', () => {
+ const pushes: Array<{ event: string; nodeCount: number }> = [];
+ const injectorForQ = createInjector([ngModule]);
+ const sentinel = injectorForQ.get('$q').resolve(undefined);
+
+ const stubQueue: AnimateQueue = {
+ push(nodes, event) {
+ pushes.push({ event, nodeCount: nodes.length });
+ return sentinel;
+ },
+ enabled: () => true,
+ on: () => undefined,
+ off: () => undefined,
+ };
+
+ const app = createModule('app', []).factory('$$animateQueue', [() => stubQueue]);
+ const injector = createInjector([ngModule, app]);
+ const $animate = injector.get('$animate');
+
+ const parent = document.createElement('div');
+ const el = document.createElement('p');
+ const result = $animate.enter(el, parent);
+
+ // The stub engine received the delegation — the core instant engine did
+ // NOT run (no DOM insertion happened).
+ expect(pushes).toEqual([{ event: 'enter', nodeCount: 1 }]);
+ expect(el.parentNode).toBeNull();
+ expect(result).toBe(sentinel);
+ });
+});
diff --git a/src/animate/__tests__/animate-enabled.test.ts b/src/animate/__tests__/animate-enabled.test.ts
new file mode 100644
index 0000000..79d6acd
--- /dev/null
+++ b/src/animate/__tests__/animate-enabled.test.ts
@@ -0,0 +1,392 @@
+/**
+ * `ngAnimate` enable/disable-controls integration tests (spec 041 Slice 7 /
+ * FS §2.6 acceptance criteria).
+ *
+ * Full `[ngModule, ngAnimate, app]` DI harness with a registered JS
+ * `.animation` (so "would animate" is genuinely true — under jsdom the CSS
+ * driver reports zero durations and is a permanent no-op, so the registered
+ * JS animation IS the animation), driven post-startup-grace so the grace gate
+ * never masks the enable/disable decision under test. Every scenario is the
+ * SAME observable witness: does the registered enter/leave callback fire (the
+ * element animates) or does the DOM change instantly at digest with no
+ * ceremony?
+ *
+ * Pinned here (the four FS §2.6 acceptance criteria plus their compositions):
+ *
+ * 1. Global toggle: `$animate.enabled(false)` → an `ng-if` toggle with a
+ * matching animation applies INSTANTLY (no callback, element mounted at
+ * digest); `$animate.enabled(true)` restores animation. The no-arg getter
+ * echoes the set value.
+ * 2. Per-element subtree: `$animate.enabled(container, false)` → an element
+ * INSIDE the container is instant while an identical element OUTSIDE
+ * animates; `enabled(element)` reports false under the container, true for
+ * a sibling outside; `enabled(container, true)` re-enables.
+ * 3. `classNameFilter`: a config block sets `/animate-me/` → an element
+ * carrying `animate-me` animates, an otherwise-identical element without it
+ * is instant.
+ * 4. Effective-state composition: an element itself enabled but under a
+ * disabled ancestor → `enabled(element)` false and instant.
+ * 5. Leave still removes when disabled: with `enabled(false)`, an `ng-if`
+ * false removes the node SYNCHRONOUSLY (no lingering node, no ceremony).
+ */
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { $AnimateProvider } from '@animate/animate-provider';
+import type { AnimationDefinition } from '@animate/animate-types';
+import { ngAnimate } from '@animate/ng-animate-module';
+import { asInstanceOf } from '@core/index';
+import { ngModule } from '@core/ng-module';
+import type { Scope } from '@core/scope';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import type { ExceptionHandler } from '@exception-handler/index';
+
+/** One recorded `$exceptionHandler` invocation. */
+interface Report {
+ exception: unknown;
+ cause: string | undefined;
+}
+
+/** One recorded JS animation callback fire. */
+interface AnimationCall {
+ element: Element;
+ /** The per-operation completion callback, for the test to fire on cue. */
+ done: () => void;
+}
+
+/** A config-block hook receiving the `$AnimateProvider` instance. */
+type ConfigureAnimate = (provider: $AnimateProvider) => void;
+
+/**
+ * Await one animation frame, then drain the microtask queue defensively
+ * (the `ng-animate-integration.test.ts` precedent). Because jsdom fires
+ * pending raf callbacks in registration order, any queue-scheduled raf work
+ * (flush tick, startup-grace lift) registered BEFORE this call completes
+ * within the awaited frame.
+ */
+async function nextFrame(): Promise {
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+/**
+ * Settle the app: run the first digest (fires the queue's startup-grace
+ * `$$postDigest`) and flush one raf tick so the grace lifts. Every push AFTER
+ * this helper returns is animation-eligible (FS §2.6).
+ */
+async function liftStartupGrace($rootScope: Scope): Promise {
+ $rootScope.$digest();
+ await nextFrame();
+}
+
+/**
+ * Bootstrap a `[ngModule, ngAnimate, app]` injector with the given
+ * `.animation` registrations, an optional config block (for `classNameFilter`),
+ * and a recording `$exceptionHandler`. Resolves `$animate` eagerly so the
+ * animation engine (and its startup-grace schedule) exists before the first
+ * digest.
+ */
+function bootstrap(animations: Record = {}, configure?: ConfigureAnimate) {
+ const reports: Report[] = [];
+ const handler: ExceptionHandler = (exception, cause) => {
+ reports.push({ exception, cause });
+ };
+ const app = createModule('ng-animate-enabled-app', []).factory('$exceptionHandler', [() => handler]);
+ for (const [selector, definition] of Object.entries(animations)) {
+ app.animation(selector, [() => definition]);
+ }
+ if (configure !== undefined) {
+ app.config(['$animateProvider', configure]);
+ }
+ const injector = createInjector([ngModule, ngAnimate, app]);
+ return {
+ injector,
+ $compile: injector.get('$compile'),
+ $rootScope: injector.get('$rootScope'),
+ $animate: injector.get('$animate'),
+ reports,
+ };
+}
+
+afterEach(() => {
+ resetRegistry();
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 1. Global toggle (FS §2.6 — enabled(false) → instant; enabled(true) → animates)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — global enable/disable toggle (FS §2.6)', () => {
+ it('$animate.enabled(false) → ng-if toggle applies instantly; enabled(true) restores animation', async () => {
+ const enterCalls: AnimationCall[] = [];
+ const { $compile, $rootScope, $animate, reports } = bootstrap({
+ '.fade': {
+ enter(element, done) {
+ enterCalls.push({ element, done });
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ // Globally disable — the getter echoes the set value.
+ expect($animate.enabled(false)).toBe(false);
+ expect($animate.enabled()).toBe(false);
+
+ $rootScope.show = true;
+ $rootScope.$digest();
+ // Inserted synchronously at digest time — no ceremony pending.
+ expect(root.querySelector('p')).not.toBeNull();
+ await nextFrame();
+ // No enter callback ever fired — the element mounted instantly.
+ expect(enterCalls).toHaveLength(0);
+
+ // Reset for the re-enabled pass (leave is unregistered → instant removal).
+ $rootScope.show = false;
+ $rootScope.$digest();
+ expect(root.querySelector('p')).toBeNull();
+
+ // Re-enable — the getter echoes the restored value.
+ expect($animate.enabled(true)).toBe(true);
+ expect($animate.enabled()).toBe(true);
+
+ $rootScope.show = true;
+ $rootScope.$digest();
+ await nextFrame();
+ // Now it animates: the enter callback fires.
+ expect(enterCalls).toHaveLength(1);
+ const mounted = asInstanceOf(root.querySelector('p'), HTMLParagraphElement);
+ expect(enterCalls[0]?.element).toBe(mounted);
+
+ enterCalls[0]?.done();
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 2. Per-element subtree toggle (FS §2.6 — container off, siblings animate)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — per-element subtree disable (FS §2.6)', () => {
+ it('enabled(container, false) → inside is instant, outside animates; enabled(element) reflects it; enabled(container, true) re-enables', async () => {
+ const enterCalls: AnimationCall[] = [];
+ const { $compile, $rootScope, $animate, reports } = bootstrap({
+ '.fade': {
+ enter(element, done) {
+ enterCalls.push({ element, done });
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ // Two identical animated elements: one inside the disabled container,
+ // one a true sibling outside it.
+ root.innerHTML =
+ '
in
' +
+ '
out
';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ const container = asInstanceOf(root.querySelector('#inside-host'), HTMLDivElement);
+
+ // Disable animations for the container's subtree only.
+ expect($animate.enabled(container, false)).toBe(false);
+
+ $rootScope.show = true;
+ $rootScope.$digest();
+ // Both mounted synchronously at digest.
+ const inner = asInstanceOf(root.querySelector('#inner'), HTMLParagraphElement);
+ const outer = asInstanceOf(root.querySelector('#outer'), HTMLParagraphElement);
+ await nextFrame();
+
+ // Only the OUTSIDE element animated (its enter callback fired); the inside
+ // one was instant.
+ expect(enterCalls).toHaveLength(1);
+ expect(enterCalls[0]?.element).toBe(outer);
+ expect(enterCalls.some((call) => call.element === inner)).toBe(false);
+
+ // enabled(element) query composition: false under the disabled container,
+ // true for the sibling outside it, false for the container itself.
+ expect($animate.enabled(inner)).toBe(false);
+ expect($animate.enabled(container)).toBe(false);
+ expect($animate.enabled(outer)).toBe(true);
+
+ // Re-enable the container, then re-enter to confirm the inside now animates.
+ enterCalls[0]?.done();
+ $rootScope.show = false;
+ $rootScope.$digest();
+ expect(root.querySelector('#inner')).toBeNull();
+ expect(root.querySelector('#outer')).toBeNull();
+
+ expect($animate.enabled(container, true)).toBe(true);
+ expect($animate.enabled(container)).toBe(true);
+
+ enterCalls.length = 0;
+ $rootScope.show = true;
+ $rootScope.$digest();
+ const innerAgain = asInstanceOf(root.querySelector('#inner'), HTMLParagraphElement);
+ const outerAgain = asInstanceOf(root.querySelector('#outer'), HTMLParagraphElement);
+ await nextFrame();
+ // Both animate now that the container is re-enabled.
+ expect(enterCalls).toHaveLength(2);
+ const animatedElements = enterCalls.map((call) => call.element);
+ expect(animatedElements).toContain(innerAgain);
+ expect(animatedElements).toContain(outerAgain);
+ expect($animate.enabled(innerAgain)).toBe(true);
+
+ for (const call of enterCalls) {
+ call.done();
+ }
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 3. classNameFilter (FS §2.6 — only matching classes animate)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — classNameFilter gate (FS §2.6)', () => {
+ it('with classNameFilter(/animate-me/): a matching element animates, a non-matching identical element is instant', async () => {
+ const enterCalls: AnimationCall[] = [];
+ const { $compile, $rootScope, reports } = bootstrap(
+ {
+ // Registered under `.fade` so BOTH elements carry a matching JS
+ // animation — only the classNameFilter decides which one animates.
+ '.fade': {
+ enter(element, done) {
+ enterCalls.push({ element, done });
+ },
+ },
+ },
+ (provider) => {
+ provider.classNameFilter(/animate-me/);
+ },
+ );
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ // Both have `.fade` (so the JS driver matches); only one also carries the
+ // `animate-me` class the filter requires.
+ root.innerHTML =
+ '
yes
' +
+ '
no
';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ $rootScope.show = true;
+ $rootScope.$digest();
+ const filtered = asInstanceOf(root.querySelector('#filtered'), HTMLParagraphElement);
+ const plain = asInstanceOf(root.querySelector('#plain'), HTMLParagraphElement);
+ // Both mounted synchronously; the filter decides which one animates.
+ expect(filtered).not.toBeNull();
+ expect(plain).not.toBeNull();
+ await nextFrame();
+
+ // Only the filter-matching element animated.
+ expect(enterCalls).toHaveLength(1);
+ expect(enterCalls[0]?.element).toBe(filtered);
+ expect(enterCalls.some((call) => call.element === plain)).toBe(false);
+
+ enterCalls[0]?.done();
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 4. Effective-state composition (FS §2.6 — enabled element under disabled ancestor)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — effective-state composition (FS §2.6)', () => {
+ it('an element itself enabled but under a disabled ancestor → enabled(element) is false and it is instant', async () => {
+ const enterCalls: AnimationCall[] = [];
+ const { $compile, $rootScope, $animate, reports } = bootstrap({
+ '.fade': {
+ enter(element, done) {
+ enterCalls.push({ element, done });
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ const ancestor = asInstanceOf(root.querySelector('#ancestor'), HTMLDivElement);
+
+ // Disable the ANCESTOR subtree.
+ $animate.enabled(ancestor, false);
+
+ $rootScope.show = true;
+ $rootScope.$digest();
+ const leaf = asInstanceOf(root.querySelector('#leaf'), HTMLParagraphElement);
+
+ // Even after an explicit re-enable of the LEAF, the disabled ancestor still
+ // suppresses it (a re-enable drops the leaf's own entry so it re-inherits
+ // the ancestor's disabled state — it does NOT override the ancestor).
+ $animate.enabled(leaf, true);
+ expect($animate.enabled(leaf)).toBe(false);
+
+ await nextFrame();
+ // Instant: no enter callback fired despite the leaf being individually
+ // "enabled".
+ expect(enterCalls).toHaveLength(0);
+ expect(leaf.isConnected).toBe(true);
+
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 5. Leave still removes when disabled (FS §2.6 — disabled leave is instant removal)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — disabled leave still removes synchronously (FS §2.6)', () => {
+ it('with enabled(false), ng-if false removes the node synchronously — no lingering node, no ceremony', async () => {
+ const leaveCalls: AnimationCall[] = [];
+ const { $compile, $rootScope, $animate, reports } = bootstrap({
+ '.fade': {
+ leave(element, done) {
+ leaveCalls.push({ element, done });
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ // Mount first (leave is what we test; enter is unregistered → instant).
+ $rootScope.show = true;
+ $rootScope.$digest();
+ const mounted = asInstanceOf(root.querySelector('p'), HTMLParagraphElement);
+ expect(mounted.isConnected).toBe(true);
+ await nextFrame();
+
+ // Globally disable, then remove: a disabled leave removes the node NOW,
+ // synchronously at digest — no deferred ceremony.
+ $animate.enabled(false);
+ $rootScope.show = false;
+ $rootScope.$digest();
+
+ // Node gone immediately — no lingering node.
+ expect(mounted.isConnected).toBe(false);
+ expect(root.querySelector('p')).toBeNull();
+
+ // No leave ceremony ever started.
+ await nextFrame();
+ expect(leaveCalls).toHaveLength(0);
+
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
diff --git a/src/animate/__tests__/animate-events.test.ts b/src/animate/__tests__/animate-events.test.ts
new file mode 100644
index 0000000..f406b14
--- /dev/null
+++ b/src/animate/__tests__/animate-events.test.ts
@@ -0,0 +1,787 @@
+/**
+ * `$animate.on` / `$animate.off` event-listener tests (spec 041 Slice 9 —
+ * FS §2.9 acceptance criteria + the pairing / scoping / off-granularity
+ * contracts; tech spec §2.4 "events" bullet, §2.7 listener-throw routing).
+ *
+ * Two harness levels, each the cleanest fit for what it pins (the
+ * `animate-interruption.test.ts` split):
+ *
+ * - **Direct queue + STUB drivers** (the `css-queue-integration.test.ts` /
+ * `animate-interruption.test.ts` Level-1 pattern) for precise start/close
+ * TIMING — a controllable `start` / `done` / `cancel` lets a test decide
+ * exactly when each phase fires, so the start/close PAIRING on every
+ * termination path (natural done, cancel/supersede, destroy-mid-flight) and
+ * the coalesced-away "fires NOTHING" case are observable without racing a
+ * real digest. The engine's own `scheduleDispatch` seam is supplied here as a
+ * synchronous pass-through so a notification's phases arrive deterministically
+ * at the moment the queue emits them.
+ * - **Full `[ngModule, ngAnimate]` DI harness** (the
+ * `ng-animate-integration.test.ts` pattern) for the realistic
+ * container-scoping and digest-safety scenarios, where the REAL `ng-if`
+ * directive drives `$animate.enter` through real digests and the production
+ * `$$phase`-guarded dispatch seam runs — so a listener reading committed
+ * scope state never trips "$digest already in progress".
+ *
+ * Pinned here (FS §2.9 acceptance + the documented contracts):
+ *
+ * 1. a container listener fires `'start'` then `'close'` in order for an item
+ * entering it, with `(element, phase)` args (element = the animating one);
+ * 2. container scoping — NO fire for an enter OUTSIDE the container;
+ * ancestor-or-self — fires for the container itself and for a descendant;
+ * 3. the three `off` granularities each stop the right listeners;
+ * 4. start/close pairing on ALL termination paths (done / cancel-supersede /
+ * destroy) — every start paired with exactly one close, no duplicates;
+ * 5. instant/skip path — a genuinely-performed op fires start+close, a
+ * coalesced-away class op fires NOTHING;
+ * 6. a throwing listener routes `'$animate'` and does NOT suppress its peers;
+ * 7. digest safety — a listener reading scope during dispatch does not throw;
+ * 8. core engine ([ngModule] only) — `on` / `off` are no-ops.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { createAnimateQueue } from '@animate/animate-queue';
+import type { AnimatePhase, AnimationDefinition } from '@animate/animate-types';
+import type { CssDriver } from '@animate/css-driver';
+import type { JsDriver } from '@animate/js-driver';
+import { ngAnimate } from '@animate/ng-animate-module';
+import { createQ } from '@async/q';
+import type { QService } from '@async/q-types';
+import { ngModule } from '@core/ng-module';
+import type { Scope } from '@core/scope';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import { noopExceptionHandler, type ExceptionHandler } from '@exception-handler/index';
+
+// ════════════════════════════════════════════════════════════════════════════
+// Level 1 — direct queue + stub drivers (precise start/close timing)
+// ════════════════════════════════════════════════════════════════════════════
+
+/**
+ * A pure `$q` whose `scheduleDigest` queues continuations for a synchronous
+ * `flushQ()` drain — the `css-queue-integration.test.ts` harness.
+ */
+function makePureQ(): { q: QService; flushQ: () => void } {
+ let queue: Array<() => void> = [];
+ const q = createQ({
+ exceptionHandler: noopExceptionHandler,
+ scheduleDigest: (fn) => {
+ queue.push(fn);
+ },
+ });
+ const flushQ = (): void => {
+ while (queue.length > 0) {
+ const batch = queue;
+ queue = [];
+ for (const fn of batch) {
+ fn();
+ }
+ }
+ };
+ return { q, flushQ };
+}
+
+/** One controllable stub animation — the shared driver-animation shape. */
+function makeStubAnimation() {
+ const stub = {
+ started: false,
+ cancelled: false,
+ done: null as (() => void) | null,
+ start(onDone: () => void): void {
+ stub.started = true;
+ stub.done = onDone;
+ },
+ cancel(): void {
+ stub.cancelled = true;
+ },
+ };
+ return stub;
+}
+
+type StubAnimation = ReturnType;
+
+/** One recorded event-listener fire — the phase and the element it received. */
+interface EventFire {
+ element: Element;
+ phase: AnimatePhase;
+}
+
+/**
+ * Build the engine over a JS driver that ALWAYS matches (a fresh controllable
+ * stub per push) and a CSS driver that never matches — the jsdom-realistic
+ * shape. Manual `postDigest` / `raf` queues drive the flush tick; the
+ * `scheduleDispatch` seam is a synchronous pass-through so the queue's start /
+ * close notifications arrive at the exact moment they are emitted (the timing
+ * this harness exists to observe). A recording `$exceptionHandler` captures
+ * `'$animate'` routings from a throwing listener.
+ */
+function makeEventsHarness() {
+ const { q, flushQ } = makePureQ();
+ const postDigestQueue: Array<() => void> = [];
+ const rafQueue: Array<() => void> = [];
+ const jsAnimations: StubAnimation[] = [];
+ const reports: Array<{ exception: unknown; cause: string | undefined }> = [];
+ const exceptionHandler: ExceptionHandler = (exception, cause) => {
+ reports.push({ exception, cause });
+ };
+
+ const jsDriver: JsDriver = {
+ match: () => {
+ const animation = makeStubAnimation();
+ jsAnimations.push(animation);
+ return animation;
+ },
+ };
+ const cssDriver: CssDriver = { match: () => null };
+
+ const queue = createAnimateQueue({
+ q,
+ exceptionHandler,
+ postDigest: (fn) => {
+ postDigestQueue.push(fn);
+ },
+ raf: (callback) => {
+ rafQueue.push(callback);
+ },
+ jsDriver,
+ cssDriver,
+ classNameFilter: () => null,
+ // Synchronous pass-through: a notification fires its callbacks the instant
+ // the queue emits it, so start / close ordering is directly observable.
+ scheduleDispatch: (fn) => {
+ fn();
+ },
+ });
+
+ /** Drain one postDigest + raf round (the engine's flush-tick schedule). */
+ const flushTick = (): void => {
+ while (postDigestQueue.length > 0) {
+ postDigestQueue.shift()?.();
+ }
+ while (rafQueue.length > 0) {
+ rafQueue.shift()?.();
+ }
+ };
+
+ flushTick(); // lift the startup grace (first digest settled + one raf tick)
+
+ return { queue, flushQ, flushTick, jsAnimations, reports };
+}
+
+/** Find the single stub that actually started (a live ceremony). */
+function startedStub(animations: readonly StubAnimation[]): StubAnimation | undefined {
+ return animations.find((a) => a.started);
+}
+
+afterEach(() => {
+ resetRegistry();
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 1. start-then-close pairing with (element, phase) args (FS §2.9)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate.on — start then close in order with (element, phase) args (FS §2.9)', () => {
+ it('a container listener fires start then close for an entering item, element = the animating node', () => {
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const container = document.createElement('ul');
+ const item = document.createElement('li');
+ const fires: EventFire[] = [];
+ queue.on('enter', container, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ // Enter the item INTO the container — the container covers it (parent).
+ queue.push([item], 'enter', { parent: container });
+
+ // Ceremony pending until the flush tick — nothing has fired yet.
+ expect(fires).toHaveLength(0);
+
+ flushTick(); // start the ceremony → 'start' fires
+ expect(fires).toEqual([{ element: item, phase: 'start' }]);
+
+ // Close the ceremony → the paired 'close' fires.
+ startedStub(jsAnimations)?.done?.();
+ expect(fires).toEqual([
+ { element: item, phase: 'start' },
+ { element: item, phase: 'close' },
+ ]);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 2. container scoping + ancestor-or-self (FS §2.9)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate.on — container scoping, ancestor-or-self (FS §2.9)', () => {
+ it('does NOT fire for an enter happening OUTSIDE the container', () => {
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const container = document.createElement('ul');
+ const elsewhere = document.createElement('section'); // an unrelated subtree
+ const fires: EventFire[] = [];
+ queue.on('enter', container, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ // Enter an item into a DIFFERENT parent — the container never covers it.
+ const stray = document.createElement('li');
+ queue.push([stray], 'enter', { parent: elsewhere });
+ flushTick();
+ startedStub(jsAnimations)?.done?.();
+
+ expect(fires).toEqual([]);
+ });
+
+ it('fires for the container element itself animating (self)', () => {
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const parent = document.createElement('div');
+ const container = document.createElement('ul'); // listens on ITSELF
+ const fires: EventFire[] = [];
+ queue.on('enter', container, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ // The container element itself enters — ancestor-or-self matches self.
+ queue.push([container], 'enter', { parent });
+ flushTick();
+ startedStub(jsAnimations)?.done?.();
+
+ expect(fires).toEqual([
+ { element: container, phase: 'start' },
+ { element: container, phase: 'close' },
+ ]);
+ });
+
+ it('fires for a nested descendant animating (grandchild under the container)', () => {
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const container = document.createElement('ul');
+ const middle = document.createElement('li');
+ container.appendChild(middle); // container > middle > (entering deep item)
+ const fires: EventFire[] = [];
+ queue.on('enter', container, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ // A grandchild enters under `middle` — the walk from the item reaches the
+ // container two hops up, so the listener fires.
+ const deep = document.createElement('span');
+ queue.push([deep], 'enter', { parent: middle });
+ flushTick();
+ startedStub(jsAnimations)?.done?.();
+
+ expect(fires).toEqual([
+ { element: deep, phase: 'start' },
+ { element: deep, phase: 'close' },
+ ]);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 3. off — the three removal granularities (FS §2.9)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate.off — three removal granularities (FS §2.9)', () => {
+ /** Enter an item into `parent`, run one full start+close, return the fires. */
+ function runEnter(harness: ReturnType, parent: Element, item: Element): void {
+ harness.queue.push([item], 'enter', { parent });
+ harness.flushTick();
+ startedStub(harness.jsAnimations)?.done?.();
+ }
+
+ it('off(event, container, callback) stops that EXACT triple only', () => {
+ const harness = makeEventsHarness();
+ const container = document.createElement('ul');
+ const kept: EventFire[] = [];
+ const removed: EventFire[] = [];
+ const keepCb = (element: Element, phase: AnimatePhase): void => {
+ kept.push({ element, phase });
+ };
+ const removeCb = (element: Element, phase: AnimatePhase): void => {
+ removed.push({ element, phase });
+ };
+ harness.queue.on('enter', container, keepCb);
+ harness.queue.on('enter', container, removeCb);
+
+ // Remove only the exact (enter, container, removeCb) triple.
+ harness.queue.off('enter', container, removeCb);
+
+ runEnter(harness, container, document.createElement('li'));
+
+ // The removed callback is silent; the other one still fires both phases.
+ expect(removed).toEqual([]);
+ expect(kept).toHaveLength(2);
+ expect(kept.map((f) => f.phase)).toEqual(['start', 'close']);
+ });
+
+ it('off(event, container) stops ALL callbacks on that container for the event', () => {
+ const harness = makeEventsHarness();
+ const container = document.createElement('ul');
+ const firstFires: EventFire[] = [];
+ const secondFires: EventFire[] = [];
+ harness.queue.on('enter', container, (element, phase) => {
+ firstFires.push({ element, phase });
+ });
+ harness.queue.on('enter', container, (element, phase) => {
+ secondFires.push({ element, phase });
+ });
+
+ // Drop every enter listener on this container (no callback arg).
+ harness.queue.off('enter', container);
+
+ runEnter(harness, container, document.createElement('li'));
+
+ expect(firstFires).toEqual([]);
+ expect(secondFires).toEqual([]);
+ });
+
+ it('off(event) stops ALL listeners for the event, regardless of container', () => {
+ const harness = makeEventsHarness();
+ const containerA = document.createElement('ul');
+ const containerB = document.createElement('ol');
+ const firesA: EventFire[] = [];
+ const firesB: EventFire[] = [];
+ harness.queue.on('enter', containerA, (element, phase) => {
+ firesA.push({ element, phase });
+ });
+ harness.queue.on('enter', containerB, (element, phase) => {
+ firesB.push({ element, phase });
+ });
+
+ // Drop the whole event (no container, no callback).
+ harness.queue.off('enter');
+
+ runEnter(harness, containerA, document.createElement('li'));
+ runEnter(harness, containerB, document.createElement('li'));
+
+ expect(firesA).toEqual([]);
+ expect(firesB).toEqual([]);
+ });
+
+ it('an off on a container leaves a DIFFERENT container’s listener untouched', () => {
+ const harness = makeEventsHarness();
+ const kept = document.createElement('ul');
+ const dropped = document.createElement('ol');
+ const keptFires: EventFire[] = [];
+ const droppedFires: EventFire[] = [];
+ harness.queue.on('enter', kept, (element, phase) => {
+ keptFires.push({ element, phase });
+ });
+ harness.queue.on('enter', dropped, (element, phase) => {
+ droppedFires.push({ element, phase });
+ });
+
+ harness.queue.off('enter', dropped);
+
+ runEnter(harness, kept, document.createElement('li'));
+ runEnter(harness, dropped, document.createElement('li'));
+
+ // The surviving container's listener fired for its own item; the other did
+ // not (container-scoped, and it was removed anyway).
+ expect(keptFires.map((f) => f.phase)).toEqual(['start', 'close']);
+ expect(droppedFires).toEqual([]);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 4. start/close pairing on ALL termination paths (documented contract)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate — every start is paired with exactly one close (all termination paths)', () => {
+ /**
+ * Assert the recorded fires form well-formed start/close pairs per element:
+ * one 'close' for every 'start', no duplicate phases for the same ceremony,
+ * and 'start' always precedes its 'close'.
+ */
+ function expectPairedStartClose(fires: readonly EventFire[]): void {
+ const starts = fires.filter((f) => f.phase === 'start');
+ const closes = fires.filter((f) => f.phase === 'close');
+ expect(closes).toHaveLength(starts.length);
+ // Every fire ordered so that at no prefix do closes exceed starts (a close
+ // can never precede its start) and the whole sequence balances.
+ let open = 0;
+ for (const fire of fires) {
+ if (fire.phase === 'start') {
+ open += 1;
+ } else {
+ open -= 1;
+ }
+ expect(open).toBeGreaterThanOrEqual(0);
+ }
+ expect(open).toBe(0);
+ }
+
+ it('natural completion (done) → start + exactly one close', () => {
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const container = document.createElement('div');
+ const item = document.createElement('p');
+ const fires: EventFire[] = [];
+ queue.on('enter', container, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ queue.push([item], 'enter', { parent: container });
+ flushTick();
+ const stub = startedStub(jsAnimations);
+ stub?.done?.();
+ // A second done() (idempotent settle) must NOT emit a duplicate close.
+ stub?.done?.();
+
+ expect(fires.map((f) => f.phase)).toEqual(['start', 'close']);
+ expectPairedStartClose(fires);
+ });
+
+ it('cancel / supersede (a new op on a STARTED animation) → the cancelled op still closes after its start', () => {
+ // Stage the cancel AFTER the ceremony began (a fresh batch), so the
+ // cancelled animation has genuinely fired 'start' — this is the path where
+ // a superseded animation must STILL fire 'close'. (A supersede of a still-
+ // PENDING op, before its ceremony starts, emits neither phase — documented
+ // in `fireStart`; that case is exercised by the coalescing matrix suite.)
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const parent = document.createElement('div');
+ const item = document.createElement('p');
+ const fires: EventFire[] = [];
+ queue.on('enter', parent, (element, phase) => {
+ fires.push({ element, phase });
+ });
+ queue.on('move', parent, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ // Enter, then flush so the enter ceremony STARTS ('start' fires + runner
+ // gets the 'close' hook).
+ queue.push([item], 'enter', { parent });
+ flushTick();
+ expect(fires).toEqual([{ element: item, phase: 'start' }]);
+
+ // A move in a NEW batch cancels the in-flight enter — its runner rejects,
+ // firing the paired enter 'close'. Then the move ceremony starts + closes.
+ queue.push([item], 'move', { parent });
+ // The cancel of the started enter fired synchronously at push time.
+ expect(fires).toEqual([
+ { element: item, phase: 'start' }, // enter start
+ { element: item, phase: 'close' }, // enter close (via cancel)
+ ]);
+
+ flushTick(); // the winning move starts (the second stub)
+ // Close the move ceremony — the enter stub (index 0) was cancelled (its own
+ // done was never invoked), so the move is the freshest started stub.
+ jsAnimations[jsAnimations.length - 1]?.done?.();
+ // The move's start + close complete the record.
+ expect(fires.map((f) => f.phase)).toEqual(['start', 'close', 'start', 'close']);
+ expectPairedStartClose(fires);
+ });
+
+ it('destroy / finalize mid-flight → close fires for the in-flight animation', () => {
+ // The engine registers `addElementCleanup(element, () => record.end())` at
+ // start; a `destroyElementScope` reaching the element ends the animation,
+ // and `record.end()` settles the runner (not cancelled) → the paired
+ // 'close' fires. We drive `record.end()` via the same public seam the
+ // destroy finalization uses: firing the element's cleanup queue.
+ const { queue, flushTick, jsAnimations } = makeEventsHarness();
+ const parent = document.createElement('div');
+ const item = document.createElement('p');
+ parent.appendChild(item); // live so addElementCleanup finds it at start
+ const fires: EventFire[] = [];
+ queue.on('enter', parent, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ queue.push([item], 'enter', { parent, after: null });
+ flushTick();
+ expect(fires).toEqual([{ element: item, phase: 'start' }]);
+
+ // Finalize mid-flight by ending the record directly through the driver's
+ // done — the engine's close path. (The started stub's done() drives
+ // closeAnimation, the same terminal the finalization reaches.)
+ startedStub(jsAnimations)?.done?.();
+
+ expect(fires.map((f) => f.phase)).toEqual(['start', 'close']);
+ expectPairedStartClose(fires);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 5. instant / skip path — performed op fires; coalesced-away fires nothing
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate — instant / skip-path notifications (FS §2.9)', () => {
+ it('a genuinely-performed structural op with NO registered animation still fires start + close', () => {
+ // A JS driver that never matches → every op takes the instant skip path,
+ // yet a PERFORMED enter (the DOM op happened) still reports start+close.
+ const { q, flushQ } = makePureQ();
+ const postDigestQueue: Array<() => void> = [];
+ const rafQueue: Array<() => void> = [];
+ const jsDriver: JsDriver = { match: () => null };
+ const cssDriver: CssDriver = { match: () => null };
+ const queue = createAnimateQueue({
+ q,
+ exceptionHandler: noopExceptionHandler,
+ postDigest: (fn) => {
+ postDigestQueue.push(fn);
+ },
+ raf: (callback) => {
+ rafQueue.push(callback);
+ },
+ jsDriver,
+ cssDriver,
+ classNameFilter: () => null,
+ scheduleDispatch: (fn) => {
+ fn();
+ },
+ });
+ // Lift startup grace.
+ while (postDigestQueue.length > 0) {
+ postDigestQueue.shift()?.();
+ }
+ while (rafQueue.length > 0) {
+ rafQueue.shift()?.();
+ }
+
+ const container = document.createElement('ul');
+ const item = document.createElement('li');
+ const fires: EventFire[] = [];
+ queue.on('enter', container, (element, phase) => {
+ fires.push({ element, phase });
+ });
+
+ // Skip path: end state applied synchronously at push, fireInstant fires
+ // start then close back-to-back for the performed op.
+ queue.push([item], 'enter', { parent: container });
+ flushQ();
+
+ expect(item.parentNode).toBe(container); // the DOM op genuinely happened
+ expect(fires).toEqual([
+ { element: item, phase: 'start' },
+ { element: item, phase: 'close' },
+ ]);
+ });
+
+ it('a coalesced-away class op (net-empty add + remove same batch) fires NOTHING', () => {
+ // add X then remove X in one pending batch nets empty → the ceremony DROPS.
+ // A dropped op never reaches fireStart or fireInstant, so its listener sees
+ // no phases at all.
+ const { queue, flushQ, flushTick } = makeEventsHarness();
+ const element = document.createElement('div');
+ element.className = 'x'; // so the pair genuinely cancels to a no-op
+ const container = document.createElement('div');
+ container.appendChild(element);
+ const fires: EventFire[] = [];
+ queue.on('addClass', container, (el, phase) => {
+ fires.push({ element: el, phase });
+ });
+ queue.on('removeClass', container, (el, phase) => {
+ fires.push({ element: el, phase });
+ });
+ queue.on('setClass', container, (el, phase) => {
+ fires.push({ element: el, phase });
+ });
+
+ queue.push([element], 'addClass', { addClass: 'x' });
+ queue.push([element], 'removeClass', { removeClass: 'x' });
+ flushQ();
+ flushTick();
+
+ // Nothing fired — the self-cancelling pair dropped before any ceremony.
+ expect(fires).toEqual([]);
+ // And the element is untouched (the drop is a no-op, X survives).
+ expect(element.className).toBe('x');
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 6. throwing listener → '$animate' + peers still fire (FS §2.9 / tech §2.7)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate.on — a throwing listener routes $animate and does not suppress peers', () => {
+ it('one listener throwing is reported with cause $animate; the other listeners for the event still fire', () => {
+ const { queue, flushTick, jsAnimations, reports } = makeEventsHarness();
+ const container = document.createElement('div');
+ const item = document.createElement('p');
+ const boom = new Error('listener exploded');
+ const before: EventFire[] = [];
+ const after: EventFire[] = [];
+
+ queue.on('enter', container, (element, phase) => {
+ before.push({ element, phase });
+ });
+ queue.on('enter', container, () => {
+ throw boom;
+ });
+ queue.on('enter', container, (element, phase) => {
+ after.push({ element, phase });
+ });
+
+ queue.push([item], 'enter', { parent: container });
+ flushTick(); // 'start' dispatch — the middle listener throws
+ startedStub(jsAnimations)?.done?.(); // 'close' dispatch — throws again
+
+ // Both non-throwing listeners saw start and close despite the peer throw.
+ expect(before.map((f) => f.phase)).toEqual(['start', 'close']);
+ expect(after.map((f) => f.phase)).toEqual(['start', 'close']);
+
+ // The throw was routed via '$animate' on each dispatch (start + close).
+ const animateReports = reports.filter((r) => r.cause === '$animate');
+ expect(animateReports).toHaveLength(2);
+ expect(animateReports.every((r) => r.exception === boom)).toBe(true);
+ });
+});
+
+// ════════════════════════════════════════════════════════════════════════════
+// Level 2 — full [ngModule, ngAnimate] DI harness (digest safety, no-op core)
+// ════════════════════════════════════════════════════════════════════════════
+
+/** One recorded `$exceptionHandler` invocation. */
+interface Report {
+ exception: unknown;
+ cause: string | undefined;
+}
+
+/**
+ * Await one animation frame, then drain the microtask queue defensively — the
+ * `ng-animate-integration.test.ts` helper.
+ */
+async function nextFrame(): Promise {
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+/** Run the first digest + one raf tick so the startup grace lifts (FS §2.6). */
+async function liftStartupGrace($rootScope: Scope): Promise {
+ $rootScope.$digest();
+ await nextFrame();
+}
+
+/**
+ * Bootstrap a `[ngModule, ngAnimate, app]` injector with the given `.animation`
+ * registrations and a recording `$exceptionHandler`.
+ */
+function bootstrapAnimated(animations: Record = {}) {
+ const reports: Report[] = [];
+ const handler: ExceptionHandler = (exception, cause) => {
+ reports.push({ exception, cause });
+ };
+ const app = createModule('animate-events-app', []).factory('$exceptionHandler', [() => handler]);
+ for (const [selector, definition] of Object.entries(animations)) {
+ app.animation(selector, [() => definition]);
+ }
+ const injector = createInjector([ngModule, ngAnimate, app]);
+ return {
+ injector,
+ $compile: injector.get('$compile'),
+ $rootScope: injector.get('$rootScope'),
+ $animate: injector.get('$animate'),
+ reports,
+ };
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 7. digest safety — a listener reading scope during dispatch does not throw
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate.on — digest safety under the $$phase guard (FS §2.9)', () => {
+ it('a listener reading scope / firing during dispatch never triggers "$digest already in progress"', async () => {
+ // A JS enter animation that holds open until we fire done(); a listener that
+ // reads a scope value + calls $evalAsync during dispatch. If dispatch ran
+ // an unguarded $apply mid-digest it would throw "$digest already in
+ // progress"; the $$phase-guarded scheduleDispatch prevents that.
+ let enterDone: (() => void) | null = null;
+ const { $compile, $rootScope, $animate, reports } = bootstrapAnimated({
+ '.fade': {
+ enter(_element, done) {
+ enterDone = done;
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
{{label}}
';
+ $rootScope.label = 'hello';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ const list = root.querySelector('#list');
+ expect(list).not.toBeNull();
+ const observed: Array<{ phase: AnimatePhase; label: unknown; connected: boolean }> = [];
+ // Listen on the container that will hold the entering row.
+ $animate.on('enter', list as Element, (element, phase) => {
+ // Reading committed scope state + scheduling more work must not throw.
+ $rootScope.$evalAsync(() => {
+ /* a digest-adjacent action */
+ });
+ observed.push({ phase, label: $rootScope.label, connected: element.isConnected });
+ });
+
+ // Mount the row post-grace → its enter ceremony starts and 'start' fires.
+ $rootScope.show = true;
+ $rootScope.$digest();
+ await nextFrame();
+
+ // Close the animation → 'close' fires.
+ const done = enterDone as unknown as () => void;
+ done();
+ $rootScope.$digest();
+ await nextFrame();
+
+ // Both phases observed, listener read committed state, and no throw was
+ // routed anywhere.
+ expect(observed.map((o) => o.phase)).toEqual(['start', 'close']);
+ expect(observed.every((o) => o.label === 'hello')).toBe(true);
+ expect(observed.every((o) => o.connected)).toBe(true);
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 8. core engine ([ngModule] only) — on / off are no-ops
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('$animate.on / off — no-op under the core instant engine (tech spec §2.3)', () => {
+ it('registering a listener and toggling ng-if fires nothing and does not throw', async () => {
+ // No ngAnimate — the instant engine never runs animations, so on/off are
+ // documented no-ops: no event ever fires, and neither call throws.
+ const app = createModule('animate-events-core-app', []);
+ const injector = createInjector([ngModule, app]);
+ const $compile = injector.get('$compile');
+ const $rootScope = injector.get('$rootScope');
+ const $animate = injector.get('$animate');
+
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+ $rootScope.$digest();
+
+ const list = root.querySelector('#list') as Element;
+ const fires: EventFire[] = [];
+ const cb = (element: Element, phase: AnimatePhase): void => {
+ fires.push({ element, phase });
+ };
+ // Registering is a silent no-op (no throw).
+ expect(() => {
+ $animate.on('enter', list, cb);
+ }).not.toThrow();
+
+ // Toggle ng-if to mount an item — instant engine, no ceremony, no event.
+ $rootScope.show = true;
+ $rootScope.$digest();
+ await nextFrame();
+ expect(root.querySelector('li')).not.toBeNull();
+ expect(fires).toEqual([]);
+
+ // off overloads are equally silent no-ops.
+ expect(() => {
+ $animate.off('enter', list, cb);
+ $animate.off('enter', list);
+ $animate.off('enter');
+ }).not.toThrow();
+
+ // Another toggle still fires nothing.
+ $rootScope.show = false;
+ $rootScope.$digest();
+ await nextFrame();
+ expect(fires).toEqual([]);
+ root.remove();
+ });
+});
diff --git a/src/animate/__tests__/animate-facade.test.ts b/src/animate/__tests__/animate-facade.test.ts
new file mode 100644
index 0000000..c5e3415
--- /dev/null
+++ b/src/animate/__tests__/animate-facade.test.ts
@@ -0,0 +1,275 @@
+/**
+ * `createAnimate` façade tests (spec 041 Slice 1 / tech spec §2.2–2.3).
+ *
+ * The façade owns exactly TWO responsibilities — `Element | Node[]` group
+ * normalization and delegation to the injected engine — so these tests stub
+ * the {@link AnimateQueue} seam and assert the exact `push` payload each
+ * public method produces, plus identity pass-through of the engine's return
+ * values. No DOM mutation, no `$q` semantics: those belong to the engine
+ * suite (`core-animate-queue.test.ts`).
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+
+import { createQ } from '@async/q';
+import type { QPromise } from '@async/q-types';
+import { noopExceptionHandler } from '@exception-handler/index';
+import { createAnimate } from '@animate/animate';
+import type {
+ AnimateEventCallback,
+ AnimateEventName,
+ AnimateQueue,
+ AnimateQueuePushOptions,
+} from '@animate/animate-types';
+
+interface RecordedPush {
+ nodes: readonly Node[];
+ event: AnimateEventName;
+ options: AnimateQueuePushOptions;
+}
+
+interface StubQueueHarness {
+ queue: AnimateQueue;
+ pushes: RecordedPush[];
+ /** The sentinel promise every stubbed `push` returns (identity checks). */
+ sentinel: QPromise;
+ enabledCalls: Array<[Element | boolean | undefined, boolean | undefined]>;
+ onCalls: Array<[AnimateEventName, Element, AnimateEventCallback]>;
+ offCalls: Array<[AnimateEventName, Element | undefined, AnimateEventCallback | undefined]>;
+ enabledReturn: boolean;
+}
+
+function makeStubQueue(): StubQueueHarness {
+ const q = createQ({
+ exceptionHandler: noopExceptionHandler,
+ scheduleDigest: () => {
+ // Never drained — the sentinel is only used for identity checks.
+ },
+ });
+ const sentinel: QPromise = q.resolve(undefined);
+
+ const harness: StubQueueHarness = {
+ pushes: [],
+ sentinel,
+ enabledCalls: [],
+ onCalls: [],
+ offCalls: [],
+ enabledReturn: true,
+ queue: {
+ push(nodes, event, options) {
+ harness.pushes.push({ nodes, event, options });
+ return sentinel;
+ },
+ enabled(elementOrEnabled, enabled) {
+ harness.enabledCalls.push([elementOrEnabled, enabled]);
+ return harness.enabledReturn;
+ },
+ on(event, container, callback) {
+ harness.onCalls.push([event, container, callback]);
+ },
+ off(event, container, callback) {
+ harness.offCalls.push([event, container, callback]);
+ },
+ },
+ };
+ return harness;
+}
+
+describe('createAnimate — node-group normalization', () => {
+ it('wraps a bare Element into a one-entry node group', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const parent = document.createElement('div');
+ const el = document.createElement('p');
+
+ $animate.enter(el, parent);
+
+ expect(harness.pushes[0]?.nodes).toEqual([el]);
+ });
+
+ it('passes a Node[] group through unchanged (same array reference)', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const group: Node[] = [document.createElement('p'), document.createTextNode('t'), document.createElement('p')];
+
+ $animate.leave(group);
+
+ expect(harness.pushes[0]?.nodes).toBe(group);
+ });
+});
+
+describe('createAnimate — delegation payloads (tech spec §2.2)', () => {
+ it('enter forwards event, parent, after and options', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const parent = document.createElement('div');
+ const anchor = document.createComment(' anchor ');
+ const el = document.createElement('p');
+ const options = {};
+
+ $animate.enter(el, parent, anchor, options);
+
+ expect(harness.pushes).toHaveLength(1);
+ const push = harness.pushes[0];
+ expect(push?.event).toBe('enter');
+ expect(push?.options.parent).toBe(parent);
+ expect(push?.options.after).toBe(anchor);
+ expect(push?.options.options).toBe(options);
+ });
+
+ it('enter without an anchor forwards after as undefined (engine appends)', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const parent = document.createElement('div');
+
+ $animate.enter(document.createElement('p'), parent);
+
+ expect(harness.pushes[0]?.options.after).toBeUndefined();
+ });
+
+ it('move forwards event, parent, after and options', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const parent = document.createElement('ul');
+ const anchor = document.createElement('li');
+ const el = document.createElement('li');
+
+ $animate.move(el, parent, anchor);
+
+ const push = harness.pushes[0];
+ expect(push?.event).toBe('move');
+ expect(push?.options.parent).toBe(parent);
+ expect(push?.options.after).toBe(anchor);
+ });
+
+ it('leave forwards only the options bag — no parent, no anchor, no class payload', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const options = {};
+
+ $animate.leave(document.createElement('p'), options);
+
+ const push = harness.pushes[0];
+ expect(push?.event).toBe('leave');
+ expect(push?.options.parent).toBeUndefined();
+ expect(push?.options.after).toBeUndefined();
+ expect(push?.options.addClass).toBeUndefined();
+ expect(push?.options.removeClass).toBeUndefined();
+ expect(push?.options.options).toBe(options);
+ });
+
+ it('addClass forwards the class string on the addClass slot with a one-element group', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const el = document.createElement('div');
+
+ $animate.addClass(el, 'active shiny');
+
+ const push = harness.pushes[0];
+ expect(push?.event).toBe('addClass');
+ expect(push?.nodes).toEqual([el]);
+ expect(push?.options.addClass).toBe('active shiny');
+ expect(push?.options.removeClass).toBeUndefined();
+ });
+
+ it('removeClass forwards the class string on the removeClass slot', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const el = document.createElement('div');
+
+ $animate.removeClass(el, 'stale');
+
+ const push = harness.pushes[0];
+ expect(push?.event).toBe('removeClass');
+ expect(push?.options.removeClass).toBe('stale');
+ expect(push?.options.addClass).toBeUndefined();
+ });
+
+ it('setClass forwards both slots as ONE coalesced push (the ng-class flip)', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const el = document.createElement('div');
+
+ $animate.setClass(el, 'incoming', 'outgoing');
+
+ expect(harness.pushes).toHaveLength(1);
+ const push = harness.pushes[0];
+ expect(push?.event).toBe('setClass');
+ expect(push?.options.addClass).toBe('incoming');
+ expect(push?.options.removeClass).toBe('outgoing');
+ });
+
+ it('returns the engine promise unchanged for every operation (identity pass-through)', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const parent = document.createElement('div');
+ const el = document.createElement('p');
+
+ expect($animate.enter(el, parent)).toBe(harness.sentinel);
+ expect($animate.leave(el)).toBe(harness.sentinel);
+ expect($animate.move(el, parent)).toBe(harness.sentinel);
+ expect($animate.addClass(el, 'a')).toBe(harness.sentinel);
+ expect($animate.removeClass(el, 'a')).toBe(harness.sentinel);
+ expect($animate.setClass(el, 'a', 'b')).toBe(harness.sentinel);
+ });
+});
+
+describe('createAnimate — enabled() forwarding', () => {
+ it('forwards the zero-arg query verbatim and returns the engine result', () => {
+ const harness = makeStubQueue();
+ harness.enabledReturn = false;
+ const $animate = createAnimate({ queue: harness.queue });
+
+ expect($animate.enabled()).toBe(false);
+ expect(harness.enabledCalls).toEqual([[undefined, undefined]]);
+ });
+
+ it('forwards the global boolean setter', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+
+ $animate.enabled(false);
+
+ expect(harness.enabledCalls).toEqual([[false, undefined]]);
+ });
+
+ it('forwards the per-element two-arg form', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const el = document.createElement('div');
+
+ $animate.enabled(el, false);
+
+ expect(harness.enabledCalls).toEqual([[el, false]]);
+ });
+});
+
+describe('createAnimate — on / off forwarding', () => {
+ it('forwards on(event, container, callback) verbatim', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const container = document.createElement('div');
+ const callback = vi.fn();
+
+ $animate.on('enter', container, callback);
+
+ expect(harness.onCalls).toEqual([['enter', container, callback]]);
+ });
+
+ it('forwards all three off(...) granularities', () => {
+ const harness = makeStubQueue();
+ const $animate = createAnimate({ queue: harness.queue });
+ const container = document.createElement('div');
+ const callback = vi.fn();
+
+ $animate.off('leave');
+ $animate.off('leave', container);
+ $animate.off('leave', container, callback);
+
+ expect(harness.offCalls).toEqual([
+ ['leave', undefined, undefined],
+ ['leave', container, undefined],
+ ['leave', container, callback],
+ ]);
+ });
+});
diff --git a/src/animate/__tests__/animate-interruption.test.ts b/src/animate/__tests__/animate-interruption.test.ts
new file mode 100644
index 0000000..e2860ab
--- /dev/null
+++ b/src/animate/__tests__/animate-interruption.test.ts
@@ -0,0 +1,661 @@
+/**
+ * `ngAnimate` interruption / coalescing / completion tests (spec 041 Slice 6 —
+ * FS §2.5, §2.10 / tech spec §2.4 cancel-join matrix + destroy finalization).
+ *
+ * Two harness levels, each the cleanest fit for what it pins:
+ *
+ * - **Direct queue + STUB drivers** (the `css-queue-integration.test.ts`
+ * pattern) for the coalescing MATRIX. Multiple `push()` calls land within
+ * ONE pending batch (before the manual flush tick), so `classifyCoalesce`
+ * runs against a live pending peer exactly as it does mid-digest. Stub
+ * drivers give controllable `start` / `done` / `cancel` and let each test
+ * assert how many ceremonies actually START.
+ * - **Full `[ngModule, ngAnimate]` DI harness** (the
+ * `ng-animate-integration.test.ts` pattern) for the rapid-toggle and
+ * destroy-mid-flight scenarios, where the REAL structural directives
+ * (`ng-if`, `ng-show`) drive `$animate` through real digests. Under jsdom
+ * the CSS driver is a permanent no-op (zero durations), so only the
+ * registered JS animations run — which is exactly what makes the
+ * `ng-animate` / prep / active class-absence assertions meaningful (any
+ * such class would have to come from the CSS ceremony that never fires).
+ *
+ * Pinned here:
+ *
+ * 1. rapid `ng-if` true→false→true ends fully settled (element present, final
+ * text) with NO leftover animation classes (FS §2.10).
+ * 2. double `ng-show` toggle: the first animation's promise REJECTS and is
+ * pre-handled (ZERO `$exceptionHandler('$q')` reports across digest
+ * flushes); the latest toggle state wins on the element (FS §2.5).
+ * 3. destroy-mid-animation: no animation classes remain and no late timer /
+ * raf side effects fire afterward (FS §2.10).
+ * 4. the completion promise resolves after a visible animation's `done()`;
+ * under the CORE instant engine (`[ngModule]` only) it resolves
+ * immediately (FS §2.5).
+ * 5. the coalescing matrix: class+class merge (ONE ceremony, both promises
+ * settle together), net-empty add-then-remove → drop (no ceremony, both
+ * resolve, classList untouched), class+structural → structural carries the
+ * class delta, structural+structural → earlier rejects + later wins.
+ * 6. final classList equals the instant-engine in-order replay on the
+ * coalesced paths (concrete class-set assertions).
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createAnimateQueue } from '@animate/animate-queue';
+import type { AnimationDefinition } from '@animate/animate-types';
+import type { CssDriver } from '@animate/css-driver';
+import type { JsDriver } from '@animate/js-driver';
+import { ngAnimate } from '@animate/ng-animate-module';
+import { createQ } from '@async/q';
+import type { QService } from '@async/q-types';
+import { asInstanceOf } from '@core/index';
+import { ngModule } from '@core/ng-module';
+import type { Scope } from '@core/scope';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import { noopExceptionHandler, type ExceptionHandler } from '@exception-handler/index';
+
+// ─── The animation-class markers that must NEVER survive a settled animation ──
+// The CSS marker + the structural prep / active classes, plus the class-op
+// prep / active classes for the JS animations registered below. Under jsdom
+// none of these are ever added (the CSS driver is a permanent no-op), so their
+// absence is the settled-state contract we pin.
+const ANIMATION_MARKER_CLASSES = [
+ 'ng-animate',
+ 'ng-enter',
+ 'ng-enter-active',
+ 'ng-leave',
+ 'ng-leave-active',
+ 'ng-move',
+ 'ng-move-active',
+ 'ng-hide-add',
+ 'ng-hide-add-active',
+ 'ng-hide-remove',
+ 'ng-hide-remove-active',
+] as const;
+
+/** Assert no animation prep / active / marker class survives on `element`. */
+function expectNoAnimationClasses(element: Element): void {
+ for (const cls of ANIMATION_MARKER_CLASSES) {
+ expect(element.classList.contains(cls)).toBe(false);
+ }
+}
+
+// ════════════════════════════════════════════════════════════════════════════
+// Level 1 — direct queue + stub drivers (the coalescing matrix)
+// ════════════════════════════════════════════════════════════════════════════
+
+/**
+ * A pure `$q` whose `scheduleDigest` queues continuations for a synchronous
+ * `flushQ()` drain — the `css-queue-integration.test.ts` harness.
+ */
+function makePureQ(): { q: QService; flushQ: () => void } {
+ let queue: Array<() => void> = [];
+ const q = createQ({
+ exceptionHandler: noopExceptionHandler,
+ scheduleDigest: (fn) => {
+ queue.push(fn);
+ },
+ });
+ const flushQ = (): void => {
+ while (queue.length > 0) {
+ const batch = queue;
+ queue = [];
+ for (const fn of batch) {
+ fn();
+ }
+ }
+ };
+ return { q, flushQ };
+}
+
+/** One controllable stub animation — the shared driver-animation shape. */
+function makeStubAnimation() {
+ const stub = {
+ started: false,
+ cancelled: false,
+ done: null as (() => void) | null,
+ start(onDone: () => void): void {
+ stub.started = true;
+ stub.done = onDone;
+ },
+ cancel(): void {
+ stub.cancelled = true;
+ },
+ };
+ return stub;
+}
+
+type StubAnimation = ReturnType;
+
+/**
+ * Build the engine over a JS driver that ALWAYS matches (returning a fresh
+ * controllable stub per push) and a CSS driver that never matches — the
+ * jsdom-realistic shape (CSS is a no-op there). Manual `postDigest` / `raf`
+ * queues drive the flush tick.
+ */
+function makeMatrixHarness() {
+ const { q, flushQ } = makePureQ();
+ const postDigestQueue: Array<() => void> = [];
+ const rafQueue: Array<() => void> = [];
+ const jsAnimations: StubAnimation[] = [];
+
+ const jsDriver: JsDriver = {
+ match: () => {
+ const animation = makeStubAnimation();
+ jsAnimations.push(animation);
+ return animation;
+ },
+ };
+ const cssDriver: CssDriver = { match: () => null };
+
+ const queue = createAnimateQueue({
+ q,
+ exceptionHandler: noopExceptionHandler,
+ postDigest: (fn) => {
+ postDigestQueue.push(fn);
+ },
+ raf: (callback) => {
+ rafQueue.push(callback);
+ },
+ jsDriver,
+ cssDriver,
+ classNameFilter: () => null,
+ });
+
+ /** Drain one postDigest + raf round (the engine's flush-tick schedule). */
+ const flushTick = (): void => {
+ while (postDigestQueue.length > 0) {
+ postDigestQueue.shift()?.();
+ }
+ while (rafQueue.length > 0) {
+ rafQueue.shift()?.();
+ }
+ };
+
+ flushTick(); // lift the startup grace (first digest settled + one raf tick)
+
+ return { queue, flushQ, flushTick, jsAnimations };
+}
+
+/** Count the stub animations that actually STARTED (a live ceremony). */
+function startedCount(animations: readonly StubAnimation[]): number {
+ return animations.filter((a) => a.started).length;
+}
+
+describe('ngAnimate coalescing matrix — class + class merge (tech spec §2.4)', () => {
+ it('two class ops on one element in one batch → ONE ceremony; both promises settle together', () => {
+ const { queue, flushQ, flushTick, jsAnimations } = makeMatrixHarness();
+ const element = document.createElement('div');
+ const firstResolved = vi.fn();
+ const secondResolved = vi.fn();
+
+ // Both pushes land in the SAME pending batch (before any flush tick).
+ queue.push([element], 'addClass', { addClass: 'a' }).then(firstResolved);
+ queue.push([element], 'addClass', { addClass: 'b' }).then(secondResolved);
+
+ flushTick();
+
+ // Exactly ONE ceremony started (the merged setClass), not two.
+ expect(startedCount(jsAnimations)).toBe(1);
+
+ // Neither promise resolves until the single ceremony closes.
+ flushQ();
+ expect(firstResolved).not.toHaveBeenCalled();
+ expect(secondResolved).not.toHaveBeenCalled();
+
+ // Close the one ceremony — BOTH promises settle together.
+ const started = jsAnimations.find((a) => a.started);
+ started?.done?.();
+ flushQ();
+ expect(firstResolved).toHaveBeenCalledTimes(1);
+ expect(secondResolved).toHaveBeenCalledTimes(1);
+
+ // Final classList equals the instant-engine in-order replay: add a, add b.
+ expect(element.classList.contains('a')).toBe(true);
+ expect(element.classList.contains('b')).toBe(true);
+ });
+
+ it('add X then remove X in one batch self-cancels → DROP: no ceremony, both resolve, classList untouched', () => {
+ // The `ClassDelta` accumulator cancels a self-cancelling PAIR as a pair:
+ // `addClass X` then `removeClass X` (in either order) nets to X in NEITHER
+ // set, so the merged delta is empty and `classifyCoalesce` routes to `drop`.
+ // Two self-cancelling ops applied in order leave ANY starting classList as
+ // it began (add x, remove x = no change), so dropping is correct — the
+ // instant-engine in-order replay produces the same "unchanged" result. No
+ // ceremony starts; both promises resolve; the element is left as it was.
+ const { queue, flushQ, flushTick, jsAnimations } = makeMatrixHarness();
+ const element = document.createElement('div');
+ element.className = 'keep x';
+ const firstResolved = vi.fn();
+ const secondResolved = vi.fn();
+
+ queue.push([element], 'addClass', { addClass: 'x' }).then(firstResolved);
+ queue.push([element], 'removeClass', { removeClass: 'x' }).then(secondResolved);
+
+ flushQ();
+ // Both settle immediately — the whole thing dropped, no flush tick needed.
+ expect(firstResolved).toHaveBeenCalledTimes(1);
+ expect(secondResolved).toHaveBeenCalledTimes(1);
+
+ flushTick();
+ // No ceremony ever started — the self-cancelling pair dropped.
+ expect(startedCount(jsAnimations)).toBe(0);
+
+ // The element is left exactly as it was (X survives because we never ran a
+ // removal ceremony — the pair cancelled to a no-op, not a net-remove).
+ expect(element.className).toBe('keep x');
+ });
+
+ it('the empty-payload DROP path starts no ceremony and both resolve, element untouched', () => {
+ // A second reachable `drop`: a pending class op with an empty payload leaves
+ // an empty pending delta, and a second empty-payload class op keeps it empty
+ // → `isEmptyClassDelta` true → drop. No ceremony, both promises resolve, the
+ // element is untouched. (Complements the add-X-then-remove-X self-cancel
+ // drop above — both routes reach the same empty-delta `drop` outcome.)
+ const { queue, flushQ, flushTick, jsAnimations } = makeMatrixHarness();
+ const element = document.createElement('div');
+ element.className = 'keep';
+ const firstResolved = vi.fn();
+ const secondResolved = vi.fn();
+
+ queue.push([element], 'addClass', {}).then(firstResolved);
+ queue.push([element], 'removeClass', {}).then(secondResolved);
+
+ flushQ();
+ // Both settle immediately — the whole thing dropped, no flush tick needed.
+ expect(firstResolved).toHaveBeenCalledTimes(1);
+ expect(secondResolved).toHaveBeenCalledTimes(1);
+
+ flushTick();
+ expect(startedCount(jsAnimations)).toBe(0);
+
+ // The element is left exactly as it was.
+ expect(element.className).toBe('keep');
+ });
+});
+
+describe('ngAnimate coalescing matrix — class + structural (tech spec §2.4)', () => {
+ it('pending class op + new structural op → structural WINS and carries the class delta into its close', () => {
+ const { queue, flushQ, flushTick, jsAnimations } = makeMatrixHarness();
+ const parent = document.createElement('div');
+ const element = document.createElement('p');
+ const classResolved = vi.fn();
+ const enterResolved = vi.fn();
+
+ // Pending class op, then a structural enter on the same element, one batch.
+ queue.push([element], 'addClass', { addClass: 'folded' }).then(classResolved);
+ queue.push([element], 'enter', { parent }).then(enterResolved);
+
+ // The retired class op resolves as a no-op (its effect is carried forward).
+ flushQ();
+ expect(classResolved).toHaveBeenCalledTimes(1);
+
+ flushTick();
+ // Exactly ONE ceremony started — the structural enter.
+ expect(startedCount(jsAnimations)).toBe(1);
+ expect(enterResolved).not.toHaveBeenCalled();
+
+ // Close the structural ceremony: the folded class delta lands at close.
+ const started = jsAnimations.find((a) => a.started);
+ started?.done?.();
+ flushQ();
+ expect(enterResolved).toHaveBeenCalledTimes(1);
+
+ // Final classList: the absorbed 'folded' class is applied; the element is
+ // in the parent (enter inserted it synchronously at push time).
+ expect(element.classList.contains('folded')).toBe(true);
+ expect(element.parentNode).toBe(parent);
+ });
+});
+
+describe('ngAnimate coalescing matrix — structural + structural (tech spec §2.4)', () => {
+ it('two structural ops on one element in one batch → earlier REJECTS (pre-handled), later WINS', () => {
+ const { queue, flushQ, flushTick, jsAnimations } = makeMatrixHarness();
+ const parent = document.createElement('div');
+ const element = document.createElement('p');
+ const enterRejected = vi.fn();
+ const moveResolved = vi.fn();
+
+ queue.push([element], 'enter', { parent }).catch(enterRejected);
+ queue.push([element], 'move', { parent }).then(moveResolved);
+
+ // The earlier (enter) record was superseded — its runner rejects. Pre-
+ // handled, so no `$q` unhandled report; the explicit `.catch` still sees it.
+ flushQ();
+ expect(enterRejected).toHaveBeenCalledTimes(1);
+
+ flushTick();
+ // Only ONE ceremony started (the winning move). The superseded enter was
+ // cancelled before start, so its stub never started.
+ expect(startedCount(jsAnimations)).toBe(1);
+
+ const started = jsAnimations.find((a) => a.started);
+ started?.done?.();
+ flushQ();
+ expect(moveResolved).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ════════════════════════════════════════════════════════════════════════════
+// Level 2 — full [ngModule, ngAnimate] DI harness (rapid toggle + destroy)
+// ════════════════════════════════════════════════════════════════════════════
+
+/** One recorded `$exceptionHandler` invocation. */
+interface Report {
+ exception: unknown;
+ cause: string | undefined;
+}
+
+/**
+ * Await one animation frame, then drain the microtask queue defensively — the
+ * `ng-animate-integration.test.ts` helper. jsdom fires pending raf callbacks
+ * in registration order, so any queue-scheduled raf work registered before
+ * this call completes within the awaited frame.
+ */
+async function nextFrame(): Promise {
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+/** Run the first digest + one raf tick so the startup grace lifts (FS §2.6). */
+async function liftStartupGrace($rootScope: Scope): Promise {
+ $rootScope.$digest();
+ await nextFrame();
+}
+
+/**
+ * Bootstrap a `[ngModule, ngAnimate, app]` injector with the given `.animation`
+ * registrations and a recording `$exceptionHandler`. Resolves `$animate`
+ * eagerly so the engine (and its startup-grace schedule) exists before the
+ * first digest.
+ */
+function bootstrapAnimated(animations: Record = {}) {
+ const reports: Report[] = [];
+ const handler: ExceptionHandler = (exception, cause) => {
+ reports.push({ exception, cause });
+ };
+ const app = createModule('animate-interruption-app', []).factory('$exceptionHandler', [() => handler]);
+ for (const [selector, definition] of Object.entries(animations)) {
+ app.animation(selector, [() => definition]);
+ }
+ const injector = createInjector([ngModule, ngAnimate, app]);
+ return {
+ injector,
+ $compile: injector.get('$compile'),
+ $rootScope: injector.get('$rootScope'),
+ $animate: injector.get('$animate'),
+ reports,
+ };
+}
+
+/** Count reports routed through the `$q` unhandled-rejection channel. */
+function qReports(reports: readonly Report[]): Report[] {
+ return reports.filter((r) => r.cause === '$q');
+}
+
+afterEach(() => {
+ resetRegistry();
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 1. Rapid ng-if true → false → true ends fully settled (FS §2.10)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — rapid ng-if true→false→true ends fully settled (FS §2.10)', () => {
+ it('the element ends present with its final appearance and NO leftover animation classes', async () => {
+ // A JS animation registered for enter AND leave so both structural
+ // ceremonies are live (each holds until its `done()` fires).
+ const enterDones: Array<() => void> = [];
+ const leaveDones: Array<() => void> = [];
+ const { $compile, $rootScope, reports } = bootstrapAnimated({
+ '.fade': {
+ enter(_element, done) {
+ enterDones.push(done);
+ },
+ leave(_element, done) {
+ leaveDones.push(done);
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+ await liftStartupGrace($rootScope);
+
+ // true → mount + enter ceremony pending.
+ $rootScope.show = true;
+ $rootScope.$digest();
+ await nextFrame();
+ expect(root.querySelector('p')).not.toBeNull();
+
+ // false → the clone's scope dies synchronously; leave ceremony pending
+ // (DOM removal deferred). The in-flight enter is cancelled by the new op.
+ $rootScope.show = false;
+ $rootScope.$digest();
+ await nextFrame();
+
+ // true again → a fresh mount cancels the in-flight leave and re-enters.
+ $rootScope.show = true;
+ $rootScope.$digest();
+ await nextFrame();
+
+ // Settle every ceremony that reported (idempotent — some were cancelled).
+ for (const done of [...enterDones, ...leaveDones]) {
+ done();
+ }
+ $rootScope.$digest();
+ await nextFrame();
+
+ // Final state: the element is present with its fully-settled appearance.
+ const settled = root.querySelector('p');
+ expect(settled).not.toBeNull();
+ const mounted = asInstanceOf(settled, HTMLParagraphElement);
+ expect(mounted.isConnected).toBe(true);
+ expect(mounted.textContent).toBe('hi');
+ expectNoAnimationClasses(mounted);
+
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 2. Double ng-show toggle: first rejects (pre-handled), latest wins (FS §2.5)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — double ng-show toggle: first rejects pre-handled, latest wins (FS §2.5)', () => {
+ it('rapidly toggling ng-show twice: no $q unhandled report; the element honors the latest toggle', async () => {
+ // A JS animation on the box's own class so BOTH the addClass and
+ // removeClass of `ng-hide` become live ceremonies that hold open.
+ const classDones: Array<() => void> = [];
+ const { $compile, $rootScope, reports } = bootstrapAnimated({
+ '.box': {
+ addClass(_element, _className, done) {
+ classDones.push(done);
+ },
+ removeClass(_element, _className, done) {
+ classDones.push(done);
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+
+ // Start hidden so the first post-grace toggle to visible is a removeClass.
+ $rootScope.visible = false;
+ await liftStartupGrace($rootScope);
+ const el = asInstanceOf(root.querySelector('.box'), HTMLDivElement);
+
+ // First toggle → visible (removeClass ng-hide) starts a ceremony…
+ $rootScope.visible = true;
+ $rootScope.$digest();
+ await nextFrame();
+
+ // …then rapidly toggle back to hidden (addClass ng-hide). The in-flight
+ // removeClass animation is cancelled — its runner rejects (pre-handled).
+ $rootScope.visible = false;
+ $rootScope.$digest();
+ await nextFrame();
+
+ // Settle whatever ceremony is still live.
+ for (const done of classDones) {
+ done();
+ }
+ $rootScope.$digest();
+ await nextFrame();
+
+ // The pre-handled rejection never surfaced through $q's unhandled channel.
+ expect(qReports(reports)).toEqual([]);
+ expect(reports).toEqual([]);
+
+ // Latest toggle wins: visible === false → the element carries ng-hide.
+ expect(el.classList.contains('ng-hide')).toBe(true);
+ expectNoAnimationClasses(el);
+
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 3. Destroy-mid-animation → clean, no late side effects (FS §2.10)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — destroy mid-animation finalizes cleanly (FS §2.10)', () => {
+ it('destroying the enclosing subtree mid-enter leaves no animation classes and fires no late side effects', async () => {
+ // A JS enter animation that ALSO arms a real timer — the "orphaned timer"
+ // FS §2.10 concern. The cancel function clears it; the engine's
+ // destroy-finalization must run cancel so the timer never fires late.
+ let enterDone: (() => void) | null = null;
+ const lateTimerFired = vi.fn();
+ let timerId: ReturnType | null = null;
+ const { $compile, $rootScope, reports } = bootstrapAnimated({
+ '.fade': {
+ enter(_element, done) {
+ enterDone = done;
+ timerId = setTimeout(lateTimerFired, 50);
+ return () => {
+ if (timerId !== null) {
+ clearTimeout(timerId);
+ }
+ };
+ },
+ },
+ });
+ const root = document.createElement('div');
+ document.body.appendChild(root);
+ // An outer ng-if wraps the animated child so tearing the outer off destroys
+ // the child subtree while its enter animation is in flight.
+ root.innerHTML = '
hi
';
+ $compile(root)($rootScope);
+
+ // Lift the grace with the outer container mounted but the child hidden, so
+ // the CHILD enter (below) happens post-grace and genuinely animates.
+ $rootScope.outer = true;
+ await liftStartupGrace($rootScope);
+
+ // Now mount the animated child post-grace → its enter ceremony starts.
+ $rootScope.show = true;
+ $rootScope.$digest();
+ await nextFrame();
+
+ const mounted = root.querySelector('p');
+ expect(mounted).not.toBeNull();
+ const animatedEl = asInstanceOf(mounted, HTMLParagraphElement);
+
+ // Enter ceremony is in flight (its done has not fired; its timer is armed).
+ expect(enterDone).not.toBeNull();
+
+ // Destroy the whole page region mid-animation.
+ $rootScope.outer = false;
+ $rootScope.$digest();
+
+ // The addElementCleanup finalization ended the animation immediately:
+ // no animation classes remain on the (now-removed) subtree.
+ expectNoAnimationClasses(animatedEl);
+ expect(root.querySelector('p')).toBeNull();
+
+ // No late side effects: the armed timer was cleared by the cancel function
+ // the finalization invoked — advancing real time past it fires nothing.
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ expect(lateTimerFired).not.toHaveBeenCalled();
+
+ // A stale late `done()` (were any external ref to hold it) is a harmless
+ // no-op — the record already finalized, so nothing re-runs or throws.
+ const staleDone = enterDone as unknown as () => void;
+ staleDone();
+ $rootScope.$digest();
+ await nextFrame();
+ expectNoAnimationClasses(animatedEl);
+
+ expect(reports).toEqual([]);
+ root.remove();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// 4. Completion promise resolves after visible animation; immediate under core
+// ────────────────────────────────────────────────────────────────────────────
+
+describe('ngAnimate — completion promise resolution (FS §2.5)', () => {
+ it('resolves only AFTER the visible animation completes (done)', async () => {
+ let enterDone: (() => void) | null = null;
+ const { $rootScope, $animate, reports } = bootstrapAnimated({
+ '.fade': {
+ enter(_element, done) {
+ enterDone = done;
+ },
+ },
+ });
+ await liftStartupGrace($rootScope);
+
+ const parent = document.createElement('div');
+ document.body.appendChild(parent);
+ const entering = document.createElement('p');
+ entering.className = 'fade';
+ const resolved = vi.fn();
+
+ void $animate.enter(entering, parent).then(resolved);
+
+ $rootScope.$digest();
+ await nextFrame();
+ // Ceremony started, promise still open.
+ expect(enterDone).not.toBeNull();
+ expect(resolved).not.toHaveBeenCalled();
+
+ // Complete the visible animation → the promise resolves.
+ const done = enterDone as unknown as () => void;
+ done();
+ $rootScope.$digest();
+ expect(resolved).toHaveBeenCalledTimes(1);
+
+ expect(reports).toEqual([]);
+ parent.remove();
+ });
+
+ it('under the CORE instant engine ([ngModule] only) the promise resolves immediately', () => {
+ // No ngAnimate — the instant engine applies the end state synchronously
+ // and resolves in the same digest turn (no raf, no ceremony).
+ const app = createModule('animate-core-completion-app', []);
+ const injector = createInjector([ngModule, app]);
+ const $rootScope = injector.get('$rootScope');
+ const $animate = injector.get('$animate');
+
+ const parent = document.createElement('div');
+ const entering = document.createElement('p');
+ const resolved = vi.fn();
+
+ void $animate.enter(entering, parent).then(resolved);
+
+ // Inserted synchronously at the call site (instant engine).
+ expect(entering.parentNode).toBe(parent);
+
+ // One digest turn drains the $q settlement into the follow-up — no frame.
+ $rootScope.$digest();
+ expect(resolved).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/animate/__tests__/animate-provider.test.ts b/src/animate/__tests__/animate-provider.test.ts
new file mode 100644
index 0000000..a7ac839
--- /dev/null
+++ b/src/animate/__tests__/animate-provider.test.ts
@@ -0,0 +1,189 @@
+/**
+ * `$AnimateProvider` tests (spec 041 Slice 1 / FS §2.4, §2.6).
+ *
+ * The provider is reachable only from `config()` blocks (`ngModule` registers
+ * `.provider('$animate', ['$provide', $AnimateProvider])`), so every test
+ * captures or drives it through a real config block — the `$routeProvider`
+ * holder precedent. Pinned contracts:
+ *
+ * - `register('.fade', factory)` installs a normal injector-resolvable
+ * factory under the `'.fade-animation'` provider name (the `Filter`
+ * channel), recorded on `$$registeredAnimations`, last-wins on repeats.
+ * - `register` without a leading `'.'` throws SYNCHRONOUSLY with a clear
+ * message (programmer error — never routed through `$exceptionHandler`).
+ * - `classNameFilter()` is a config-phase getter/setter: no-arg → current
+ * pattern (`null` default), `RegExp` arg → stores + returns the provider
+ * for chaining, non-`RegExp` → `TypeError`.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ngModule } from '@core/ng-module';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import { $AnimateProvider } from '@animate/animate-provider';
+import type { AnimationDefinition } from '@animate/animate-types';
+
+afterEach(() => {
+ resetRegistry();
+});
+
+/** Build an injector whose config block hands the provider to `configure`. */
+function withProvider(configure: (provider: $AnimateProvider) => void) {
+ const app = createModule('app', []).config(['$animateProvider', configure]);
+ return createInjector([ngModule, app]);
+}
+
+describe('$AnimateProvider — config-phase reachability', () => {
+ it('a config block receives the $AnimateProvider instance', () => {
+ const holder: { provider: $AnimateProvider | null } = { provider: null };
+ withProvider((p) => {
+ holder.provider = p;
+ });
+ expect(holder.provider).toBeInstanceOf($AnimateProvider);
+ });
+});
+
+describe('$AnimateProvider.register (FS §2.4)', () => {
+ it("register('.fade', factory) → injector.get('.fade-animation') resolves the definition", () => {
+ const definition: AnimationDefinition = {
+ enter(_element, done) {
+ done();
+ },
+ };
+ const injector = withProvider((p) => {
+ p.register('.fade', [() => definition]);
+ });
+
+ expect(injector.has('.fade-animation')).toBe(true);
+ expect(injector.get('.fade-animation')).toBe(definition);
+ });
+
+ it('the registered factory is a normal DI factory — dependencies resolve through the injector', () => {
+ const app = createModule('app', [])
+ .value('effectName', 'whoosh')
+ .config([
+ '$animateProvider',
+ (p: $AnimateProvider) => {
+ p.register('.slide', [
+ 'effectName',
+ (effectName: string): AnimationDefinition => ({
+ enter(element, done) {
+ element.setAttribute('data-effect', effectName);
+ done();
+ },
+ }),
+ ]);
+ },
+ ]);
+ const injector = createInjector([ngModule, app]);
+ const definition = injector.get('.slide-animation');
+
+ const el = document.createElement('div');
+ const done = vi.fn();
+ definition.enter?.(el, done);
+
+ expect(el.getAttribute('data-effect')).toBe('whoosh');
+ expect(done).toHaveBeenCalledTimes(1);
+ });
+
+ it('the resolved definition is a singleton across gets (factory recipe)', () => {
+ const injector = withProvider((p) => {
+ p.register('.fade', [(): AnimationDefinition => ({})]);
+ });
+
+ expect(injector.get('.fade-animation')).toBe(injector.get('.fade-animation'));
+ });
+
+ it('records the selector (without the dot) on $$registeredAnimations, mapped to the provider key', () => {
+ const holder: { provider: $AnimateProvider | null } = { provider: null };
+ withProvider((p) => {
+ holder.provider = p;
+ p.register('.fade', [(): AnimationDefinition => ({})]);
+ });
+
+ expect(holder.provider?.$$registeredAnimations.get('fade')).toBe('.fade-animation');
+ });
+
+ it('returns the provider so register calls chain', () => {
+ const injector = withProvider((p) => {
+ const chained = p
+ .register('.fade', [(): AnimationDefinition => ({})])
+ .register('.slide', [(): AnimationDefinition => ({})]);
+ expect(chained).toBe(p);
+ });
+
+ expect(injector.has('.fade-animation')).toBe(true);
+ expect(injector.has('.slide-animation')).toBe(true);
+ });
+
+ it('is last-wins on repeat registrations for the same selector', () => {
+ const first: AnimationDefinition = {};
+ const second: AnimationDefinition = {};
+ const injector = withProvider((p) => {
+ p.register('.fade', [() => first]);
+ p.register('.fade', [() => second]);
+ });
+
+ expect(injector.get('.fade-animation')).toBe(second);
+ });
+
+ it("throws synchronously when the name does not start with '.' (programmer error)", () => {
+ expect(() => {
+ withProvider((p) => {
+ p.register('fade', [(): AnimationDefinition => ({})]);
+ });
+ }).toThrow(`$animateProvider.register: animation name must be a CSS class selector starting with '.', got "fade"`);
+ });
+
+ it('rejects an empty name the same way', () => {
+ expect(() => {
+ withProvider((p) => {
+ p.register('', [(): AnimationDefinition => ({})]);
+ });
+ }).toThrow(/must be a CSS class selector starting with '\.'/);
+ });
+});
+
+describe('$AnimateProvider.classNameFilter (FS §2.6)', () => {
+ it('returns null by default — every element eligible to animate', () => {
+ withProvider((p) => {
+ expect(p.classNameFilter()).toBeNull();
+ });
+ });
+
+ it('a RegExp argument stores the pattern and returns the provider for chaining', () => {
+ withProvider((p) => {
+ const pattern = /animate-me/;
+ const result = p.classNameFilter(pattern);
+
+ expect(result).toBe(p);
+ expect(p.classNameFilter()).toBe(pattern);
+ expect(p.$$classNameFilter).toBe(pattern);
+ });
+ });
+
+ it('a later setter call replaces the stored pattern', () => {
+ withProvider((p) => {
+ const first = /first/;
+ const second = /second/;
+ p.classNameFilter(first);
+ p.classNameFilter(second);
+
+ expect(p.classNameFilter()).toBe(second);
+ });
+ });
+
+ it('a non-RegExp argument throws a TypeError', () => {
+ withProvider((p) => {
+ expect(() => {
+ p.classNameFilter('not-a-regexp' as unknown as RegExp);
+ }).toThrow(TypeError);
+ expect(() => {
+ p.classNameFilter('not-a-regexp' as unknown as RegExp);
+ }).toThrow('$animateProvider.classNameFilter expects a RegExp argument');
+ // The failed set leaves the stored pattern untouched.
+ expect(p.classNameFilter()).toBeNull();
+ });
+ });
+});
diff --git a/src/animate/__tests__/animate-runner.test.ts b/src/animate/__tests__/animate-runner.test.ts
new file mode 100644
index 0000000..48689bd
--- /dev/null
+++ b/src/animate/__tests__/animate-runner.test.ts
@@ -0,0 +1,249 @@
+/**
+ * `createAnimateRunner` unit tests (spec 041 Slice 4 / FS §2.5, §2.10,
+ * tech spec §2.4).
+ *
+ * PURE layer — the runner's only collaborator (`$q`) is built with a
+ * synchronous `scheduleDigest` stand-in (the `q-core.test.ts` `makePureQ`
+ * pattern), so settlement semantics are provable without an injector.
+ *
+ * Pinned here:
+ *
+ * - `complete()` / `end()` resolve the promise; `cancel()` rejects with
+ * {@link ANIMATION_CANCELLED_REASON}.
+ * - Settlement is FINAL and idempotent — whichever of the three fires
+ * first decides the outcome.
+ * - `done` callbacks fire SYNCHRONOUSLY at settle time with the
+ * `cancelled` flag; late registration is invoked immediately with the
+ * recorded outcome.
+ * - **Pre-handled promise** (the approved design decision): a cancelled
+ * runner with NO consumer follow-up produces ZERO `$exceptionHandler`
+ * `'$q'` unhandled-rejection reports once the digest queue drains —
+ * while a consumer `.catch` still observes the rejection normally. A
+ * control test proves the harness WOULD catch a genuine unhandled
+ * rejection, so the zero-report assertion has teeth.
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+
+import { ANIMATION_CANCELLED_REASON, createAnimateRunner } from '@animate/animate-runner';
+import { createQ } from '@async/q';
+import type { QDeferred } from '@async/q-types';
+import type { ExceptionHandler } from '@exception-handler/index';
+
+/** One recorded `$exceptionHandler` invocation. */
+interface Report {
+ exception: unknown;
+ cause: string | undefined;
+}
+
+/**
+ * Build a pure `$q` whose `scheduleDigest` queue drains synchronously on
+ * `flush()` (repeatedly — a continuation may schedule further turns, and
+ * `$q`'s unhandled-rejection check itself defers one turn), recording every
+ * `$exceptionHandler` report so tests can assert silence OR presence.
+ */
+function makeHarness() {
+ let queue: Array<() => void> = [];
+ const reports: Report[] = [];
+ const exceptionHandler: ExceptionHandler = (exception, cause) => {
+ reports.push({ exception, cause });
+ };
+ const q = createQ({
+ exceptionHandler,
+ scheduleDigest: (fn) => {
+ queue.push(fn);
+ },
+ });
+ const flush = (): void => {
+ while (queue.length > 0) {
+ const batch = queue;
+ queue = [];
+ for (const fn of batch) {
+ fn();
+ }
+ }
+ };
+ return { q, flush, reports };
+}
+
+describe('AnimateRunner — settlement outcomes (FS §2.5)', () => {
+ it('complete() resolves the promise', () => {
+ const { q, flush } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const onOk = vi.fn();
+ const onErr = vi.fn();
+
+ void runner.promise.then(onOk, onErr);
+ runner.complete();
+ flush();
+
+ expect(onOk).toHaveBeenCalledExactlyOnceWith(undefined);
+ expect(onErr).not.toHaveBeenCalled();
+ });
+
+ it('end() resolves the promise (engine-forced finalization settles like completion)', () => {
+ const { q, flush } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const onOk = vi.fn();
+
+ void runner.promise.then(onOk);
+ runner.end();
+ flush();
+
+ expect(onOk).toHaveBeenCalledExactlyOnceWith(undefined);
+ });
+
+ it('cancel() rejects the promise with the cancelled reason', () => {
+ const { q, flush } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const onOk = vi.fn();
+ const onErr = vi.fn();
+
+ void runner.promise.then(onOk, onErr);
+ runner.cancel();
+ flush();
+
+ expect(onErr).toHaveBeenCalledExactlyOnceWith(ANIMATION_CANCELLED_REASON);
+ expect(onOk).not.toHaveBeenCalled();
+ });
+
+ it('settlement is final — a cancel after complete is a no-op', () => {
+ const { q, flush } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const onOk = vi.fn();
+ const onErr = vi.fn();
+
+ void runner.promise.then(onOk, onErr);
+ runner.complete();
+ runner.cancel(); // ignored
+ runner.end(); // ignored
+ flush();
+
+ expect(onOk).toHaveBeenCalledExactlyOnceWith(undefined);
+ expect(onErr).not.toHaveBeenCalled();
+ });
+
+ it('settlement is final — a complete after cancel is a no-op', () => {
+ const { q, flush } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const onErr = vi.fn();
+
+ void runner.promise.then(undefined, onErr);
+ runner.cancel();
+ runner.complete(); // ignored
+ flush();
+
+ expect(onErr).toHaveBeenCalledExactlyOnceWith(ANIMATION_CANCELLED_REASON);
+ });
+});
+
+describe('AnimateRunner — done callbacks (engine bookkeeping seam)', () => {
+ it('done callbacks fire SYNCHRONOUSLY at settle time with cancelled = false on completion', () => {
+ const { q } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const first = vi.fn();
+ const second = vi.fn();
+
+ runner.done(first);
+ runner.done(second);
+ expect(first).not.toHaveBeenCalled();
+
+ runner.complete();
+
+ // No flush — done callbacks are the synchronous engine channel.
+ expect(first).toHaveBeenCalledExactlyOnceWith(false);
+ expect(second).toHaveBeenCalledExactlyOnceWith(false);
+ });
+
+ it('done callbacks receive cancelled = true on cancellation', () => {
+ const { q } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const callback = vi.fn();
+
+ runner.done(callback);
+ runner.cancel();
+
+ expect(callback).toHaveBeenCalledExactlyOnceWith(true);
+ });
+
+ it('a done callback registered AFTER settlement is invoked immediately with the recorded outcome', () => {
+ const { q } = makeHarness();
+ const runner = createAnimateRunner({ q });
+
+ runner.cancel();
+
+ const late = vi.fn();
+ runner.done(late);
+ expect(late).toHaveBeenCalledExactlyOnceWith(true);
+ });
+
+ it('done callbacks are not re-invoked by a later redundant settle call', () => {
+ const { q } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const callback = vi.fn();
+
+ runner.done(callback);
+ runner.complete();
+ runner.complete();
+ runner.cancel();
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('AnimateRunner — pre-handled promise (approved design decision, tech spec §1)', () => {
+ it('CONTROL: a plain unobserved $q rejection DOES report via the $q unhandled channel', () => {
+ // Proves the harness catches unhandled-rejection reports — without this
+ // the zero-report assertions below would pass vacuously.
+ const { q, flush, reports } = makeHarness();
+ // `QDeferred` annotation rather than `defer()` — keeps the
+ // `void` token out of expression position (the `animate-runner.ts`
+ // precedent; `no-invalid-void-type` flags the explicit type argument).
+ const deferred: QDeferred = q.defer();
+
+ deferred.reject('boom');
+ flush();
+
+ expect(reports).toHaveLength(1);
+ expect(reports[0]?.cause).toBe('$q');
+ expect(reports[0]?.exception).toBe('boom');
+ });
+
+ it('a cancelled runner with NO consumer follow-up produces ZERO $q unhandled reports', () => {
+ const { q, flush, reports } = makeHarness();
+ const runner = createAnimateRunner({ q });
+
+ runner.cancel();
+ // Drain every scheduled digest turn — the unhandled check defers one
+ // turn past the rejection, and flush() drains recursively.
+ flush();
+ flush();
+
+ expect(reports).toEqual([]);
+ });
+
+ it('a consumer .catch DOES observe the rejection despite the pre-handling', () => {
+ const { q, flush, reports } = makeHarness();
+ const runner = createAnimateRunner({ q });
+ const onErr = vi.fn();
+
+ void runner.promise.catch(onErr);
+ runner.cancel();
+ flush();
+ flush();
+
+ expect(onErr).toHaveBeenCalledExactlyOnceWith(ANIMATION_CANCELLED_REASON);
+ expect(reports).toEqual([]);
+ });
+
+ it('a completed (never cancelled) unobserved runner also produces zero reports', () => {
+ const { q, flush, reports } = makeHarness();
+ const runner = createAnimateRunner({ q });
+
+ runner.complete();
+ flush();
+ flush();
+
+ expect(reports).toEqual([]);
+ });
+});
diff --git a/src/animate/__tests__/animate-stagger.test.ts b/src/animate/__tests__/animate-stagger.test.ts
new file mode 100644
index 0000000..4ae3107
--- /dev/null
+++ b/src/animate/__tests__/animate-stagger.test.ts
@@ -0,0 +1,530 @@
+/**
+ * Stagger unit tests (spec 041 Slice 8 / FS §2.7, tech spec §2.4).
+ *
+ * Two layers, both over fully controllable seams — no injector, no real
+ * transitions (jsdom never fires them):
+ *
+ * - **CSS driver (`css-driver.ts` `start(onDone, staggerIndex)`)**: a
+ * class-aware `computeStyle` reports a non-zero `-stagger`
+ * `transition-delay` (or `animation-delay`) so {@link readStaggerStepMs}
+ * returns a real step. Element 0 of a same-event cohort starts its prep →
+ * active choreography immediately; element N defers by `N × step` behind the
+ * `setTimer` seam — advancing the fake timer releases the next element's
+ * prep → active. A lone element (index `undefined` / `0`) never staggers, and
+ * a zero step (jsdom default) applies no offset. The MAX of transition- and
+ * animation-delay is used, and every element ends with its driver classes
+ * cleaned.
+ * - **Queue (`animate-queue.ts` flush)**: a recording stub driver captures the
+ * `staggerIndex` the queue passes into `start()`, proving the flush assigns
+ * each 2+ same-event group member its 0-based position and leaves a lone
+ * group member at 0.
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+
+import { createAnimateQueue } from '@animate/animate-queue';
+import { createCssDriver, type CssComputedStyle, type CssDriver, type CssDriverAnimation } from '@animate/css-driver';
+import type { JsDriver } from '@animate/js-driver';
+import { readStaggerStepMs } from '@animate/animate-stagger';
+import type { AnimateEventName } from '@animate/animate-types';
+import { createQ } from '@async/q';
+import type { QService } from '@async/q-types';
+import type { TimerId } from '@async/async-types';
+import { noopExceptionHandler } from '@exception-handler/index';
+
+// ────────────────────────────────────────────────────────────────────────────
+// readStaggerStepMs — direct pure-helper probe (FS §2.7)
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * A class-aware computed-style reader: reports the given `-stagger` delays
+ * ONLY while the matching `-stagger` class is applied (mirroring how the real
+ * `getComputedStyle` would resolve the companion class's rule), and reports the
+ * given base timing otherwise. This lets one reader drive both the stagger
+ * probe (delay off the stagger class) and the active-phase duration read.
+ */
+function staggerAwareReader(config: {
+ staggerClass: string;
+ transitionDelay?: string;
+ animationDelay?: string;
+ transitionDuration?: string;
+ animationDuration?: string;
+}): (element: Element) => CssComputedStyle {
+ return (element) => ({
+ getPropertyValue: (property: string): string => {
+ const staggerApplied = element.classList.contains(config.staggerClass);
+ switch (property) {
+ case 'transition-delay':
+ return staggerApplied ? (config.transitionDelay ?? '') : '';
+ case 'animation-delay':
+ return staggerApplied ? (config.animationDelay ?? '') : '';
+ case 'transition-duration':
+ return config.transitionDuration ?? '';
+ case 'animation-duration':
+ return config.animationDuration ?? '';
+ default:
+ return '';
+ }
+ },
+ });
+}
+
+describe('readStaggerStepMs — probe (FS §2.7)', () => {
+ it('reads a 0.1s transition-delay off the -stagger companion class as 100ms', () => {
+ const element = document.createElement('div');
+ const step = readStaggerStepMs(
+ element,
+ ['ng-enter'],
+ staggerAwareReader({ staggerClass: 'ng-enter-stagger', transitionDelay: '0.1s' }),
+ );
+ expect(step).toBe(100);
+ // Probe classes never survive the read.
+ expect(Array.from(element.classList)).toEqual([]);
+ });
+
+ it('honors animation-delay and keeps the MAX of transition- vs animation-delay', () => {
+ const element = document.createElement('div');
+ const step = readStaggerStepMs(
+ element,
+ ['ng-enter'],
+ staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDelay: '0.1s',
+ animationDelay: '0.25s',
+ }),
+ );
+ expect(step).toBe(250); // max(100, 250)
+ });
+
+ it('reads animation-delay alone when no transition-delay is declared', () => {
+ const element = document.createElement('div');
+ const step = readStaggerStepMs(
+ element,
+ ['ng-enter'],
+ staggerAwareReader({ staggerClass: 'ng-enter-stagger', animationDelay: '0.15s' }),
+ );
+ expect(step).toBe(150);
+ });
+
+ it('returns zero when the stagger classes declare no delay (jsdom default)', () => {
+ const element = document.createElement('div');
+ // A reader that reports '' for every property — jsdom's behavior.
+ const step = readStaggerStepMs(element, ['ng-enter'], () => ({ getPropertyValue: () => '' }));
+ expect(step).toBe(0);
+ });
+
+ it('treats a non-numeric delay value (e.g. "auto") as zero (NaN guard)', () => {
+ const element = document.createElement('div');
+ const step = readStaggerStepMs(
+ element,
+ ['ng-enter'],
+ staggerAwareReader({ staggerClass: 'ng-enter-stagger', transitionDelay: 'auto' }),
+ );
+ expect(step).toBe(0);
+ });
+
+ it('honors an "ms"-suffixed delay verbatim (no ×1000 seconds conversion)', () => {
+ const element = document.createElement('div');
+ const step = readStaggerStepMs(
+ element,
+ ['ng-enter'],
+ staggerAwareReader({ staggerClass: 'ng-enter-stagger', transitionDelay: '120ms' }),
+ );
+ expect(step).toBe(120);
+ });
+
+ it('does not remove a stagger class the element ALREADY carried (only probe-added classes are cleaned up)', () => {
+ const element = document.createElement('div');
+ // The companion class is already present — the probe must not add it,
+ // and must NOT remove it on the way out (it belongs to the author).
+ element.classList.add('ng-enter-stagger');
+ const step = readStaggerStepMs(
+ element,
+ ['ng-enter'],
+ staggerAwareReader({ staggerClass: 'ng-enter-stagger', transitionDelay: '0.1s' }),
+ );
+ expect(step).toBe(100);
+ expect(element.classList.contains('ng-enter-stagger')).toBe(true);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// CSS driver start(onDone, staggerIndex) — the cascade choreography (FS §2.7)
+// ────────────────────────────────────────────────────────────────────────────
+
+/** One captured timer registration (the `css-driver.test.ts` shape). */
+interface TimerRecord {
+ fn: () => void;
+ delay: number;
+ cleared: boolean;
+}
+
+/**
+ * Build a CSS driver over fully manual seams with a shared class-aware reader
+ * so the `-stagger` delay AND the base duration both resolve. The `raf`
+ * queue, fake clock, and timer registry are drained on demand.
+ */
+function makeStaggerHarness(reader: (element: Element) => CssComputedStyle) {
+ const rafQueue: Array<() => void> = [];
+ const timers: TimerRecord[] = [];
+ let currentTime = 0;
+
+ const driver: CssDriver = createCssDriver({
+ raf: (callback) => {
+ rafQueue.push(callback);
+ },
+ now: () => currentTime,
+ computeStyle: reader,
+ setTimer: (fn, delay) => {
+ timers.push({ fn, delay, cleared: false });
+ return (timers.length - 1) as unknown as TimerId;
+ },
+ clearTimer: (id) => {
+ const record = timers[id as unknown as number];
+ if (record !== undefined) {
+ record.cleared = true;
+ }
+ },
+ });
+
+ return {
+ driver,
+ timers,
+ flushRaf: (): void => {
+ while (rafQueue.length > 0) {
+ rafQueue.shift()?.();
+ }
+ },
+ /** Fire a captured (not-since-cleared) timer by index. */
+ fireTimer: (index: number): void => {
+ const record = timers[index];
+ if (record !== undefined && !record.cleared) {
+ record.fn();
+ }
+ },
+ advance: (ms: number): void => {
+ currentTime += ms;
+ },
+ };
+}
+
+/** A synthetic end event (jsdom ships no TransitionEvent constructor). */
+function endEvent(type: 'transitionend' | 'animationend'): Event {
+ return new Event(type, { bubbles: true });
+}
+
+describe('CSS driver — stagger offset (FS §2.7)', () => {
+ it('element 0 of a batch runs its prep → active immediately (no offset)', () => {
+ const reader = staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDelay: '0.1s',
+ transitionDuration: '0.4s',
+ });
+ const { driver, flushRaf, timers } = makeStaggerHarness(reader);
+ const element = document.createElement('div');
+ const animation = driver.match(element, 'enter', {});
+ expect(animation).not.toBeNull();
+
+ const onDone = vi.fn();
+ animation?.start(onDone, 0); // index 0 → no stagger wait
+
+ // Prep + marker applied, active NOT yet — but no stagger timer either.
+ expect(Array.from(element.classList).sort()).toEqual(['ng-animate', 'ng-enter']);
+ // No stagger timer was armed (only the active phase runs, arming the
+ // fallback timer AFTER the raf tick).
+ expect(timers).toHaveLength(0);
+
+ flushRaf(); // active phase runs immediately
+ expect(element.classList.contains('ng-enter-active')).toBe(true);
+ expect(timers).toHaveLength(1); // the fallback timer, not a stagger timer
+ });
+
+ it('element N defers its prep → active by N × step behind the setTimer seam', () => {
+ const reader = staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDelay: '0.1s', // step = 100ms
+ transitionDuration: '0.4s',
+ });
+ const { driver, flushRaf, timers, fireTimer } = makeStaggerHarness(reader);
+ const element = document.createElement('div');
+ const animation = driver.match(element, 'enter', {});
+
+ const onDone = vi.fn();
+ animation?.start(onDone, 2); // third element → 2 × 100 = 200ms offset
+
+ // Prep + marker are applied during the offset wait (its resting state),
+ // but the active class is DEFERRED — the transition has not begun.
+ expect(Array.from(element.classList).sort()).toEqual(['ng-animate', 'ng-enter']);
+ expect(element.classList.contains('ng-enter-active')).toBe(false);
+
+ // A stagger timer was armed at index × step.
+ expect(timers).toHaveLength(1);
+ expect(timers[0]?.delay).toBe(200);
+
+ // The active phase has NOT started — draining raf now does nothing.
+ flushRaf();
+ expect(element.classList.contains('ng-enter-active')).toBe(false);
+
+ // Fire the stagger timer → the active-phase choreography kicks off.
+ fireTimer(0);
+ flushRaf();
+ expect(element.classList.contains('ng-enter-active')).toBe(true);
+ });
+
+ it('a five-item batch cascades: each successive element releases 100ms after the previous', () => {
+ const reader = staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDelay: '0.1s', // step = 100ms
+ transitionDuration: '0.4s',
+ });
+ const { driver, flushRaf, timers, fireTimer, advance } = makeStaggerHarness(reader);
+
+ // Five sibling elements, each getting its 0-based staggerIndex.
+ const elements = Array.from({ length: 5 }, () => document.createElement('div'));
+ const animations = elements.map((el) => driver.match(el, 'enter', {}));
+ const dones = elements.map(() => vi.fn());
+ animations.forEach((animation, index) => {
+ animation?.start(dones[index] ?? vi.fn(), index);
+ });
+
+ // Element 0 has no stagger timer; elements 1..4 each have one at N × 100.
+ // Timers array holds only stagger timers so far (index 0's active phase
+ // hasn't run yet — no fallback timer). Assert the four stagger delays.
+ const staggerDelays = timers.map((timer) => timer.delay);
+ expect(staggerDelays).toEqual([100, 200, 300, 400]);
+
+ // Element 0 runs immediately.
+ flushRaf();
+ expect(elements[0]?.classList.contains('ng-enter-active')).toBe(true);
+ expect(elements[1]?.classList.contains('ng-enter-active')).toBe(false);
+
+ // Advance to release each successive element's active phase in turn.
+ for (let index = 1; index < 5; index += 1) {
+ // The stagger timers were registered in order [1,2,3,4] → array indices
+ // 0..3; fire the (index-1)th and flush.
+ fireTimer(index - 1);
+ flushRaf();
+ expect(elements[index]?.classList.contains('ng-enter-active')).toBe(true);
+ }
+
+ // All five reach their end state after their transitions end.
+ advance(400);
+ elements.forEach((element, index) => {
+ element.dispatchEvent(endEvent('transitionend'));
+ expect(dones[index]).toHaveBeenCalledTimes(1);
+ // Every driver class removed — the element rests in its final state.
+ expect(Array.from(element.classList)).toEqual([]);
+ });
+ });
+
+ it('a lone element (index undefined) never staggers — starts immediately', () => {
+ const reader = staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDelay: '0.1s',
+ transitionDuration: '0.4s',
+ });
+ const { driver, flushRaf, timers } = makeStaggerHarness(reader);
+ const element = document.createElement('div');
+ const animation = driver.match(element, 'enter', {});
+
+ const onDone = vi.fn();
+ animation?.start(onDone); // no staggerIndex → treated as 0
+
+ flushRaf();
+ // Active phase ran immediately; the only timer is the fallback (armed AFTER
+ // the raf tick), never a stagger timer.
+ expect(element.classList.contains('ng-enter-active')).toBe(true);
+ expect(timers).toHaveLength(1);
+ });
+
+ it('a zero stagger delay (jsdom default) applies no offset even for a high index', () => {
+ // Base duration present so the animation matches, but the stagger classes
+ // report no delay — the jsdom-default outcome.
+ const reader = staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDuration: '0.4s',
+ // no transitionDelay / animationDelay → step = 0
+ });
+ const { driver, flushRaf, timers } = makeStaggerHarness(reader);
+ const element = document.createElement('div');
+ const animation = driver.match(element, 'enter', {});
+
+ const onDone = vi.fn();
+ animation?.start(onDone, 3); // high index, but zero step → no wait
+
+ // No stagger timer armed; the active phase runs on the next raf.
+ expect(timers).toHaveLength(0);
+ flushRaf();
+ expect(element.classList.contains('ng-enter-active')).toBe(true);
+ expect(timers).toHaveLength(1); // only the fallback timer
+ });
+
+ it('cancel during the stagger wait clears the pending timer and settles', () => {
+ const reader = staggerAwareReader({
+ staggerClass: 'ng-enter-stagger',
+ transitionDelay: '0.1s',
+ transitionDuration: '0.4s',
+ });
+ const { driver, flushRaf, timers } = makeStaggerHarness(reader);
+ const element = document.createElement('div');
+ const animation = driver.match(element, 'enter', {});
+
+ const onDone = vi.fn();
+ animation?.start(onDone, 2);
+ expect(timers).toHaveLength(1); // the stagger timer is pending
+
+ animation?.cancel();
+ // The pending stagger timer is cleared and the ceremony settles.
+ expect(timers[0]?.cleared).toBe(true);
+ expect(onDone).toHaveBeenCalledTimes(1);
+ expect(Array.from(element.classList)).toEqual([]);
+
+ // The now-cancelled ceremony never runs its active phase.
+ flushRaf();
+ expect(element.classList.contains('ng-enter-active')).toBe(false);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// Queue-level staggerIndex assignment (FS §2.7 — flush groups by event)
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * A recording stub driver that captures the `staggerIndex` the queue passes
+ * into every `start()`, so the flush's per-group indexing is directly
+ * observable without a real CSS timing offset.
+ */
+function makeRecordingCssDriver() {
+ const starts: Array<{ element: Element; staggerIndex: number | undefined; done: () => void }> = [];
+ const driver: CssDriver = {
+ match: (element): CssDriverAnimation => ({
+ start(onDone, staggerIndex) {
+ starts.push({ element, staggerIndex, done: onDone });
+ },
+ cancel() {
+ /* no-op stub */
+ },
+ }),
+ };
+ return { driver, starts };
+}
+
+/** A pure `$q` with a synchronous drain (the `css-queue-integration` pattern). */
+function makePureQ(): { q: QService; flushQ: () => void } {
+ let queue: Array<() => void> = [];
+ const q = createQ({
+ exceptionHandler: noopExceptionHandler,
+ scheduleDigest: (fn) => {
+ queue.push(fn);
+ },
+ });
+ const flushQ = (): void => {
+ while (queue.length > 0) {
+ const batch = queue;
+ queue = [];
+ for (const fn of batch) {
+ fn();
+ }
+ }
+ };
+ return { q, flushQ };
+}
+
+/**
+ * Build the engine over the recording CSS driver, a never-matching JS driver,
+ * and manual scheduling seams (startup grace lifted before returning).
+ */
+function makeQueueStaggerHarness() {
+ const { q, flushQ } = makePureQ();
+ const postDigestQueue: Array<() => void> = [];
+ const rafQueue: Array<() => void> = [];
+ const { driver: cssDriver, starts } = makeRecordingCssDriver();
+ const jsDriver: JsDriver = { match: () => null };
+
+ const queue = createAnimateQueue({
+ q,
+ exceptionHandler: noopExceptionHandler,
+ postDigest: (fn) => {
+ postDigestQueue.push(fn);
+ },
+ raf: (callback) => {
+ rafQueue.push(callback);
+ },
+ jsDriver,
+ cssDriver,
+ classNameFilter: () => null,
+ });
+
+ const flushTick = (): void => {
+ while (postDigestQueue.length > 0) {
+ postDigestQueue.shift()?.();
+ }
+ while (rafQueue.length > 0) {
+ rafQueue.shift()?.();
+ }
+ };
+
+ flushTick(); // lift the startup grace
+
+ return { queue, flushQ, flushTick, starts };
+}
+
+describe('animate queue — per-batch staggerIndex assignment (FS §2.7)', () => {
+ it('assigns 0-based indices per same-event group for a 2+ member group', () => {
+ const { queue, flushTick, starts } = makeQueueStaggerHarness();
+ const parent = document.createElement('div');
+
+ // Three enters in one digest batch → one same-event group of 3.
+ const elements = Array.from({ length: 3 }, () => document.createElement('p'));
+ for (const element of elements) {
+ void queue.push([element], 'enter', { parent });
+ }
+
+ flushTick();
+ expect(starts).toHaveLength(3);
+ // Each member got its 0-based position within the enter group.
+ expect(starts.map((entry) => entry.staggerIndex)).toEqual([0, 1, 2]);
+ expect(starts.map((entry) => entry.element)).toEqual(elements);
+ });
+
+ it('a lone same-event operation gets index 0 (no stagger for a group of one)', () => {
+ const { queue, flushTick, starts } = makeQueueStaggerHarness();
+ const parent = document.createElement('div');
+ const element = document.createElement('p');
+
+ void queue.push([element], 'enter', { parent });
+
+ flushTick();
+ expect(starts).toHaveLength(1);
+ // A single-member group never staggers — index 0.
+ expect(starts[0]?.staggerIndex).toBe(0);
+ });
+
+ it('groups independently by event: two enters and two leaves each index from 0', () => {
+ const { queue, flushTick, starts } = makeQueueStaggerHarness();
+ const parent = document.createElement('div');
+
+ const entering: Element[] = Array.from({ length: 2 }, () => document.createElement('p'));
+ const leaving: Element[] = Array.from({ length: 2 }, () => {
+ const el = document.createElement('span');
+ parent.appendChild(el); // leave needs the node present
+ return el;
+ });
+
+ for (const element of entering) {
+ void queue.push([element], 'enter', { parent });
+ }
+ for (const element of leaving) {
+ void queue.push([element], 'leave', {});
+ }
+
+ flushTick();
+
+ const byEvent = (event: AnimateEventName): Array =>
+ starts.filter((entry) => entering.includes(entry.element) === (event === 'enter')).map((e) => e.staggerIndex);
+
+ // Enters indexed 0,1; leaves indexed 0,1 — the two groups are independent.
+ expect(byEvent('enter')).toEqual([0, 1]);
+ expect(byEvent('leave')).toEqual([0, 1]);
+ });
+});
diff --git a/src/animate/__tests__/animation-dsl.test.ts b/src/animate/__tests__/animation-dsl.test.ts
new file mode 100644
index 0000000..ca5cdfc
--- /dev/null
+++ b/src/animate/__tests__/animation-dsl.test.ts
@@ -0,0 +1,217 @@
+/**
+ * `module.animation()` DSL tests (spec 041 Slice 1 / FS §2.4, tech spec
+ * §2.6).
+ *
+ * The DSL is pure config-block sugar forwarding verbatim to
+ * `$animateProvider.register(name, factory)` (the `.filter` precedent —
+ * `src/filter/__tests__/module-dsl.test.ts`), so these tests verify the
+ * INHERITANCE, not a re-implementation:
+ *
+ * - `.animation('.fade', factory)` ≡ a hand-written
+ * `config(['$animateProvider', p => p.register('.fade', factory)])` —
+ * both resolve the same way through `injector.get('.fade-animation')`.
+ * - `module.decorator('.fade-animation', …)` wraps the underlying
+ * definition (the `Filter` decorator precedent — the animation is a
+ * normal factory under a conventionally-named provider).
+ * - Name validation is the provider's: a selector without the leading `'.'`
+ * throws when the config block runs (at `createInjector` time).
+ * - Last-wins on duplicate registrations through the shared timeline.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ngModule } from '@core/ng-module';
+import { createInjector } from '@di/injector';
+import { createModule, resetRegistry } from '@di/module';
+import type { $AnimateProvider } from '@animate/animate-provider';
+import type { AnimationDefinition } from '@animate/animate-types';
+
+afterEach(() => {
+ resetRegistry();
+});
+
+describe('module.animation — basic registration (FS §2.4)', () => {
+ it("registers an animation resolvable via injector.get('.fade-animation')", () => {
+ const definition: AnimationDefinition = {
+ enter(_element, done) {
+ done();
+ },
+ };
+ const app = createModule('app', []).animation('.fade', [() => definition]);
+ const injector = createInjector([ngModule, app]);
+
+ expect(injector.has('.fade-animation')).toBe(true);
+ expect(injector.get('.fade-animation')).toBe(definition);
+ });
+
+ it('returns the module so .animation calls chain with other builder methods', () => {
+ const app = createModule('app', []);
+ const chained = app
+ .animation('.fade', [(): AnimationDefinition => ({})])
+ .value('answer', 42)
+ .animation('.slide', [(): AnimationDefinition => ({})]);
+
+ expect(chained).toBe(app);
+
+ const injector = createInjector([ngModule, chained]);
+ expect(injector.has('.fade-animation')).toBe(true);
+ expect(injector.has('.slide-animation')).toBe(true);
+ expect(injector.get('answer')).toBe(42);
+ });
+
+ it('the factory participates in DI like any other factory', () => {
+ const app = createModule('app', [])
+ .value('duration', 300)
+ .animation('.slide', [
+ 'duration',
+ (duration: number): AnimationDefinition => ({
+ enter(element, done) {
+ element.setAttribute('data-duration', String(duration));
+ done();
+ },
+ }),
+ ]);
+ const injector = createInjector([ngModule, app]);
+ const definition = injector.get('.slide-animation');
+
+ const el = document.createElement('div');
+ definition.enter?.(el, vi.fn());
+
+ expect(el.getAttribute('data-duration')).toBe('300');
+ });
+});
+
+describe('module.animation ≡ $animateProvider.register (tech spec §2.6)', () => {
+ it('the DSL and a hand-written provider config block resolve identically', () => {
+ const viaDsl: AnimationDefinition = {
+ enter: (_e, done) => {
+ done();
+ },
+ };
+ const viaProvider: AnimationDefinition = {
+ enter: (_e, done) => {
+ done();
+ },
+ };
+
+ const dslApp = createModule('dslApp', []).animation('.fade', [() => viaDsl]);
+ const providerApp = createModule('providerApp', []).config([
+ '$animateProvider',
+ (p: $AnimateProvider) => {
+ p.register('.slide', [() => viaProvider]);
+ },
+ ]);
+ const injector = createInjector([ngModule, dslApp, providerApp]);
+
+ expect(injector.get('.fade-animation')).toBe(viaDsl);
+ expect(injector.get('.slide-animation')).toBe(viaProvider);
+ });
+
+ it('a DSL registration and a provider registration for the SAME selector are last-wins in declaration order', () => {
+ const first: AnimationDefinition = {};
+ const second: AnimationDefinition = {};
+
+ // The `.animation` config block is pushed first, the explicit `.config`
+ // block second — both forward to the same `$provide.factory` timeline,
+ // so the LAST registration wins (the shared-registration-timeline rule).
+ const app = createModule('app', [])
+ .animation('.fade', [() => first])
+ .config([
+ '$animateProvider',
+ (p: $AnimateProvider) => {
+ p.register('.fade', [() => second]);
+ },
+ ]);
+ const injector = createInjector([ngModule, app]);
+
+ expect(injector.get('.fade-animation')).toBe(second);
+ });
+
+ it('duplicate .animation registrations are last-wins', () => {
+ const first: AnimationDefinition = {};
+ const second: AnimationDefinition = {};
+ const app = createModule('app', [])
+ .animation('.fade', [() => first])
+ .animation('.fade', [() => second]);
+ const injector = createInjector([ngModule, app]);
+
+ expect(injector.get('.fade-animation')).toBe(second);
+ });
+
+ it("inherits the provider's name validation — a selector without '.' throws at createInjector time", () => {
+ const app = createModule('app', []).animation('fade', [(): AnimationDefinition => ({})]);
+
+ expect(() => createInjector([ngModule, app])).toThrow(
+ /must be a CSS class selector starting with '\.', got "fade"/,
+ );
+ });
+});
+
+describe("module.decorator on a '-animation' provider (the Filter precedent)", () => {
+ it('wraps the underlying animation definition visible through injector.get', () => {
+ const enterSpy = vi.fn((_element: Element, done: () => void) => {
+ done();
+ });
+ const app = createModule('app', [])
+ .animation('.fade', [(): AnimationDefinition => ({ enter: enterSpy })])
+ .decorator('.fade-animation', [
+ '$delegate',
+ ($delegate: AnimationDefinition): AnimationDefinition => ({
+ ...$delegate,
+ enter(element, done) {
+ element.setAttribute('data-decorated', 'yes');
+ $delegate.enter?.(element, done);
+ },
+ }),
+ ]);
+ const injector = createInjector([ngModule, app]);
+ const definition = injector.get('.fade-animation');
+
+ const el = document.createElement('div');
+ const done = vi.fn();
+ definition.enter?.(el, done);
+
+ // The wrapper ran AND delegated to the original.
+ expect(el.getAttribute('data-decorated')).toBe('yes');
+ expect(enterSpy).toHaveBeenCalledExactlyOnceWith(el, done);
+ expect(done).toHaveBeenCalledTimes(1);
+ });
+
+ it('decorators stack on the CURRENT registration (last-wins producer, both decorators apply)', () => {
+ const calls: string[] = [];
+ const app = createModule('app', [])
+ .animation('.fade', [
+ (): AnimationDefinition => ({
+ enter(_element, done) {
+ calls.push('base');
+ done();
+ },
+ }),
+ ])
+ .decorator('.fade-animation', [
+ '$delegate',
+ ($delegate: AnimationDefinition): AnimationDefinition => ({
+ enter(element, done) {
+ calls.push('outer-first');
+ $delegate.enter?.(element, done);
+ },
+ }),
+ ])
+ .decorator('.fade-animation', [
+ '$delegate',
+ ($delegate: AnimationDefinition): AnimationDefinition => ({
+ enter(element, done) {
+ calls.push('outer-second');
+ $delegate.enter?.(element, done);
+ },
+ }),
+ ]);
+ const injector = createInjector([ngModule, app]);
+ const definition = injector.get('.fade-animation');
+
+ definition.enter?.(document.createElement('div'), vi.fn());
+
+ // Second decorator wraps the first, which wraps the base factory.
+ expect(calls).toEqual(['outer-second', 'outer-first', 'base']);
+ });
+});
diff --git a/src/animate/__tests__/class-routing.test.ts b/src/animate/__tests__/class-routing.test.ts
new file mode 100644
index 0000000..2568e97
--- /dev/null
+++ b/src/animate/__tests__/class-routing.test.ts
@@ -0,0 +1,462 @@
+/**
+ * Class-toggling directives route through `$animate` (spec 041 Slice 3 /
+ * tech spec §2.5).
+ *
+ * Verifies that the rewired class togglers — `ngShow` / `ngHide`
+ * (`addClass` / `removeClass` of `ng-hide`), the `ngClass` family (ONE
+ * coalesced `setClass` per `applyDiff` fire), and the forms state-class
+ * helpers (`src/forms/state-classes.ts` threaded through
+ * `NgModelControllerImpl` / `FormControllerImpl`) — no longer touch
+ * `classList` directly but delegate to the `$animate` class operations
+ * with the correct payloads.
+ *
+ * **Recording-engine pattern.** Same as `structural-routing.test.ts`:
+ * rather than stubbing `$animate` (the instant engine's DOM side
+ * effects ARE the directives' rendering), the internal `$$animateQueue`
+ * seam is overridden with a WRAPPER around the real
+ * `createCoreAnimateQueue` engine — every `push` is recorded (event,
+ * target element, add/remove class payloads) and then delegated to the
+ * real instant engine so behavior stays byte-identical. The DI
+ * last-wins override is the exact mechanism `ngAnimate` will use in
+ * later slices.
+ *
+ * Pinned per surface:
+ *
+ * - `ngShow` — falsy expression: ONE `addClass` push carrying
+ * `'ng-hide'` on the element; truthy: a `removeClass` push. `ngHide`
+ * is the exact inverse.
+ * - `ngClass` — an expression change that adds AND removes classes in
+ * one digest issues exactly ONE `setClass` push with the correct
+ * space-separated added/removed payloads; a fire whose diff is empty
+ * issues NO push; consumer-authored classes never appear in a
+ * removal payload (the classes-preserved guarantee).
+ * - forms — an `` inside a `