# Eidos Motion — Diseño y Arquitectura > **Estado: modelo de dos momentos — F1–F7 implementadas (2026-06-04).** > > Este documento describe el sistema de animaciones de Eidos. El modelo, anclado > en el canon (`GUIA_IMPLEMENTACION_SEMAUIX.md` §4.3, §7, §11), es: > > **Hay DOS momentos en una interacción, y CADA UNO puede llevar animación — > uno, el otro, o ambos:** > > - **Momento `--event`** (`data-event-*`): el flourish perceptivo durante el > *hold* de una señal de sema. Es el canal `motion` de la **firma perceptiva** > (§11), definido **por evento** (`family`/`intent`/`event`). Transitorio. > - **Momento `--state`** (`data-state`): la **transición** a/desde una condición > persistente. Definido **por componente** (prop `motion`). Persistente. > > Esto es lo que hace a UIX distinto de **todos** los frameworks actuales: los > demás colapsan la animación de presence en un solo eje (Chakra: solo > `data-state` + `Presence`). UIX anima los dos momentos y los integra con la > firma perceptiva — más coherente y más rico. > > **Implementado (F1–F6):** los dos momentos (keyframes + `signatures` + `presets` > + generación + motor `EngineMotion` + validación + tests + > demo `/temas/animations`); la **firma migrada** de `events.css` al registro > (`signatures`, F2); los **recipes migrados** a presets con override de > duración/easing por componente (F3); los **drivers JS** `spring` (física) / > `waapi` / `rect` (FLIP) + `Presence.motion` + un > overlay real (Dialog) brincando (F4); la **coreografía** stagger + presets > Material `shared-axis`/`fade-through` (F5); el **rigor de tokens** — firma cruda > tokenizada (holds largos `slower`/`deliberate`/`emphatic`/`sustained`), > shared-axis `--motion-distance-xl`, easing `emphasized`, set > `[data-motion-set='expressive']` (F6); el **typegen extensible** de nombres de > preset — registry augmentable `EidosMotionPresets` (F7). **Roadmap F1–F7 > completo.** `data-motion-ref` y el "TSC `event:*` scope" quedan obsoletos > (THEMING.md §13/§14, sin uso real). > > **Refactor (post-F5): el motor de motion es un SERVICIO.** El runtime ya no > vive en eidos: se reubicó a **`src/arts/motion`** (un *art* — artefacto de > runtime puro, sin dependencias de UI ni de otros arts), expuesto como > **`uix.motion`** y consumido por **ambas** capas — soma (`Presence` recibe > `motion: EngineMotion` y llama `motion.run(node, phase)`) y eidos (delega vía > `eidos.motion` + registra sus presets `css` en el servicio al boot). Esto > **disuelve el acoplamiento soma→eidos**: desaparecen la prop > `DialogProps.runMotion` y `eidos.motionRunner` — el *bridge* es ahora > `EngineMotion.run`, que lee `data-animation-style` del nodo. Eidos conserva la > **generación de CSS** (`lib/render-css.ts`) + los **datos** de > presets/keyframes/firmas (`lib/motion/presets/css.ts`); los tipos, el motor y > los drivers (`spring`/`waapi`/`rect`) viven en `$motion`. Ver > `src/arts/motion/README.md`. (Las secciones §5/§6/§9 + el árbol de archivos > abajo reflejan ya el nuevo hogar.) > **Extensión de USO — 3 dominios (2026-06-21).** Este documento describe el MOTOR, que tiene > **dos momentos** (event/state). A nivel de USO el prop `motion` cubre **tres** dominios: > **event** (la firma) · **state** (la transición per-componente) · **content** (el tercero: > contenido que entra/sale/loop, vía `motionAttrs` / `` / loops `spin`/`pulse`/… — una > capa de USO **sobre** la maquinaria de state-presets, NO un tercer momento del motor). El > state-domain dedicado (`data-motion-state`, piloto ``), la cascada container-driven en > ambas direcciones y el afford `[data-debug-stagger]` son posteriores a F7. **Para la guía > orientada a tareas** (recipes · catálogo de presets · loops · state-domain · staggered cascade · > reduced-motion · debug) → [`MOTION_GUIDE.md`](./MOTION_GUIDE.md). Para decisiones e historia > (incl. el motor "coordinado" retirado en el Plan A) → [`MOTION_SERVICE_RFC.md`](./MOTION_SERVICE_RFC.md). **TL;DR**: - Dos momentos animables: **`--event`** (`data-event-*`, la firma perceptiva, `signatures`) y **`--state`** (`data-state`, la transición per-componente, `presets`). Uno, el otro, o ambos; componen en secuencia (`sequence`). - Un registro (`EidosConfig.motion`) con tres mapas: `keyframes`, `signatures` (momento-evento), `presets` (momento-estado). - `data-state` es de **soma**; `data-event-*` es de **sema**; **eidos lee ambos y anima** (§7: la regla es no SOBRE-ESCRIBIR el atributo ajeno, no "un eje"). - Se usa: el momento-estado vía prop `motion="scale-fade"` → `data-animation-style`; el momento-evento es automático al disparar el evento (la firma). - Drivers: `css` (suelo, paridad Chakra) + JS (`waapi`/`spring`/`rect`/`svelte`, física/FLIP/genie/orquestación — más allá de Chakra), para cualquiera de los dos momentos. --- ## Tabla de contenidos 1. [Tesis y posicionamiento](#1-tesis-y-posicionamiento) 2. [Los dos momentos](#2-los-dos-momentos) 3. [Motion en las 4 capas](#3-motion-en-las-4-capas) 4. [Arquitectura — el registro de dos superficies](#4-arquitectura--el-registro-de-dos-superficies) 5. [Los tipos](#5-los-tipos) 6. [La API del `EngineMotion`](#6-la-api-del-enginemotion) 7. [Los drivers](#7-los-drivers) 8. [Contrato DOM](#8-contrato-dom) 9. [Integración con soma (`Presence`)](#9-integración-con-soma-presence) 10. [Reduced motion](#10-reduced-motion) 11. [Primitivas y keyframes](#11-primitivas-y-keyframes) 12. [Contenido inicial (signatures + presets)](#12-contenido-inicial-signatures--presets) 13. [Defaults por componente](#13-defaults-por-componente) 14. [Dónde vive el código](#14-dónde-vive-el-código) 15. [`events.css` ES el momento-evento](#15-eventscss-es-el-momento-evento) 16. [Comparación con Chakra UI v3](#16-comparación-con-chakra-ui-v3) 17. [Decisiones de naming](#17-decisiones-de-naming) 18. [Fases de implementación](#18-fases-de-implementación) 19. [Qué queda fuera / diferido](#19-qué-queda-fuera--diferido) --- ## 1. Tesis y posicionamiento Una interacción no tiene **un** momento animable, tiene **dos**, y son de naturaleza distinta: 1. La **ocurrencia perceptiva** — "algo acaba de pasar" — transitoria, con `hold`, `intent` y `sequence`. La escribe **sema** como `data-event-*`. 2. El **cambio de estado** — "ahora esto está abierto" — persistente, fuente de verdad. Lo escribe **soma** como `data-state`. `GUIA §4.3` lo deja explícito: `emerge` = "algo entra o sale del campo perceptivo" → es un **evento**, no un estado. `GUIA §11` define la **firma perceptiva por evento**, donde `motion` es uno de los canales (junto a sound/color/presence/haptic): el movimiento de salida de un `commit.delete + loss` es "retirada/descenso"; el de `signal.alert + threat` es "entrada saliente". El movimiento se define **por evento**. > **El diferenciador de UIX.** Todos los frameworks actuales animan presence en > **un solo eje** (Chakra: `data-state` + `Presence`; Radix/Ark: igual). UIX > distingue los dos momentos y **anima ambos**, integrando el momento-evento con > la firma perceptiva (sonido/haptic incluidos). Eso es lo que lo hace más > coherente (un solo modelo, dos momentos nítidos) y más rico (la animación de > "qué ocurrió" no se confunde con la de "en qué estado estamos"). **Lo que eidos posee**: el registro tipado de DATOS (`EidosConfig.motion` = `keyframes` + `signatures` + `presets`), la generación de CSS para ambas superficies, la prop `motion` y el attr `data-animation-style`. El **motor de ejecución** (`EngineMotion`) NO es de eidos: vive en `$motion` y se consume vía `uix.motion` (eidos delega + registra ahí sus presets `css`). **Lo que NO posee**: la emisión de la señal y su firma semántica → **sema**; el lifecycle mount/unmount y `data-state` → **soma `Presence`**; los attrs sobre los que se anima (`data-state`, `data-side`, `data-starting/ending-style`) → declarados en **morfo**; la pref `allow`/`reduce` → **`ActivePrefs`**. --- ## 2. Los dos momentos | | Momento **`--event`** | Momento **`--state`** | |---|---|---| | **Atributo** | `data-event-*` | `data-state` | | **Qué es** | la ocurrencia perceptiva (la señal) | la transición a/desde una condición persistente | | **Su animación** | el **flourish de la firma** (settle, pulse, retirada por intent, entrada de un `present`…) | la **transición de presencia/layout** (escala, slide, crecer a la altura abierta…) | | **Granularidad** | **por evento** (`family`/`intent`/`event`) — genérica, consistente en todo el sistema | **por componente** (prop `motion`) | | **Dueño del attr** | sema (lo estampa durante el `hold`) | soma (effects) | | **Naturaleza** | transitoria (vive el `hold`) | persistente | | **En el registro** | `signatures` | `presets` | | **Canon** | §11 (firma perceptiva), §4.3 (emerge), §7.1 | §7.2, §5.2 (`sequence`) | **Cada momento puede llevar animación — uno, el otro, o ambos:** - `press` (botón): solo momento-**evento** (`contact.press` → squeeze). Sin transición de estado. - `dialog` abriendo: el flourish del **evento** `emerge.present` **y** la transición de **estado** a `open`. - `collapse`: la transición de altura (**estado**) **y/o** el flourish del **evento** `expand`. **Componen en secuencia** (`GUIA §5.2`): `sequence: 'pre'` hace que el flourish del evento corra **antes** de que el estado cambie (p. ej. animar la salida antes de cerrar); `'post'`, después (celebrar tras el resultado real). El `active_architecture.md` §5 lo describe en la cadena causal: la animación del evento corre durante el `hold`, luego el estado toma el relevo. **`data-state` y `data-event-*` no se mezclan** (§7.3): la regla es que **sema no sobre-escribe** atributos de estado y viceversa (ownership). NO dice que solo uno pueda animarse — **eidos lee ambos y anima ambos**. Lo prohibido es pisar el nombre ajeno, no animar sobre los dos ejes. > **Coordinación de hijos (stagger / cascada) — modelo CERRADO, RFC §D.11.** Hubo un tercer > eje «coordinado» (`PresenceGroup` / presets `cascade-*` sobre `data-starting/ending-style`); > se **retiró** el 2026-06-19. El modelo final: la cascada NO es un sistema aparte — es el > **momento `--state`** (un preset declarado) + el **stagger que ya existía** > (`índice × --motion-stagger-each`, paralelo = 0, cascada = N) + **una regla de foundation** > que escribe el índice desde estructura (`[data-stagger] > *:nth-child → --motion-stagger-index` > / `:nth-last-child → -rev`, generada en `lib/render-css.ts`). La firma del evento (`emerge` → > `present-rise`) es solo el flourish del **contenedor** (genérico, sobre `data-event-*`); el > timing de los hijos es **realización per-componente**, no un canal de sema. Tres ejes > ortogonales (sema emite · motion es el motor · eidos materializa) + el lifecycle completo: > [§D.11 del RFC](./MOTION_SERVICE_RFC.md). > **Prop `motion` UNIVERSAL — un selector, tres dominios (encuadre de diseño, [RFC §D.12]).** Tras > retirar el coordinado, queda UN sistema → **un solo prop `motion`** (hoy `` aún usa > `animation`; se unifica). La meta: **cualquier** componente (no solo overlays) puede recibir > `motion="X"` de un catálogo registrado type-safe. Qué significa el prop lo decide el > **discriminante** «¿la animación realiza un evento perceptivo?»: **evento** → la firma manda, el > prop es override/violación (tipos/lint); **estado** → selecciona el state-preset (`data-state`); > **contenido** (sin evento) → el prop es la **vía primaria**. El acoplamiento con sonido/háptico > solo aplica al dominio evento — los de contenido quedan en paridad limpia. Detalle + plan (a)+(b): > [§D.12 del RFC](./MOTION_SERVICE_RFC.md). > **Restricciones estructurales del modelo CSS — [RFC §D.13].** Los contratos que el declarativo exige: > (1) **estructura** — `[data-stagger]` ↔ hijos DIRECTOS; `:nth-child` ignora comentarios/`{#if}` (blindado), > pero un wrapper-elemento intermedio rompe el conteo → el agrupador que cascadea se vuelve su propio scope > (`inherits:false` aísla el índice); `display:contents` sin re-scope = dead-zone. (2) **exit** — unit-exit por > `Presence` (retiene + espera, el `opacity` del padre arrastra a los hijos) funciona hoy; el staggered-exit > por-hijo exige JS de lifecycle (`PresenceGroup`, diferido) → la regla container-driven es ENTER-ONLY; **nunca > `{#if}` crudo** en superficie animada. (3) **debug** — el índice es `inherits:false` (no lo pisa un ancestro); > todo vive tipado en Computed; el modo opt-in `[data-debug-stagger]` lo materializa (un badge `::after` por > hijo con su índice, vía un `counter` que espeja `:nth-child - 1`). --- ## 3. Motion en las 4 capas | Capa | Qué aporta | Momento | |---|---|---| | **Morfo** | Declara los attrs: `data-state` + estados (`open`/`closed`), los **eventos** (`emerge`/`commit`/`signal` + `sequence`/`persistence`/`intent`), `data-side`/`data-align`, `data-starting/ending-style`. | ambos | | **Sema** | Estampa `data-event-*` (`family`/`intent`/`phase`/`id`) durante el `hold`; resuelve la firma (sound/haptic son canales runtime; **`motion`/`color`/`presence` los materializa eidos** leyendo `data-event-*`). | `--event` | | **Soma** | Escribe `data-state` vía effects; `Presence` mantiene el nodo durante la salida y espera la animación (`getAnimations()` + `Promise.all(finished)`). Dispara el evento con su `sequence`. | `--state` (+ dispara el evento) | | **Eidos** | `keyframes` + `signatures` (momento-evento, sobre `data-event-*`) + `presets` (momento-estado, sobre `data-state`) + **generación de CSS**; delega el motor JS a **`uix.motion`** (servicio) y registra sus presets `css` ahí al boot. **Lee ambos ejes y anima.** | ambos | Reduced-motion ya está cableado: `MotionEffective = 'allow' \| 'reduce'` (`libs/motion`), proyectado como `data-motion` por `ActivePrefsDomProjection`, trackeado por `ReducedMotionTracker` (`arts/adom`), expuesto en `ActiveDom.prefersReducedMotion.matches`. --- ## 4. Arquitectura — el registro de dos superficies ``` EidosConfig.motion ├── keyframes: { 'fade-in': {...}, 'scale-in': {...}, ... } @keyframes registrados │ ├── signatures: { ← MOMENTO --event (la firma, genérica por evento) │ 'emerge-present': { family:'emerge', event:'present', keyframes:['fade-in'], ... }, │ 'commit-settle': { family:'commit', keyframes:['settle'], ... }, │ 'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... } │ } → genera reglas [data-event-*][data-event-phase='active'] │ └── presets: { ← MOMENTO --state (transición per-componente) 'scale-fade': { driver:'css', enter:{...}, exit:{...} }, 'slide-fade': { driver:'css', enter:{ bySide }, exit:{ bySide } }, 'genie': { driver:'rect', enter, exit } (P3, JS) } → genera reglas [data-animation-style][data-state] │ ▼ uix.motion : EngineMotion (servicio · arts/motion — resuelve, corre drivers JS, respeta reduce) ▲ eidos.motion delega aquí + registra los presets css al boot │ ▼ Eidos lee data-event-* (firma) + data-state (transición) → anima ``` - **`signatures`** es la firma genérica: un `commit` asienta igual en todo el sistema; un `emerge.present` entra igual. Los packs de sema (`sema/components/*.ts`) afinan la firma de un componente concreto. Es lo que hoy hace `events.css` a mano (§15). - **`presets`** es la transición per-componente, elegida con la prop `motion`. - Theme/app pueden añadir u override en ambos mapas. --- ## 5. Los tipos `$motion` (`src/arts/motion/types.ts`) — reubicado a un *art*. `duration`/`ease` son `string` (desacoplados de `DurationKey`/`EaseKey`: eidos resuelve los tokens en su capa de generación, el art no conoce la escala): ```ts type ReducePolicy = 'instant' | 'opacity-only' | 'none' type MotionSide = 'top' | 'right' | 'bottom' | 'left' type KeyframeName = string // clave en motion.keyframes type MotionPresetName = string // valor de la prop `motion`; 'none' desactiva // Una fase CSS: keyframes compuestos + tokens + side-awareness. interface CssPhase { keyframes: KeyframeName | KeyframeName[] // coma-compuestos: ['scale-in','fade-in'] duration?: string // token key ('moderate'…) o crudo ('600ms') ease?: string transformOrigin?: string // p. ej. 'var(--floating-transform-origin)' bySide?: Partial> } // ── Momento --event: la firma perceptiva (genérica por evento) ── interface EventSignature { family?: string // data-event-family ('emerge' | 'commit' | 'signal' | …) intent?: string // data-event-intent (familias valenced) event?: string | string[] // data-event nombre(s)/prefijo(s) (['present','open'], …) keyframes: KeyframeName | KeyframeName[] duration?: string // token key O hold crudo ('600ms', fuera de escala) ease?: EaseKey fill?: 'none' | 'forwards' | 'backwards' | 'both' reduce?: ReducePolicy } // ── Momento --state: preset per-componente (prop `motion`) ── interface CssStatePreset { driver: 'css' enter?: CssPhase // [data-state='open'] exit?: CssPhase // [data-state='closed'] reduce?: ReducePolicy } interface JsStatePreset { // implementado (waapi/spring/rect/svelte) driver: 'waapi' | 'spring' | 'rect' | 'svelte' enter?: MotionRun; exit?: MotionRun requires?: ('sourceRect' | 'targetRect' | 'placement')[] reduce?: ReducePolicy fallback?: CssStatePreset // declarado; hoy NO se auto-aplica // (el driver spring honra ctx.reduced él mismo) } type StatePreset = CssStatePreset | JsStatePreset // ── El registro ── interface MotionConfig { keyframes?: Record signatures?: Record // momento --event presets?: Record // momento --state } ``` Decisión estructural: **el momento-evento es genérico por evento** (`signatures`, por `family`/`intent`), no empaquetado por preset — porque la firma (§11) se define por evento y debe ser consistente entre componentes. El ajuste fino per-componente del momento-evento va en los **packs de sema**. La prop `motion` solo elige el **preset de estado**. --- ## 6. La API del `EngineMotion` Vive en `$motion` (servicio `uix.motion`; `eidos.motion` y `soma.motion` lo exponen — la misma instancia). Registro + path declarativo CSS + **ejecución de drivers JS**. El caller pasa `MotionRunOptions` (`dom`, `reduced`, `side`, `sourceRect`/`targetRect`, `duration`/`ease`) para los drivers que la necesiten. ```ts interface EngineMotion { register(name: string, preset: StatePreset): void resolve(name: string): StatePreset | undefined has(name: string): boolean list(): string[] // css: declarativo (handle settled); js: construye el MotionContext, corre el // driver, normaliza el retorno a un handle y lo trackea por elemento. enter(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle exit(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle // El bridge de Presence (reemplaza al viejo `runner`): lee `data-animation-style` // del nodo y delega a enter/exit (css → handle settled; js → corre el driver). run(el: HTMLElement, phase: 'enter' | 'exit', opts?: MotionRunOptions): MotionHandle cancel(el: HTMLElement): void // cancela handles JS activos pending(el: HTMLElement): Promise // finished combinado (para Presence) dispose(): void // cancela todo (cleanup del servicio) } ``` - **State preset `css`** — declarativo: el wrapper pone `data-animation-style` y la regla CSS `[data-animation-style][data-state]` + la `Presence` de soma hacen el resto. El runtime no lo "corre". - **State preset JS** — corre el driver (`spring`/`waapi`/`rect`/`svelte`), normaliza el retorno (`Animation` | `Animation[]` | `MotionHandle`) a un handle único, lo trackea por elemento (`cancel`/`pending`), respeta la `ReducePolicy`. - El **momento-evento** (signatures) no pasa por `enter/exit`: es CSS generado que reacciona a `data-event-*`. ### 6.1 — Política de cleanup (WAAPI / spring) `fill: forwards` deja `Animation`s colgando en `getAnimations()` → rompería la espera de exit de `Presence`. Disciplina: el reposo vive en el CSS de `[data-state]`; los presets `waapi` animan **sin `fill: forwards`** y el runtime hace `cancel()` al terminar; para reposos no expresables en CSS (FLIP medido), `commitStyles()` + `cancel()`. --- ## 7. Los drivers Aplican a **cualquiera de los dos momentos** (un signature o un state preset). | Driver | Sustrato | Para qué | Más allá de CSS | |---|---|---|---| | **`css`** | `@keyframes` + tokens (generado) | fade / scale / slide / collapse / firma | — (suelo Chakra) | | **`waapi`** | `el.animate()` | keyframes computados en runtime | distancia/tamaño medidos, cancelable | | **`spring`** | integrador semi-implícito de Euler (RAF vía `ActiveDom`) | overshoot/settle, drag, snap-back | física real — lo que ninguna cubic-bezier expresa | | **`rect`** | medición de rects (vía `ActiveDom`) → WAAPI | FLIP, shared-element, genie | animar entre dos posiciones reales | | **`svelte`** (opt-in) | `transition:` / `animate:flip` | reorder de listas `{#each}` | declarativo de Svelte | **Bundle**: `css` (generado) es el suelo. `spring` es un integrador **self-contained** (sin dependencia — un muelle independiente por propiedad, stepeado por `ctx.dom.requestFrame`); `waapi` envuelve `el.animate` (API del browser, ya visible a `getAnimations()`); `rect` (FLIP) compone medición + WAAPI. Los helpers viven en `lib/motion/presets/js.ts` (`spring()` / `waapi()` / `rect()`). `svelte` (opt-in) se reserva para `animate:flip` en `{#each}`. Adapter externo (`motion-one`) opt-in. **Matiz `svelte`**: las transiciones de `{#if}` pelearían con la `Presence` de soma; el camino core es WAAPI/spring (compone con `getAnimations`). `svelte` se reserva para `animate:flip` en `{#each}` (Svelte dueño del lifecycle). **Batching `rect`**: FLIP con N elementos hace su pasada en dos fases sobre un único `requestFrame` (medir todos los source → mutar → medir todos los target → animar). `ActiveDom` ya da `requestFrame`; el batching es del driver, no de `ActiveDom`. --- ## 8. Contrato DOM **Momento `--event`** (lo escribe sema durante el hold; eidos reacciona): ```css [data-event='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 …; } ``` **Momento `--state`** (lo escribe soma; eidos reacciona). El wrapper pone `data-animation-style` (la prop `motion`); el reposo lo da el recipe sobre el mismo `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 …; } ``` Prop pública: `motion?: MotionPresetName | 'none'` (default por componente; `'none'` desactiva). Placement-aware: lee `data-side`/`data-align` (morfo) + `--floating-transform-origin` (soma floating). **NO `data-motion`**: ya tiene dos dueños (la pref `allow`/`reduce` + la dirección de NavMenu). El attr del motor es `data-animation-style` (espejo del `animationStyle` de Chakra). Ver §17. --- ## 9. Integración con soma (`Presence`) `soma/layers/presence.svelte.ts` ya hace el lifecycle: al cerrar mantiene el nodo, espera `node.getAnimations()` + `Promise.all(finished)` (guard de `runId`, flag `enabled`), y desmonta. Las CSS animations (ambos momentos) y las de `el.animate()` (drivers JS) aparecen en `getAnimations()` → **se esperan sin plumbing nuevo**. **`Presence.motion`** (F4 · refactor a servicio) — el hook para drivers que NO están en `getAnimations()` (el `spring`, RAF puro): `Presence` recibe `motion: EngineMotion` y llama **`motion.run(node, phase)`** al entrar/salir; `run` arranca la animación JS y devuelve un `MotionHandle`, cuyo `finished` `Presence` espera ALONGSIDE `getAnimations()`. Para un preset `css` (o un nodo sin `data-animation-style`), `run` devuelve un handle ya *settled* → path declarativo sin cambio. soma **NO importa eidos**: consume el servicio vía `soma.motion` (= `uix.motion`), la misma instancia que `eidos.motion`. Cableado en el provider de Dialog (`contentPresence`/`overlayPresence` reciben `motion: this.soma.motion`); el mismo patrón vale para cualquier overlay — basta pasar `motion` a su `Presence`, **sin prop ni acoplamiento** (el viejo `DialogProps.runMotion` / `eidos.motionRunner` desapareció en el refactor). **Orquestación de hijos** (pendiente, en re-diseño): cuelga de la firma del evento (sema) materializada por eidos, no de un motor paralelo — ver Apéndice D del [`MOTION_SERVICE_RFC.md`](./MOTION_SERVICE_RFC.md). ### 9.1 — SSR / hidratación Los overlays montan al abrir en cliente; motion dispara en transiciones, no en el primer montaje (un elemento montado-abierto suprime el enter). Sin handoff SSR→JS en vuelo; el `fallback` CSS cubre sin-JS. ### 9.2 — Exit + a11y: `inert` (de soma) Durante el exit el nodo sigue montado: debe ser `inert`/`aria-hidden` y no foco. El `focus.return` del morfo ya devuelve el foco; el `inert` durante la ventana de exit es mejora de `Presence`/`Dismissal` de soma (ya usa el patrón en `color-field`). --- ## 10. Reduced motion - **CSS** (ambos momentos): reglas `[data-motion='reduce'] …` + `@media (prefers-reduced-motion)` por política (`instant` → `animation: none`; `opacity-only` → solo fade; `none` → íntegro). - **Runtime (drivers JS)**: `MotionContext.reduced` de `ActiveDom.prefersReducedMotion`; aplica la `ReducePolicy` antes de correr. - Morfo declara `a11ySemantic.reducedMotionFallback` por evento; soma silencia la señal cuando aplica. El motor es coherente con esa decisión. --- ## 11. Primitivas y keyframes Tokens existentes (`STATIC_MOTION`, generados, leídos por css y js): | Token | Valores | |---|---| | `--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` (pickup de drag — el único `>1`, ver §Draggable surfaces en THEMING) | **Vars de override (runtime, NO tokens de theme)** — un componente las pone en su elemento; el preset las lee con fallback al token: | Var | Para | Notas | |---|---|---| | `--motion-duration-{enter,exit}` | timing por componente (F3) | `@property inherits:false` | | `--motion-ease-{enter,exit}` | curva por componente (F3) | `@property inherits:false` | | `--motion-slide-leave` | extra de `slide-full` para paneles inset (drawer) | default `0px` | | `--height` / `--collapsed-height` | altura medida del `collapse` (soma la escribe) | — | | `--motion-stagger-each` | ritmo del stagger (en el contenedor, hereda) | default `0ms` → **paralelo**; N → cascada | | `--motion-stagger-index` / `-index-rev` | índice por ítem **desde estructura** — `[data-stagger] > *:nth-child` (fwd, enter) / `:nth-last-child` (rev, exit); generado en `render-css.ts`, **nadie lo escribe** | `@property inherits:false`, `` | Keyframes (`EidosConfig.motion.keyframes`, paridad `theme.keyframes` de Chakra): `translate`/`scale` individuales (componen sin pisarse); parametrizados por CSS var para tamaños/distancias dinámicas (`var(--height)`, `var(--collapsed-height,0)`). Built-in: `fade-in/out`, `scale-in/out`, `slide-from/to-{side}[-full]`, `expand/collapse-height`; la **firma** (`announce-pulse-*`, `commit-settle`, `press-squeeze`, `dismiss-fade`, `present-rise`); **Material** (`slide-axis-{x,y}-{in,out}`, `scale-from-92`). --- ## 12. Contenido inicial (signatures + presets) **`presets` (momento `--state`)** — built-in (`css`): | Nombre | Notas | |---|---| | `fade` · `scale-fade` | enter+exit; scale-fade con `--floating-transform-origin` | | `slide-fade` | side-aware (`data-side`): slide + scale 0.985 + fade | | `slide-full` | drawer por borde, inset-aware (`--motion-slide-leave`) | | `collapse` | altura medida (`var(--height)`) | | `shared-axis-x/y` · `fade-through` | Material 3 (F5) | Los **drivers JS** (`spring`/`waapi`/`rect`, `presets/js.ts`) se registran por app/demo. El primer **built-in JS por nombre** es `spring-pop` (`driver:'spring'`, `presets/js.ts` → `BUILTIN_JS_PRESETS`), registrado directo en `ActiveEidos` — no vía el `EidosConfig.motion.presets` serializable, que no puede portar sus funciones `MotionRun` (`structuredClone` falla). Demos adicionales registran `panel-spring`, `dialog-spring`, `flip`. **`signatures` (momento `--event`)** — built-in, migradas de `events.css` (F2, §15): `present`/`dismiss`, `commit` (+ intents `fulfill`/`affirm`/`threat`), `press` (`contact`), `announce` (+ 5 intents). Genéricas por `family`/`intent`/`event`. --- ## 13. Defaults por componente El **momento-estado** por componente (prop `motion`, default del wrapper): | Componente | Parte | 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` | El **momento-evento** es automático: al disparar el evento (p. ej. `commit`, `emerge.present`), la firma `signatures` correspondiente reacciona — sin prop. --- ## 14. Dónde vive el código ``` arts/motion/ ← EL SERVICIO (art puro: sin deps de UI ni de otros arts) types.ts ✓ MotionDom (port estructural), MotionConfig, EventSignature, StatePreset, MotionHandle… engine-motion.ts ✓ createEngineMotion → EngineMotion (register/resolve/enter/exit/run/cancel/pending/dispose) drivers.ts ✓ drivers JS: spring() (física) / waapi() / rect() (FLIP) index.ts ✓ barrel `$motion` (re-exports nombrados) + README.md arts/active-app/service-factories/motion.ts ✓ defineEngineMotion (factory; coreDep 'dom') eidos/lib/motion/presets/css.ts ✓ DATOS: keyframes + state presets + signatures (BUILTIN_*) + Material (F5) eidos/lib/config-types.ts ✓ EidosConfig.motion (tipo MotionConfig de $motion) eidos/lib/render-css.ts ✓ GENERA CSS: presets ([data-state]) + signatures ([data-event-*]) + overrides + stagger eidos/lib/config.ts ✓ valida presets (css + js) + signatures eidos/lib/themes/base.ts ✓ built-ins DATA (keyframes + signatures + presets) eidos/active-eidos.svelte.ts ✓ eidos.motion → delega a uix.motion + registra los presets css al boot eidos/components/dialog/ ✓ wrapper: prop motion → data-animation-style (sin runMotion) active-uix/active-uix.svelte.ts ✓ uix.motion : EngineMotion (createActiveUix lo crea; attach lo lee del app) soma/core/soma.svelte.ts ✓ soma.motion → uix.motion soma/layers/presence.svelte.ts ✓ opt `motion: EngineMotion` → motion.run(node, phase) (gating JS) + tests ``` (`✓` hecho. Reubicado a `arts/motion` en el refactor a servicio. Pendiente: pasar `motion` a más overlays (drawer/popover/…); drivers JS built-in por nombre; F6/F7.) --- ## 15. `events.css` ES el momento-evento `events.css` ERA "la superficie `--event` implementada a mano". En **F2 se migró a `signatures`**: las 9 keyframes + 12 reacciones (announce pulse por intent, commit settle, dismiss fade, press squeeze, present rise) viven ahora en `EidosConfig.motion.{keyframes,signatures}` (`presets/css.ts` → `BUILTIN_SIGNATURES`) y se generan a `generated/base.css` — theme-extensible. `events.css` quedó reducido a dos concerns globales: el hint de compositor (`will-change`) y el cap de reduced-motion (el `motion` de la firma se silencia bajo reduce, pero sonido + haptic siguen comunicando — la ventaja cross-modal). `archetypes.css` (4 transitions baseline de hover/focus) se queda como micro-interacción transversal. --- ## 16. Comparación con Chakra UI v3 | Capacidad | Eidos | Chakra v3 | |---|---|---| | Momento `--state` (`data-state` + presets nombrados) | ✅ `presets` + prop `motion` | ✅ `animationStyle` + `data-state` | | `Presence` (hold-through-exit) | ✅ soma (`getAnimations`, cubre transitions) | ✅ (`animationend`) | | keyframes registry + tokens | ✅ | ✅ | | placement-aware | ✅ `data-side` | ✅ `data-placement` | | **Momento `--event` (firma perceptiva)** | ✅ `signatures` + sema (intent/hold/sequence) | ❌ **no existe** | | **Los dos momentos integrados** | ✅ | ❌ (solo `--state`) | | física / FLIP / orquestación (drivers JS) | ✅ `spring` / `rect` / `waapi` | ❌ | El **momento `--event` integrado con la firma perceptiva** (y con sonido/haptic) es lo que ningún framework actual tiene. Chakra anima el estado; UIX anima el estado **y** la ocurrencia, distinguiéndolos. --- ## 17. Decisiones de naming - Attr del momento-estado: **`data-animation-style`** (NO `data-motion`, que está reservado para la pref de reduce-motion `allow`/`reduce`). - Attrs del momento-evento: los `data-event-*` que sema ya estampa. - **Limpieza (F7)** ✓: el `data-motion` direccional de NavMenu (un rename planeado) **no existe** en el código — moot. `data-motion-ref` y el "TSC `event:*` scope" se descartan (sin uso real). --- ## 18. Fases de implementación Roadmap «superar a todos los frameworks». **F1–F6 implementadas:** - **F1 — Fundación de dos momentos** ✓: tipos (`MotionConfig` {keyframes, signatures, presets}, `EventSignature`, `StatePreset`) + generación (ambas superficies) + motor `EngineMotion` (servicio `uix.motion`, reubicado a `$motion` en el refactor) + validación + tests. - **F2 — Firma cross-modal** ✓ (supera SwiftUI): `events.css` migrado a `signatures` (§15); el `motion` de la firma + sonido + haptic salen del MISMO evento (acoplamiento que el `.sensoryFeedback` de SwiftUI deja suelto). - **F3 — Presence pulida** ✓ (iguala Base UI): Dialog/Popover/Drawer/Accordion migrados a presets; override de **duración + easing por componente** (`--motion-duration/ease-{enter,exit}` + `@property inherits:false`), slide inset-aware (`--motion-slide-leave`), bridge de altura medida (`--height`). Cero regresión de timing. - **F4 — Motor mecánico JS** ✓ (iguala Framer): drivers `spring` (física real) / `waapi` / `rect` (FLIP); `Presence.motion` → `motion.run(node, phase)` (el motor como servicio `uix.motion`, consumido por soma y eidos **sin acoplamiento**); Dialog real brincando. - **F5 — Coreografía** ✓ (iguala Material 3): stagger declarativo (`--motion-stagger-{index,each}`), presets `shared-axis-x/y` + `fade-through`; container-transform vía el driver `rect`. - **F6 — Rigor de tokens** ✓ (iguala Carbon): la firma cruda **tokenizada** — la escala de duración gana el tramo largo de holds perceptivos (`slower` 400 · `deliberate` 600 · `emphatic` 800 · `sustained` 1000ms); los pulsos `announce` escalan por severidad de intent (deliberate→emphatic→sustained, alineado con los holds-by-intent del libro). Shared-axis viaja el `--motion-distance-xl` (30px) canónico; fade-through usa `--motion-scale-through` (0.92); `press`/`commit` snapean a la escala existente (sub-perceptible). Easing **`emphasized`** (M3 emphasized-decelerate). **Sets productive/expressive** (Carbon): `primitives.motion.expressive` emite un scope `[data-motion-set='expressive']` que remapea el easing — productive es el default. La duración-escalada-por- distancia queda como **convención de emparejamiento** (distance token ↔ duration token), no fórmula runtime (sin consumidor CSS hoy — el driver `rect` la haría por medición si un consumidor lo pide). - **F7 — Extensibilidad + typegen** ✓ (más allá de todos): los nombres de preset del momento-`--state` son **type-safe + app-extensibles** vía un registry augmentable — `EidosMotionPresets` (espejo de `SemaChannelSignatures`), con `MotionPresetName = keyof EidosMotionPresets | 'none' | (string & {})`. Una app añade presets type-safe con `declare module '$uix/eidos' { interface EidosMotionPresets { … } }`: la prop `motion` los autocompleta y un typo es error de compilación. El motor (`$motion`/`uix.motion`) queda `string` (abierto en runtime) — el registry es una ergonomía de compile-time sobre las props; un test fija el set built-in contra él. El `data-motion` direccional de NavMenu ya no existe (rename moot). `transition` con grupos nombrados queda **diferido** (sin consumidor; `getAnimations({ subtree: true })` lo cubriría). **Roadmap F1–F7 completo.** Cada fase mantuvo **cero regresión** para quien no usa `motion` ni dispara eventos nuevos. --- ## 19. Qué queda fuera / diferido - Adapter `motion-one` u otro motor JS externo (opt-in, nunca dependencia base). - Drivers exóticos (timelines complejos) — solo con consumer real. - Ampliar durations a 7 pasos (estilo Chakra) — solo si 5 se queda corto. --- **Última revisión**: 2026-06-21 — cierre del dominio del prop `motion` (§D.12): **dominio estado** (atributo dedicado `data-motion-state` + preset énfasis `select-pop`, piloto ``), **loops de contenido** (`spin`/`pulse`/`ping`/`bounce`, reglas un-gated infinitas) + fix del exit de `` (`tick` no-op), afford de debug `[data-debug-stagger]`, y primer **built-in JS por nombre** `spring-pop` (driver `spring` estrenado, registrado en `ActiveEidos`; demostrado sobre `Popover.Content motion="spring-pop"`). Demo de loops+lifecycle en `web/routes/demos/motion`. Previo (2026-06-04): F1–F7 implementadas (dos momentos + firma cross-modal + presence pulida + motor mecánico JS + coreografía + rigor de tokens + typegen extensible) + motor reubicado a `arts/motion` (servicio `uix.motion`). Si el código diverge, el código gana; abre un issue. Referencias: `GUIA_IMPLEMENTACION_SEMAUIX.md` (§4.3, §7, §11 — el modelo evento/estado/firma), `active_architecture.md` §5/§6 (cadena causal, attrs), `THEMING.md` (tokens), `soma/SOMA_ARCHITECTURE.md` (Presence), `sema/README.md` (firma/señales), `arts/adom/README.md` (reduced-motion).