--- title: Eidos Motion — Design and Architecture type: reference audience: human + agent authority: E1/E3 — the two-moment motion model and the engine architecture status: current source: migrated from src/uix/eidos/eidos-motion.md (2026-07-02, docs-book F7.3) --- # Eidos Motion — Design and Architecture > **Status: the two-moment model — F1–F7 implemented (2026-06-04).** > > This document describes Eidos's animation system. The model, anchored in > the canon (the historical seed `GUIA_IMPLEMENTACION_SEMAUIX.md` §4.3, §7, > §11), is: > > **An interaction has TWO moments, and EACH may carry animation — one, the > other, or both:** > > - **The `--event` moment** (`data-event-*`): the perceptual flourish during > a sema signal's *hold*. It is the `motion` channel of the **perceptual > signature** (§11), defined **per event** (`family`/`intent`/`event`). > Transient. > - **The `--state` moment** (`data-state`): the **transition** to/from a > persistent condition. Defined **per component** (the `motion` prop). > Persistent. > > This is what makes UIX different from **every** current framework: the > others collapse presence animation onto a single axis (Chakra: only > `data-state` + `Presence`). UIX animates both moments and integrates them > with the perceptual signature — more coherent and richer. > > **Implemented (F1–F6):** the two moments (keyframes + `signatures` + > `presets` + generation + the `EngineMotion` engine + validation + tests + > the `/temas/animations` demo); the **signature migrated** from `events.css` > into the registry (`signatures`, F2); the **recipes migrated** to presets > with per-component duration/easing overrides (F3); the **JS drivers** > `spring` (physics) / `waapi` / `rect` (FLIP) + `Presence.motion` + a real > overlay (Dialog) bouncing (F4); **choreography** — stagger + the Material > `shared-axis`/`fade-through` presets (F5); **token rigor** — the raw > signature tokenized (long perceptual holds `slower`/`deliberate`/ > `emphatic`/`sustained`), shared-axis on `--motion-distance-xl`, the > `emphasized` easing, the `[data-motion-set='expressive']` set (F6); the > **extensible typegen** of preset names — the augmentable registry > `EidosMotionPresets` (F7). **Roadmap F1–F7 complete.** `data-motion-ref` > and the "TSC `event:*` scope" are obsolete (THEMING §13/§14, no real use). > > **Refactor (post-F5): the motion engine is a SERVICE.** The runtime no > longer lives in eidos: it was relocated to **`src/arts/motion`** (an *art* > — a pure runtime artifact with no UI or cross-art dependencies), exposed as > **`uix.motion`** and consumed by **both** layers — soma (`Presence` > receives `motion: EngineMotion` and calls `motion.run(node, phase)`) and > eidos (delegates via `eidos.motion` + registers its `css` presets in the > service at boot). This **dissolves the soma→eidos coupling**: the > `DialogProps.runMotion` prop and `eidos.motionRunner` disappear — the > *bridge* is now `EngineMotion.run`, which reads the node's > `data-animation-style`. Eidos keeps the **CSS generation** > (`lib/render-css.ts`) + the presets/keyframes/signatures **DATA** > (`lib/motion/presets/css.ts`); the types, the engine and the drivers > (`spring`/`waapi`/`rect`) live in `$motion`. See > [`src/arts/motion/README.md`](../../src/arts/motion/README.md). (Sections > §5/§6/§9 + the file tree below already reflect the new home.) > **USAGE extension — 3 domains (2026-06-21).** This document describes the > ENGINE, which has **two moments** (event/state). At the USAGE level the > `motion` prop covers **three** domains: **event** (the signature) · > **state** (the per-component transition) · **content** (the third: content > entering/leaving/looping, via `motionAttrs` / `` / the > `spin`/`pulse`/… loops — a USAGE layer **on top of** the state-preset > machinery, NOT a third engine moment). The dedicated state-domain > (`data-motion-state`, `` pilot), the container-driven cascade in both > directions and the `[data-debug-stagger]` affordance are post-F7. **For the > task-oriented guide** (recipes · preset catalog · loops · state-domain · > staggered cascade · reduced-motion · debug) → > [`motion-guide.md`](./motion-guide.md). For decisions and history (incl. > the "coordinated" engine retired in Plan A) → > [`MOTION_SERVICE_RFC.md`](../../src/uix/eidos/MOTION_SERVICE_RFC.md). **TL;DR**: - Two animatable moments: **`--event`** (`data-event-*`, the perceptual signature, `signatures`) and **`--state`** (`data-state`, the per-component transition, `presets`). One, the other, or both; they compose in sequence (`sequence`). - One registry (`EidosConfig.motion`) with three maps: `keyframes`, `signatures` (the event-moment), `presets` (the state-moment). - `data-state` belongs to **soma**; `data-event-*` to **sema**; **eidos reads both and animates** (§7: the rule is not to OVERWRITE the other layer's attribute, not "one axis only"). - Usage: the state-moment via the prop `motion="scale-fade"` → `data-animation-style`; the event-moment is automatic when the event fires (the signature). - Drivers: `css` (the floor, Chakra parity) + JS (`waapi`/`spring`/`rect`/ `svelte` — physics/FLIP/genie/orchestration, beyond Chakra), for either moment. --- ## Table of contents 1. [Thesis and positioning](#1-thesis-and-positioning) 2. [The two moments](#2-the-two-moments) 3. [Motion across the 4 layers](#3-motion-across-the-4-layers) 4. [Architecture — the two-surface registry](#4-architecture--the-two-surface-registry) 5. [The types](#5-the-types) 6. [The `EngineMotion` API](#6-the-enginemotion-api) 7. [The drivers](#7-the-drivers) 8. [The DOM contract](#8-the-dom-contract) 9. [Soma integration (`Presence`)](#9-soma-integration-presence) 10. [Reduced motion](#10-reduced-motion) 11. [Primitives and keyframes](#11-primitives-and-keyframes) 12. [Initial content (signatures + presets)](#12-initial-content-signatures--presets) 13. [Per-component defaults](#13-per-component-defaults) 14. [Where the code lives](#14-where-the-code-lives) 15. [The event-moment: from `events.css` to `signatures`](#15-the-event-moment-from-eventscss-to-signatures) 16. [Comparison with Chakra UI v3](#16-comparison-with-chakra-ui-v3) 17. [Naming decisions](#17-naming-decisions) 18. [Implementation phases](#18-implementation-phases) 19. [Out of scope / deferred](#19-out-of-scope--deferred) --- ## 1. Thesis and positioning An interaction does not have **one** animatable moment; it has **two**, and they are of different natures: 1. The **perceptual occurrence** — "something just happened" — transient, with a `hold`, an `intent` and a `sequence`. **Sema** writes it as `data-event-*`. 2. The **state change** — "this is now open" — persistent, the source of truth. **Soma** writes it as `data-state`. The canon makes it explicit (GUIA §4.3): `emerge` = "something enters or leaves the perceptual field" → it is an **event**, not a state. GUIA §11 defines the **per-event perceptual signature**, where `motion` is one of the channels (alongside sound/color/presence/haptic): the exit movement of a `commit.delete + loss` is "withdrawal/descent"; that of a `signal.alert + threat` is "protruding entrance". Movement is defined **per event**. > **UIX's differentiator.** Every current framework animates presence on > **one axis** (Chakra: `data-state` + `Presence`; Radix/Ark: same). UIX > distinguishes the two moments and **animates both**, integrating the > event-moment with the perceptual signature (sound/haptic included). That > is what makes it more coherent (one model, two sharp moments) and richer > (the animation of "what happened" is never confused with "what state we > are in"). **What eidos owns**: the typed DATA registry (`EidosConfig.motion` = `keyframes` + `signatures` + `presets`), the CSS generation for both surfaces, the `motion` prop and the `data-animation-style` attr. The **execution engine** (`EngineMotion`) is NOT eidos's: it lives in `$motion` and is consumed via `uix.motion` (eidos delegates + registers its `css` presets there). **What it does NOT own**: emitting the signal and its semantic signature → **sema**; the mount/unmount lifecycle and `data-state` → **soma `Presence`**; the attrs animated over (`data-state`, `data-side`, `data-starting/ending-style`) → declared in **morfo**; the `allow`/`reduce` pref → **`ActivePrefs`**. --- ## 2. The two moments | | The **`--event`** moment | The **`--state`** moment | |---|---|---| | **Attribute** | `data-event-*` | `data-state` | | **What it is** | the perceptual occurrence (the signal) | the transition to/from a persistent condition | | **Its animation** | the **signature's flourish** (settle, pulse, intent-tinted withdrawal, a `present`'s entrance…) | the **presence/layout transition** (scale, slide, grow to the open height…) | | **Granularity** | **per event** (`family`/`intent`/`event`) — generic, consistent system-wide | **per component** (the `motion` prop) | | **Attr owner** | sema (stamps it during the `hold`) | soma (effects) | | **Nature** | transient (lives the `hold`) | persistent | | **In the registry** | `signatures` | `presets` | | **Canon** | §11 (perceptual signature), §4.3 (emerge), §7.1 | §7.2, §5.2 (`sequence`) | **Each moment may carry animation — one, the other, or both:** - `press` (a button): only the **event** moment (`contact.press` → squeeze). No state transition. - a `dialog` opening: the **event** flourish of `emerge.present` **and** the **state** transition to `open`. - a `collapse`: the height transition (**state**) **and/or** the `expand` **event** flourish. **They compose in sequence** (GUIA §5.2): `sequence: 'pre'` runs the event's flourish **before** the state changes (e.g. animate the exit before closing); `'post'`, after (celebrate after the real result). The architecture chapter describes it in the causal chain ([`active-architecture.md`](../architecture/active-architecture.md) §5): the event's animation runs during the `hold`, then the state takes over. **`data-state` and `data-event-*` never mix** (§7.3): the rule is that **sema does not overwrite** state attributes and vice versa (ownership). It does NOT say only one may animate — **eidos reads both and animates both**. What is forbidden is stepping on the other's name, not animating over both axes. > **When both write `animation` on ONE node (KNOWN-FRAGILE).** Eidos animates > both axes, but a firma and a `--state`/stagger preset both set the single > `animation` shorthand — only one applies. On a shared node (a `DropdownMenu` > item that staggers on open/close AND receives a `commit-select` event) both > selectors compute `(0,3,0)`, so **CSS source order** decides: the generator > emits all `signatures` before all `presets`, so the later stagger wins and the > generic firma is masked — directionally the intended outcome (the coordinated > owns the visual axis, RFC B.2), but produced **accidentally by emission order**, > NOT by an opt-out (the `channels:[]` / `expression:'none'` source-silence is > dormant; the old `animation: none !important` neutralization is retired). No > shipped component shows a defect (e.g. dropdown select feedback is sound+haptic > only; the masked pulse is the generic fallback and the item is fading out at > select). This is a **provisional accident, not a contract** — pinned by > `motion.test.ts` ("pins the firma-vs-stagger cascade precedence") so a generator > reorder or a future `(0,4,0)` `[data-event^='…']` signature fails loudly. The > structural fix ("una firma por evento") is > [RFC §D.4-B](../../src/uix/eidos/MOTION_SERVICE_RFC.md). `@layer` (order- > independent precedence) is a deferred, tracked end-state — triggered only when a > coordinated component gains a visual commit channel, `(0,4,0)` signatures ship, > or `@layer` is adopted for other reasons. > **Child coordination (stagger / cascade) — CLOSED model, RFC §D.11.** > There was a third "coordinated" axis (`PresenceGroup` / `cascade-*` presets > over `data-starting/ending-style`); it was **retired** on 2026-06-19. The > final model: the cascade is NOT a separate system — it is the **`--state` > moment** (a declared preset) + the **stagger that already existed** > (`index × --motion-stagger-each`, parallel = 0, cascade = N) + **one > foundation rule** that writes the index from structure > (`[data-stagger] > *:nth-child → --motion-stagger-index` / > `:nth-last-child → -rev`, generated in `lib/render-css.ts`). The event's > signature (`emerge` → `present-rise`) is only the **container's** flourish > (generic, over `data-event-*`); the children's timing is **per-component > realization**, not a sema channel. Three orthogonal axes (sema emits · > motion is the engine · eidos materializes) + the full lifecycle: > [RFC §D.11](../../src/uix/eidos/MOTION_SERVICE_RFC.md). > **The UNIVERSAL `motion` prop — one selector, three domains (design > framing, [RFC §D.12]).** After retiring the coordinated axis, ONE system > remains → **a single `motion` prop** (today `` still uses > `animation`; it gets unified). The goal: **any** component (not just > overlays) can receive `motion="X"` from a type-safe registered catalog. > What the prop means is decided by the **discriminant** "does the animation > realize a perceptual event?": **event** → the signature rules, the prop is > an override/violation (types/lint); **state** → it selects the > state-preset (`data-state`); **content** (no event) → the prop is the > **primary** path. The sound/haptic coupling applies only to the event > domain — content ones stay in clean parity. Detail + plan (a)+(b): > [RFC §D.12](../../src/uix/eidos/MOTION_SERVICE_RFC.md). > **Structural constraints of the CSS model — [RFC §D.13].** The contracts > the declarative approach demands: (1) **structure** — `[data-stagger]` ↔ > DIRECT children; `:nth-child` ignores comments/`{#if}` (hardened), but an > intermediate wrapper element breaks the count → a cascading grouper > becomes its own scope (`inherits:false` isolates the index); > `display:contents` without re-scoping = a dead zone. (2) **exit** — > unit-exit via `Presence` (retain + await; the parent's `opacity` drags the > children) works today; per-child staggered-exit requires lifecycle JS > (`PresenceGroup`, deferred) → the container-driven rule is ENTER-ONLY; > **never raw `{#if}`** on an animated surface. (3) **debug** — the index is > `inherits:false` (no ancestor can stomp it); everything lives typed in > Computed; the opt-in `[data-debug-stagger]` mode materializes it (an > `::after` badge per child with its index, via a counter mirroring > `:nth-child - 1`). --- ## 3. Motion across the 4 layers | Layer | What it contributes | Moment | |---|---|---| | **Morfo** | Declares the attrs: `data-state` + states (`open`/`closed`), the **events** (`emerge`/`commit`/`signal` + `sequence`/`persistence`/`intent`), `data-side`/`data-align`, `data-starting/ending-style`. | both | | **Sema** | Stamps `data-event-*` (`family`/`intent`/`direction`/`phase`/`id`) during the `hold`; resolves the signature (sound/haptic are runtime channels; **`motion`/`color`/`presence` are materialized by eidos** reading `data-event-*`). | `--event` | | **Soma** | Writes `data-state` via effects; `Presence` keeps the node during the exit and awaits the animation (`getAnimations()` + `Promise.all(finished)`). Fires the event with its `sequence`. | `--state` (+ fires the event) | | **Eidos** | `keyframes` + `signatures` (event-moment, over `data-event-*`) + `presets` (state-moment, over `data-state`) + **CSS generation**; delegates the JS engine to **`uix.motion`** (the service) and registers its `css` presets there at boot. **Reads both axes and animates.** | both | Reduced-motion is already wired: `MotionEffective = 'allow' | 'reduce'` (`libs/motion`), resolved ONCE by prefs (`resolveMotion` folds the OS hint under the user's intent), projected as `data-motion` by `ActivePrefsDomProjection`, and handed to every JS runtime as a `MotionSource` (`createMotionSourceFromPrefs`). The OS hint itself is tracked by `ReducedMotionTracker` (`arts/adom`) and exposed on `ActiveDom.prefersReducedMotion.matches` — it feeds the prefs ENVIRONMENT, and nothing else reads it to decide. --- ## 4. Architecture — the two-surface registry ``` EidosConfig.motion ├── keyframes: { 'fade-in': {...}, 'scale-in': {...}, ... } registered @keyframes │ ├── signatures: { ← the --event MOMENT (the signature, generic per event) │ 'emerge-present': { family:'emerge', event:'emerge-present', keyframes:['fade-in'], ... }, │ 'commit-settle': { family:'commit', keyframes:['settle'], ... }, │ 'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... } │ } → generates [data-event-*][data-event-phase='active'] rules │ └── presets: { ← the --state MOMENT (per-component transition) 'scale-fade': { driver:'css', enter:{...}, exit:{...} }, 'slide-fade': { driver:'css', enter:{ bySide }, exit:{ bySide } }, 'genie': { driver:'rect', enter, exit } (P3, JS) } → generates [data-animation-style][data-state] rules │ ▼ uix.motion : EngineMotion (service · arts/motion — resolves, runs JS drivers, honors reduce) ▲ eidos.motion delegates here + registers the css presets at boot │ ▼ Eidos reads data-event-* (signature) + data-state (transition) → animates ``` - **`signatures`** is the generic signature: a `commit` settles the same way system-wide; an `emerge.present` enters the same way. Sema's packs (`sema/components/*.ts`) fine-tune a concrete component's signature. It is what `events.css` used to do by hand (§15). - **`presets`** is the per-component transition, chosen with the `motion` prop. - Theme/app can add or override in both maps. --- ## 5. The types `$motion` (`src/arts/motion/types.ts`) — relocated to an *art*. `duration`/`ease` are `string` (decoupled from `DurationKey`/`EaseKey`: eidos resolves the tokens in its generation layer; the art doesn't know the scale): ```ts type ReducePolicy = 'instant' | 'opacity-only' | 'none' type MotionSide = 'top' | 'right' | 'bottom' | 'left' type KeyframeName = string // key into motion.keyframes type MotionPresetName = string // the `motion` prop's value; 'none' disables // One CSS phase: composed keyframes + tokens + side-awareness. interface CssPhase { keyframes: KeyframeName | KeyframeName[] // comma-composed: ['scale-in','fade-in'] duration?: string // token key ('moderate'…) or raw ('600ms') ease?: string transformOrigin?: string // e.g. 'var(--_floating-transform-origin)' bySide?: Partial> } // ── The --event moment: the perceptual signature (generic per event) ── interface EventSignature { family?: string // data-event-family ('emerge' | 'commit' | 'signal' | …) intent?: string // data-event-intent (valenced families) event?: string | string[] // data-event name(s)/prefix(es) (['emerge-present','emerge-open'], …) direction?: string // data-event-direction ('forward' | 'backward') — sense of travel, // decided per emit. A REFINEMENT, never a matcher on its own. keyframes: KeyframeName | KeyframeName[] duration?: string // token key OR raw hold ('600ms', outside the scale) ease?: EaseKey fill?: 'none' | 'forwards' | 'backwards' | 'both' reduce?: ReducePolicy } // ── The --state moment: per-component preset (the `motion` prop) ── interface CssStatePreset { driver: 'css' enter?: CssPhase // [data-state='open'] exit?: CssPhase // [data-state='closed'] reduce?: ReducePolicy } interface JsStatePreset { // implemented (waapi/spring/rect/svelte) driver: 'waapi' | 'spring' | 'rect' | 'svelte' enter?: MotionRun; exit?: MotionRun requires?: ('sourceRect' | 'targetRect' | 'placement')[] reduce?: ReducePolicy fallback?: CssStatePreset // declared; NOT auto-applied today // (the spring driver honors ctx.reduced itself) } type StatePreset = CssStatePreset | JsStatePreset // ── The registry ── interface MotionConfig { keyframes?: Record signatures?: Record // the --event moment presets?: Record // the --state moment } ``` Structural decision: **the event-moment is generic per event** (`signatures`, by `family`/`intent`), not packaged per preset — because the signature (§11) is defined per event and must be consistent across components. Per-component fine-tuning of the event-moment goes in **sema's packs**. The `motion` prop only chooses the **state preset**. --- ## 6. The `EngineMotion` API Lives in `$motion` (the `uix.motion` service; `eidos.motion` and `soma.motion` expose it — the same instance). Registration + the declarative CSS path + **JS driver execution**. The caller passes `MotionRunOptions` (`dom`, `reduced`, `side`, `sourceRect`/`targetRect`, `duration`/`ease`) for the drivers that need them. ```ts interface EngineMotion { register(name: string, preset: StatePreset): void resolve(name: string): StatePreset | undefined has(name: string): boolean list(): string[] // css: declarative (settled handle); js: builds the MotionContext, runs the // driver, normalizes the return into a handle and tracks it per element. enter(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle exit(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle // Presence's bridge (replaces the old `runner`): reads the node's // `data-animation-style` and delegates to enter/exit (css → settled handle; // js → runs the driver). run(el: HTMLElement, phase: 'enter' | 'exit', opts?: MotionRunOptions): MotionHandle cancel(el: HTMLElement): void // cancels active JS handles pending(el: HTMLElement): Promise // combined finished (for Presence) dispose(): void // cancels everything (service cleanup) } ``` - A **`css` state preset** is declarative: the wrapper sets `data-animation-style` and the `[data-animation-style][data-state]` CSS rule + soma's `Presence` do the rest. The runtime doesn't "run" it. - A **JS state preset** runs the driver (`spring`/`waapi`/`rect`/`svelte`), normalizes the return (`Animation` | `Animation[]` | `MotionHandle`) into one handle, tracks it per element (`cancel`/`pending`), and honors the `ReducePolicy`. - The **event-moment** (signatures) does not go through `enter/exit`: it is generated CSS reacting to `data-event-*`. ### 6.1 — Cleanup policy (WAAPI / spring) `fill: forwards` leaves `Animation`s dangling in `getAnimations()` → it would break `Presence`'s exit wait. Discipline: rest states live in the `[data-state]` CSS; `waapi` presets animate **without `fill: forwards`** and the runtime `cancel()`s on finish; for rest states not expressible in CSS (measured FLIP), `commitStyles()` + `cancel()`. --- ## 7. The drivers They apply to **either moment** (a signature or a state preset). | Driver | Substrate | For | Beyond CSS | |---|---|---|---| | **`css`** | `@keyframes` + tokens (generated) | fade / scale / slide / collapse / the signature | — (the Chakra floor) | | **`waapi`** | `el.animate()` | runtime-computed keyframes | measured distance/size, cancelable | | **`spring`** | a semi-implicit Euler integrator (RAF via `ActiveDom`) | overshoot/settle, drag, snap-back | real physics — what no cubic-bezier expresses | | **`rect`** | rect measurement (via `ActiveDom`) → WAAPI | FLIP, shared-element, genie | animating between two real positions | | **`svelte`** (opt-in) | `transition:` / `animate:flip` | `{#each}` list reorders | Svelte's declarative | **Bundle**: `css` (generated) is the floor. `spring` is a **self-contained** integrator (no dependency — one independent spring per property, stepped by `ctx.dom.requestFrame`); `waapi` wraps `el.animate` (a browser API, already visible to `getAnimations()`); `rect` (FLIP) composes measurement + WAAPI. The helpers live in `lib/motion/presets/js.ts` (`spring()` / `waapi()` / `rect()`). `svelte` (opt-in) is reserved for `animate:flip` in `{#each}`. An external adapter (`motion-one`) is opt-in. **The `svelte` nuance**: `{#if}` transitions would fight soma's `Presence`; the core path is WAAPI/spring (composes with `getAnimations`). `svelte` is reserved for `animate:flip` in `{#each}` (Svelte owns that lifecycle). **`rect` batching**: FLIP with N elements does its pass in two phases over a single `requestFrame` (measure all sources → mutate → measure all targets → animate). `ActiveDom` already provides `requestFrame`; the batching belongs to the driver, not to `ActiveDom`. --- ## 8. The DOM contract **The `--event` moment** (sema writes it during the hold; eidos reacts): ```css [data-event='emerge-present'][data-event-phase='active'] { animation: fade-in …; } [data-event-family='commit'][data-event-phase='active'] { animation: settle …; } [data-event-family='signal'][data-event-intent='threat'][data-event-phase='active'] { animation: pulse …; } [data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] { animation: shift-cross-forward …; } ``` `data-event-direction` (`forward` | `backward`) is the sixth attr of the stamp and the only one decided PER EMIT rather than declared in the morfo: the event name already says `shift-enter-mode` vs `shift-exit-mode`, so the attr carries only the sense of travel. The `shift` crossing is the first firma to read it — its keyframes travel the inline axis via `--motion-shift-sign` (+1 `:dir(ltr)` / −1 `:dir(rtl)`), never a physical `translateX`. **The `--state` moment** (soma writes it; eidos reacts). The wrapper sets `data-animation-style` (the `motion` prop); the rest state comes from the recipe over the same `data-state`: ```css [data-animation-style='scale-fade'][data-state='open'] { animation: scale-in, fade-in …; } [data-animation-style='scale-fade'][data-state='closed'] { animation: scale-out, fade-out …; } [data-animation-style='slide-fade'][data-side='top'][data-state='open'] { animation: slide-from-bottom, fade-in …; } ``` Public prop: `motion?: MotionPresetName | 'none'` (per-component default; `'none'` disables). Placement-aware: it reads `data-side`/`data-align` (morfo) + the `--_floating-transform-origin` alias each floating host's recipe declares from its own `--_{c}-floating-transform-origin` channel (§15). **NOT `data-motion`**: that name already has two owners (the `allow`/`reduce` pref + NavMenu's direction). The engine's attr is `data-animation-style` (mirroring Chakra's `animationStyle`). See §17. --- ## 9. Soma integration (`Presence`) `soma/layers/presence.svelte.ts` already does the lifecycle: on close it keeps the node, awaits `node.getAnimations()` + `Promise.all(finished)` (`runId` guard, `enabled` flag), and unmounts. CSS animations (both moments) and `el.animate()` ones (JS drivers) appear in `getAnimations()` → **they are awaited with no new plumbing**. **`Presence.motion`** (F4 · the service refactor) — the hook for drivers NOT in `getAnimations()` (the `spring`, pure RAF): `Presence` receives `motion: EngineMotion` and calls **`motion.run(node, phase)`** on enter/exit; `run` starts the JS animation and returns a `MotionHandle`, whose `finished` `Presence` awaits ALONGSIDE `getAnimations()`. For a `css` preset (or a node without `data-animation-style`), `run` returns an already-settled handle → the declarative path is unchanged. Soma **does NOT import eidos**: it consumes the service via `soma.motion` (= `uix.motion`), the same instance as `eidos.motion`. Wired in Dialog's provider (`contentPresence`/`overlayPresence` receive `motion: this.soma.motion`); the same pattern fits any overlay — just pass `motion` to its `Presence`, **no prop and no coupling** (the old `DialogProps.runMotion` / `eidos.motionRunner` disappeared in the refactor). **Child orchestration** (pending, being redesigned): it hangs off the event's signature (sema) materialized by eidos, not off a parallel engine — see Appendix D of [`MOTION_SERVICE_RFC.md`](../../src/uix/eidos/MOTION_SERVICE_RFC.md). ### 9.1 — SSR / hydration Overlays mount on open, client-side; motion fires on transitions, not on first mount (a mounted-open element suppresses the enter). No in-flight SSR→JS handoff; the CSS `fallback` covers no-JS. ### 9.2 — Exit + a11y: `inert` (soma's) During the exit the node stays mounted: it must be `inert`/`aria-hidden` and unfocusable. The morfo's `focus.return` already returns focus; `inert` during the exit window is a `Presence`/`Dismissal` improvement in soma (the pattern is already used in `color-field`). --- ## 10. Reduced motion - **CSS** (both moments): `[data-motion='reduce'] …` rules per policy (`instant` → `animation: none`; `opacity-only` → fade only; `none` → untouched) — and **only** those. Eidos does not read media: the effective preference is `prefs`' (`resolveMotion` folds the OS hint, and an explicit `allow` overrules it) and arrives stamped on ``. A `@media (prefers-reduced-motion)` in a recipe would be a second source, still suppressing the motion of a user who asked to keep it. Guarded by `src/uix/eidos/reduced-motion-media.test.ts`. - **Runtime (JS drivers)**: `MotionContext.reduced` from the EFFECTIVE preference — a `MotionSource` over `prefs.motion` (`createMotionSourceFromPrefs`), injected into `EngineMotion` / `EngineScene` / sema's haptic channel by the composition root. The same ONE source as the CSS above: the engines no longer read `ActiveDom.prefersReducedMotion`, which is the OS hint, not the answer. `MotionRunOptions.reduced` stays as a per-run PIN and wins over the source. The `ReducePolicy` applies before running. - Morfo declares `a11ySemantic.reducedMotionFallback` per event; soma silences the signal when it applies. The engine is coherent with that decision. --- ## 11. Primitives and keyframes Existing tokens (`STATIC_MOTION`, generated, read by css and js): | Token | Values | |---|---| | `--duration-{k}` | `instant 0` · `fast 120` · `normal 180` · `moderate 240` · `slow 320` ms | | `--ease-{k}` | `default` · `out` · `in` · `spring` · `alert` · `symmetric` | | `--motion-distance-{k}` | `xs 2` · `sm 4` · `md 8` · `lg 16` px | | `--motion-scale-{k}` | `enter 0.985` · `press 0.97` · `through 0.92` (fade-through) · `lift 1.02` (drag pickup — the only `>1`, see §Draggable surfaces in THEMING) | | `--motion-stagger` | `20ms` — the stagger STEP a menu ripples at (`dropdown-menu`, `context-menu` set `--motion-stagger-each` from it) | | `--motion-stagger-viewport` | `70ms` — the reveal rhythm of a viewport animator inside `[data-stagger]`; the foundation writes it into `--motion-stagger-each` on that selector alone (changelog §57) | **Override vars (runtime, NOT theme tokens)** — a component sets them on its element; the preset reads them with a token fallback: | Var | For | Notes | |---|---|---| | `--motion-duration-{enter,exit}` | per-component timing (F3) | `@property inherits:false` | | `--motion-ease-{enter,exit}` | per-component curve (F3) | `@property inherits:false` | | `--motion-slide-leave` | `slide-full`'s extra travel for inset panels (drawer) | default `0px` | | `--height` / `--collapsed-height` | the `collapse`'s measured height (soma writes it) | — | | `--motion-stagger-each` | the stagger rhythm (on the container, inherits) | default `0ms` → **parallel**; N → cascade. One exception, and it is foundation, not a component: `[data-stagger] > [data-animation-trigger='viewport']` gets `var(--motion-stagger-viewport)` (the theme token above), so a section reveals on rhythm with no per-item delay. `` keeps its `0` | | `--motion-stagger-index` / `-index-rev` | the per-item index **from structure** — `[data-stagger] > *:nth-child` (fwd, enter) / `:nth-last-child` (rev, exit); generated in `render-css.ts`, **nobody writes it** | `@property inherits:false`, `` | Keyframes (`EidosConfig.motion.keyframes`, parity with Chakra's `theme.keyframes`): individual `translate`/`scale` (they compose without stomping each other); parameterized by CSS var for dynamic sizes/distances (`var(--height)`, `var(--collapsed-height,0)`). Built-in: `fade-in/out`, `scale-in/out`, `slide-from/to-{side}[-full]`, `expand/collapse-height`; the **signature** (`announce-pulse-*`, `commit-settle`, `press-squeeze`, `dismiss-fade`, `present-rise`); **Material** (`slide-axis-{x,y}-{in,out}`, `scale-from-92`). --- ## 12. Initial content (signatures + presets) **`presets` (the `--state` moment)** — built-in (`css`): | Name | Notes | |---|---| | `fade` · `scale-fade` | enter+exit; scale-fade with the `--_floating-transform-origin` alias (§15) | | `slide-fade` | side-aware (`data-side`): slide + scale 0.985 + fade | | `slide-full` | edge drawer, inset-aware (`--motion-slide-leave`) | | `collapse` | measured height (`var(--height)`) | | `shared-axis-x/y` · `fade-through` | Material 3 (F5) | The **JS drivers** (`spring`/`waapi`/`rect`, `presets/js.ts`) are registered per app/demo. The first **named built-in JS** preset is `spring-pop` (`driver:'spring'`, `presets/js.ts` → `BUILTIN_JS_PRESETS`), registered directly on `ActiveEidos` — not via the serializable `EidosConfig.motion.presets`, which cannot carry its `MotionRun` functions (`structuredClone` fails). Demos additionally register `panel-spring`, `dialog-spring`, `flip`. **`signatures` (the `--event` moment)** — built-in, migrated from `events.css` (F2, §15): `present`/`dismiss`, `commit` (+ the `fulfill`/`affirm`/`threat` intents), `press` (`contact`), `announce` (+ 5 intents). Generic per `family`/`intent`/`event`. --- ## 13. Per-component defaults The per-component **state-moment** (the `motion` prop, the wrapper's default): | Component | Part | Default `motion` | |---|---|---| | Dialog | overlay / content | `fade` / `scale-fade` | | Popover · Tooltip · Dropdown · Select · Menubar · ContextMenu | content | `slide-fade` | | Drawer | content | `slide-full` | | Accordion · Collapsible | content | `collapse` | | Toast | item | `slide-fade` | The **event-moment** is automatic: when the event fires (e.g. `commit`, `emerge.present`), the corresponding `signatures` entry reacts — no prop. --- ## 14. Where the code lives ``` arts/motion/ ← THE SERVICE (a pure art: no UI or cross-art deps) types.ts ✓ MotionDom (structural port), MotionConfig, EventSignature, StatePreset, MotionHandle… engine-motion.ts ✓ createEngineMotion → EngineMotion (register/resolve/enter/exit/run/cancel/pending/dispose) drivers.ts ✓ JS drivers: spring() (physics) / waapi() / rect() (FLIP) index.ts ✓ the `$motion` barrel (named re-exports) + README.md arts/active-app/service-factories/motion.ts ✓ defineEngineMotion (factory; coreDep 'dom') eidos/lib/motion/presets/css.ts ✓ DATA: keyframes + state presets + signatures (BUILTIN_*) + Material (F5) eidos/lib/config-types.ts ✓ EidosConfig.motion ($motion's MotionConfig type) eidos/lib/render-css.ts ✓ GENERATES CSS: presets ([data-state]) + signatures ([data-event-*]) + overrides + stagger eidos/lib/config.ts ✓ validates presets (css + js) + signatures eidos/lib/themes/base.ts ✓ built-in DATA (keyframes + signatures + presets) eidos/active-eidos.svelte.ts ✓ eidos.motion → delegates to uix.motion + registers the css presets at boot eidos/components/dialog/ ✓ wrapper: motion prop → data-animation-style (no runMotion) active-uix/active-uix.svelte.ts ✓ uix.motion : EngineMotion (createActiveUix creates it; attach reads it from the app) soma/core/soma.svelte.ts ✓ soma.motion → uix.motion soma/layers/presence.svelte.ts ✓ `motion: EngineMotion` opt → motion.run(node, phase) (JS gating) + tests ``` (`✓` done. Relocated to `arts/motion` in the service refactor. Pending: passing `motion` to more overlays (drawer/popover/…); named built-in JS drivers; F6/F7.) --- ## 15. The event-moment: from `events.css` to `signatures` `events.css` WAS "the hand-implemented `--event` surface". In **F2 it migrated to `signatures`**: the 9 keyframes + 12 reactions (announce pulse per intent, commit settle, dismiss fade, press squeeze, present rise) now live in `EidosConfig.motion.{keyframes,signatures}` (`presets/css.ts` → `BUILTIN_SIGNATURES`) and are generated into `generated/base.css` — theme-extensible. `events.css` was reduced to two global concerns: the compositor hint (`will-change`) and the reduced-motion cap (the signature's `motion` is silenced under reduce, but sound + haptic keep communicating — the cross-modal advantage). `archetypes.css` (4 baseline hover/focus transitions) stays as the transversal micro-interaction. --- ## 16. Comparison with Chakra UI v3 | Capability | Eidos | Chakra v3 | |---|---|---| | The `--state` moment (`data-state` + named presets) | ✅ `presets` + the `motion` prop | ✅ `animationStyle` + `data-state` | | `Presence` (hold-through-exit) | ✅ soma (`getAnimations`, covers transitions) | ✅ (`animationend`) | | keyframes registry + tokens | ✅ | ✅ | | placement-aware | ✅ `data-side` | ✅ `data-placement` | | **The `--event` moment (perceptual signature)** | ✅ `signatures` + sema (intent/hold/sequence) | ❌ **doesn't exist** | | **Both moments integrated** | ✅ | ❌ (only `--state`) | | physics / FLIP / orchestration (JS drivers) | ✅ `spring` / `rect` / `waapi` | ❌ | The **`--event` moment integrated with the perceptual signature** (and with sound/haptic) is what no current framework has. Chakra animates the state; UIX animates the state **and** the occurrence, keeping them distinct. --- ## 17. Naming decisions - The state-moment attr: **`data-animation-style`** (NOT `data-motion`, which is reserved for the reduce-motion pref `allow`/`reduce`). - The event-moment attrs: the `data-event-*` sema already stamps. - **Cleanup (F7)** ✓: NavMenu's directional `data-motion` (a planned rename) **does not exist** in the code — moot. `data-motion-ref` and the "TSC `event:*` scope" are discarded (no real use). --- ## 18. Implementation phases The "beat every framework" roadmap. **F1–F6 implemented:** - **F1 — The two-moment foundation** ✓: the types (`MotionConfig` {keyframes, signatures, presets}, `EventSignature`, `StatePreset`) + generation (both surfaces) + the `EngineMotion` engine (the `uix.motion` service, relocated to `$motion` in the refactor) + validation + tests. - **F2 — The cross-modal signature** ✓ (beats SwiftUI): `events.css` migrated to `signatures` (§15); the signature's `motion` + sound + haptic come from the SAME event (a coupling SwiftUI's `.sensoryFeedback` leaves loose). - **F3 — Polished presence** ✓ (matches Base UI): Dialog/Popover/Drawer/ Accordion migrated to presets; **per-component duration + easing** overrides (`--motion-duration/ease-{enter,exit}` + `@property inherits:false`), inset-aware slide (`--motion-slide-leave`), the measured-height bridge (`--height`). Zero timing regression. - **F4 — The mechanical JS engine** ✓ (matches Framer): the `spring` (real physics) / `waapi` / `rect` (FLIP) drivers; `Presence.motion` → `motion.run(node, phase)` (the engine as the `uix.motion` service, consumed by soma and eidos **with no coupling**); a real Dialog bouncing. - **F5 — Choreography** ✓ (matches Material 3): declarative stagger (`--motion-stagger-{index,each}`), the `shared-axis-x/y` + `fade-through` presets; container-transform via the `rect` driver. - **F6 — Token rigor** ✓ (matches Carbon): the raw signature **tokenized** — the duration scale gains the long stretch (`slower` 400 · `deliberate` 600 · `emphatic` 800 · `sustained` 1000ms). *(Corrected 2026-07-06: F6 originally set the neutral/affirm/fulfill announces to `deliberate` claiming alignment "with the book's holds-by-intent" — that aligned with the DRIFTED runtime table, not the book. Per the book's regions (TABLA 32.2): neutral/affirm = `moderate` ("breve"), fulfill = `slower` ("breve-media"), and the long end (`emphatic`/`sustained`) belongs to risk/threat. The hold never truncates a signature — the visual channel awaits the expression, capped by `MAX_EXPRESSION_WAIT_MS`; see decisions/book-deviations.md D.12.)* Shared-axis travels the canonical `--motion-distance-xl` (30px); fade-through uses `--motion-scale-through` (0.92); `press`/`commit` snap to the existing scale (sub-perceptible). The **`emphasized`** easing (M3 emphasized-decelerate). **Productive/ expressive sets** (Carbon): `primitives.motion.expressive` emits a `[data-motion-set='expressive']` scope remapping the easing — productive is the default. Distance-scaled duration stays a **pairing convention** (distance token ↔ duration token), not a runtime formula (no CSS consumer today — the `rect` driver would do it by measurement if a consumer asks). - **F7 — Extensibility + typegen** ✓ (beyond everyone): the `--state` moment's preset names are **type-safe + app-extensible** via an augmentable registry — `EidosMotionPresets` (mirroring `SemaChannelSignatures`), with `MotionPresetName = keyof EidosMotionPresets | 'none' | (string & {})`. An app adds type-safe presets with `declare module '$uix/eidos' { interface EidosMotionPresets { … } }`: the `motion` prop autocompletes them and a typo is a compile error. The engine (`$motion`/`uix.motion`) stays `string` (open at runtime) — the registry is compile-time ergonomics over the props; a test pins the built-in set against it. NavMenu's directional `data-motion` no longer exists (the rename is moot). `transition` with named groups stays **deferred** (no consumer; `getAnimations({ subtree: true })` would cover it). **Roadmap F1–F7 complete.** Every phase kept **zero regression** for anyone not using `motion` or firing new events. --- ## 19. Out of scope / deferred - A `motion-one` adapter or another external JS engine (opt-in, never a base dependency). - Exotic drivers (complex timelines) — only with a real consumer. - Widening durations to 7 steps (Chakra-style) — only if 5 falls short. --- **Revision notes**: 2026-06-21 — closure of the `motion` prop's domain (§D.12): the **state domain** (the dedicated `data-motion-state` attr + the `select-pop` emphasis preset, `` pilot), **content loops** (`spin`/`pulse`/`ping`/`bounce`, un-gated infinite rules) + the `` exit fix (`tick` no-op), the `[data-debug-stagger]` debug affordance, and the first **named built-in JS** preset `spring-pop` (the `spring` driver's debut, registered on `ActiveEidos`; demonstrated on `Popover.Content motion="spring-pop"`). Loops+lifecycle demo at `web/routes/demos/motion`. Earlier (2026-06-04): F1–F7 implemented + the engine relocated to `arts/motion` (the `uix.motion` service). If the code diverges, the code wins; open an issue. References: the historical seed `GUIA_IMPLEMENTACION_SEMAUIX.md` (§4.3, §7, §11 — the event/state/signature model), [`active-architecture.md`](../architecture/active-architecture.md) §5/§6 (the causal chain, the attrs), THEMING (tokens), [`soma-architecture.md`](../architecture/soma-architecture.md) (Presence), [`sema.md`](../architecture/sema.md) (signature/signals), `arts/adom/README.md` (reduced-motion).