# RFC — Servicio de motion de UIX (coordinación de presencia cross-layer) > Hermano de `eidos-motion.md` (que **extiende**, no contradice) y de los RFC de engine > (`COLOR_ENGINE_RFC.md`, `DEPTH_ENGINE_RFC.md`, …). A diferencia de aquéllos —engines > **visuales** de eidos— éste es un **servicio cross-layer** (morfo + soma + eidos + arts): > de ahí `_SERVICE_` y no `_ENGINE_`. > > **Qué resuelve.** Hoy el motion de UIX anima **superficies aisladas**: `EngineMotion` es > por-nodo, `Presence` coordina una sola superficie, y la coreografía entre superficies > (incluida la de los hijos) se **delega a la app**. Este RFC diseña que el desarrollador > pueda **aplicar una animación a cualquier componente sin nada más** —el sistema ya sabe > sobre qué superficie— y que las superficies se **coordinen** (secuencia / paralelo / > stagger, padre↔hijo). > > **La línea que ordena todo el documento es state ↔ visual:** > > - **morfo** declara la _estructura_ (qué partes son superficies animables y sus > dependencias de coordinación); > - **soma** coordina la _presencia / lifecycle_ del árbol (mount / unmount / await / > cancel) — **nunca el cómo-se-ve**; > - **eidos** posee el _QUÉ visual_ y los _valores expresivos_ (keyframes, easing, stagger-ms); > - **arts/motion** ejecuta primitivas **por-nodo** (no orquesta, no gana jerarquía). > > **Estado: M1–M4 + M5/M6 + M9 implementados** (contrato morfo · `PresenceGroup` · exit con > retención de DOM · interrupción/reversa §8.3 · enrutado de la prop `animation` · stagger > auto-derivado · **segunda coordinación: el modo children-DOM, `DomCascade`, cableado en el > `dropdown-menu` real — Apéndice C**). Consumidores reales: `Reveal`/`Rail` (modo registered- > `Presence`, §15) y `dropdown-menu` (modo children-DOM, Apéndice C). Pendiente: M7 (reduced-motion > + SSR hardening) · M8 (text-effects) · F3 (submenús). Todo es **aditivo y opt-in**: un componente > sin la nueva declaración funciona exactamente como hoy. ## Tabla de contenidos - [0. Tesis](#0-tesis) - [1. El estudio — referentes y dónde topan](#1-el-estudio--referentes-y-dónde-topan) - [2. No-goals explícitos](#2-no-goals-explícitos) - [3. Dónde está UIX hoy (estado verificado + gap)](#3-dónde-está-uix-hoy-estado-verificado--gap) - [4. Superficie animable declarada en morfo (solo estructura)](#4-superficie-animable-declarada-en-morfo-solo-estructura) - [5. Variants separables + naming (`animation` ↔ `data-animation-style`)](#5-variants-separables--naming-animation--data-animation-style) - [6. Routing automático vs choreography opt-in](#6-routing-automático-vs-choreography-opt-in) - [7. Coordinación de presencia — el `PresenceGroup`](#7-coordinación-de-presencia--el-presencegroup) - [7.1. El árbol de presencia es lógico (context), NO DOM](#71-el-árbol-de-presencia-es-lógico-context-no-dom) - [8. Salida, interrupción y reversa (el caso difícil)](#8-salida-interrupción-y-reversa-el-caso-difícil) - [9. Motor: agregación por el `PresenceGroup`, no `subtree:true` ciego](#9-motor-agregación-por-el-presencegroup-no-subtreetrue-ciego) - [10. Reduced-motion · firma semántica · SSR/hydration](#10-reduced-motion--firma-semántica--ssrhydration) - [11. Integración de los efectos WIP](#11-integración-de-los-efectos-wip) - [12. Encaje con las 4 capas + qué NO hace](#12-encaje-con-las-4-capas--qué-no-hace) - [13. Validación — prototipo exit-heavy](#13-validación--prototipo-exit-heavy) - [14. Impacto sobre el código actual + migración](#14-impacto-sobre-el-código-actual--migración) - [15. Roadmap de implementación por fases](#15-roadmap-de-implementación-por-fases) - [Apéndice A — decisiones resueltas vs abiertas](#apéndice-a--decisiones-resueltas-vs-abiertas) - [Apéndice B — Convivencia con la firma sema (los tres sistemas visuales)](#apéndice-b--convivencia-con-la-firma-sema-los-tres-sistemas-visuales) - [Apéndice C — M9: el modo children-DOM (la segunda coordinación)](#apéndice-c--m9-el-modo-children-dom-la-segunda-coordinación) --- ## 0. Tesis > **Una animación se _aplica_, no se _envuelve_. La coreografía se _declara_ en el contrato, > no se _escribe_ en cada call-site. Y la coordinación entre superficies es lifecycle > (state), no pintura (visual).** Hoy, para animar de forma coordinada hay que envolver elementos a mano (Framer: `` por nodo + ``) o escribir timelines imperativas (GSAP). UIX ya tiene un contrato declarativo cross-layer (**morfo**) y un lifecycle de presencia headless (**`Presence`**, en soma). La apuesta de este servicio es **proyectar la coreografía sobre ese contrato**: el componente declara _qué partes son superficies animables y cómo se coordinan_, soma generaliza `Presence` de una superficie a un **árbol de presencia**, y el desarrollador solo **nombra** la animación. El sistema sabe **dónde** aplicarla (la superficie está declarada) y **cuándo** (la coordinación está declarada). Tres propiedades que ningún referente reúne, y que NO incluyen "coreografía declarativa" a secas (Framer ya la tiene): - **(a)** la coreografía vive en el **contrato (morfo)**, una vez por componente, no en el JSX de cada uso; - **(b)** **enrutado automático a la superficie** — Framer no lo tiene porque allí envuelves manualmente; - **(c)** **composición con la firma semántica** (sema): la misma `commit + fulfill` que dispara sound/haptic/visual coordina también la entrada de los hijos. --- ## 1. El estudio — referentes y dónde topan | Framework | Modelo | Coreografía padre↔hijo | Límite estructural | | ---------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **Framer Motion** | `variants` declarativos + `` para exit | ✅ `when: 'beforeChildren'/'afterChildren'`, `staggerChildren`, `delayChildren` | la coreografía vive en el **JSX de la app** (cada call-site la cablea); el target se **envuelve a mano** (``); atado a React | | **GSAP** | timelines imperativas (`tl.to(...).to(..., '<')`) | ✅ manual, control total | **imperativo**: el guion se escribe a mano; ninguna relación con el contrato del componente | | **Svelte transitions** | directivas `in:`/`out:`/`transition:`, `animate:flip` | ⚠️ parcial (`flip` por lista; sin `when`/stagger jerárquico) | por-elemento; sin coordinación de árbol con dependencias | | **AutoAnimate** | `autoAnimate(el)` zero-config sobre cambios de children | ❌ (anima cambios, no coreografía dirigida) | una sola caja; sin secuencia/paralelo declarado | | **Chakra (Ark)** | `data-state` + `Presence` (un eje) | ❌ | colapsa la animación de presence en un solo eje; sin firma ni árbol | **El límite común** —incluso Framer, el más fino— es que la coreografía es **algo que el consumidor escribe en cada uso** y el target es **algo que el consumidor envuelve**. Nadie la trata como **propiedad declarada del componente** ni la **compone con la semántica del evento**. Ahí está el hueco (a)(b)(c) de §0. > **Honestidad obligatoria (§1 no sobreafirma):** Framer Motion **ya hace** coreografía > declarativa padre↔hijo. Nuestro `children: { enter, exit }` (§4) es ~1:1 con su `when`. Reclamar > "coreografía declarativa" como novedad sería falso y haría perder credibilidad al resto. > Lo nuevo es **dónde vive** (el contrato, no el call-site), el **enrutado** y la > **composición con sema**. --- ## 2. No-goals explícitos Declarar el alcance evita que un revisor lo pida y que el servicio se desenfoque. 1. **Layout animations / shared layout / reordenación automática** — **no-goal a propósito.** El "shared layout" de Framer (un elemento que parece viajar entre dos posiciones de layout, `layoutId`) NO entra. Matiz técnico: el driver `rect` (FLIP) de `arts/motion` mide **un solo nodo** (`el.getBoundingClientRect()` antes/después) — sirve para que **una** superficie absorba su propio salto de layout, **no** para transiciones de elemento compartido entre dos árboles. El RFC corta esa expectativa de raíz. 2. **Fondos / efectos WebGL** (`web/routes/demos/animations/background/*`: aurora, galaxy, particles, liquid-image, …) — **fuera del servicio.** No son superficies enter/exit; son efectos ambientales full-canvas. Pertenecen a otro eje (un art / componentes "ambient" tipo ``), que se diseñará por separado. 3. **Reinventar GSAP** — el servicio **no** construye timelines imperativas de propósito general. El JS coordina **presencia** (timing de mount/unmount/await), no compone tweens arbitrarios. Lo que **sí** es goal: los **text-effects** WIP (blur/count/scrambled/…) entran como candidatos a _variants de contenido_ (§11), porque sí son animación de contenido al entrar. --- ## 3. Dónde está UIX hoy (estado verificado + gap) Verificado leyendo el código, no por memoria: - **El motor es por-nodo.** `EngineMotion.run(el, phase)` lee el `data-animation-style` de _ese_ nodo, resuelve el preset y lo corre; `pending(el)` / `cancel(el)` se indexan por elemento (`Map>`). No conoce árbol, padres ni hijos. → `src/arts/motion/engine-motion.ts`. - **`Presence` es una isla por superficie.** Coordina una sola superficie (`opts.ref.current`) y espera `node.getAnimations()` **sin `{ subtree: true }`**. Un Dialog o un ContextMenu crean **varios `new Presence` independientes** (overlay, content) que coinciden en el tiempo por casualidad, no por coordinación. → `src/uix/soma/layers/presence.svelte.ts`. - **La coreografía se delega a la app.** El propio código de los presets Material shared-axis lo dice: `// Per-element here; an app triggers both sides together for the "shared" effect.` → `src/uix/eidos/lib/motion/presets/css.ts`. - **La selección del variant vive en eidos.** El momento-estado se aplica vía prop `motion="scale-fade"` → atributo `data-animation-style`. → `eidos-motion.md` (TL;DR). - **morfo no declara nada de animación.** `MorfoPart` declara `data` / `aria` / `keyboard` / `states` / `archetype` / `parts`, pero **ningún concepto de "superficie animable"** ni de coordinación. Lo más cercano es `events[].semantic.sequence` (`pre|coincident|post`), que ordena el momento-evento vs el momento-estado **del mismo componente**, no entre superficies. → `src/uix/morfo/types.ts`. **El gap, en una frase:** UIX anima superficies sueltas; no hay un árbol de presencia que las coordine. Este RFC añade ese árbol **en soma** (donde ya vive el lifecycle de presencia), declarado **en morfo** y vestido **en eidos**. --- ## 4. Superficie animable declarada en morfo (solo estructura) Una `MorfoPart` gana un campo opcional. **Solo lleva estructura** — nada visual ni expresivo: ```ts // src/uix/morfo/types.ts — añadido a MorfoPart animation?: { /** Esta part es una superficie animable: tiene lifecycle de presencia coordinable. */ surface?: boolean /** Dependencia de coordinación con las superficies hijas (opt-in: declararlo activa la coreografía). */ children?: { /** * Relación temporal del padre con sus hijos, INDEPENDIENTE por fase. Cada valor: * - 'before' → el padre actúa ANTES (enter: entra y luego suelta a los hijos; * exit: se va antes que ellos) * - 'after' → el padre actúa DESPUÉS (exit: RETIENE su DOM hasta que los hijos * terminen de salir — §8.1) * - 'together' → padre e hijos en paralelo (default de la fase ausente) */ enter?: 'before' | 'after' | 'together' exit?: 'before' | 'after' | 'together' } } ``` **Por qué `enter`/`exit` por separado y no un `when` simétrico.** Un único `when` ata las dos fases a la misma relación, y el caso más común —un **contenedor que bracketea a sus hijos**— **no es simétrico**: entra ANTES que ellos (`enter: 'before'`) y sale DESPUÉS (`exit: 'after'`, reteniendo su DOM hasta que terminen). El prototipo (§13) lo destapó: con un `when` simétrico el bracket era inexpresable. Separar las fases lo hace declarable sin coste — la fase ausente cae a `'together'`. **Por qué van en morfo y el stagger-ms NO.** `enter`/`exit` tienen **consecuencia de lifecycle**: `exit: 'after'` obliga a soma a **retener el DOM del padre** hasta que los hijos terminen su salida (§8.1). Eso es state, lo necesita soma → es estructura → morfo. En cambio el **valor** del stagger (80 ms), el easing, los keyframes y el orden visual son **expresivos** → viven en eidos (tokens/recipe), nunca en morfo. La regla 2-de-3 lo confirma: el campo lo consumen **soma** (lifecycle) y **eidos** (lo viste) — dos capas → justificado en el contrato. > **Nota sobre `order`.** El _orden_ del escalonado (forward/reverse) es expresivo y vive en > eidos. Soma no lo necesita: el escalonado lo pinta el CSS (§7). Lo que soma observa es el > **orden de registración** (§7.1), que el RFC fija = document order. El compilador (`compile.ts`) traduce este campo a un _plan de coordinación_ que el `PresenceGroup` consume; el validador (`schema.ts`) comprueba que `children` solo aparezca en parts con `surface: true` y que las partes referenciadas existan. --- ## 5. Variants separables + naming (`animation` ↔ `data-animation-style`) **El objetivo de DX:** el dev escribe `animation="scale-fade"` en **cualquier** componente animable y el sistema lo **enruta** a la superficie declarada (la part con `surface: true`), sin que el dev sepa qué part es. "El sistema ya sabe dónde." Esto convierte un variant en **separable y reutilizable**: `scale-fade` no pertenece al Dialog; pertenece al catálogo y se aplica a cualquier superficie. Es el momento-estado de `eidos-motion.md`, elevado de "prop de un wrapper concreto" a "prop transversal enrutado". **Decisión M5 (TOMADA): `animation` y `motion` quedan SEPARADAS por rol.** La versión original de este RFC recomendaba aquí _unificar_ (`animation` como cara DX y `motion` como alias deprecable), asumiendo que ambas nombraban el MISMO eje (el momento-estado elevado a transversal). **El hallazgo M6 lo invalidó** (el «hallazgo gemelo» de más abajo + Apéndice B): `motion` y `animation` gobiernan sistemas visuales **distintos** — un state-preset (`data-state`, dispara al MONTAR) metido en un coordinado ROMPE la cascada. Es **distinción, no duplicación**; y lo "caro" era la duplicación, no la distinción. Se mantienen separadas porque el **nombre de la prop comunica el sistema** a nivel de propiedad, sin leer docs: | Prop | Sistema | Reacciona a | Capa dueña | Catálogo type-safe | | --------------- | ----------------- | ------------------------------ | ---------- | ---------------------------------------- | | **`motion`** | momento `--state` | `[data-state]` | eidos | `EidosMotionPresets` (`scale-fade`…) | | **`animation`** | coordinado | `[data-starting/ending-style]` | soma | `MotionCoordinatedPresets` (`cascade-*`) | Ambas emiten `data-animation-style` (el contrato de bajo nivel del motor, que **se conserva**); lo que difiere es A QUÉ reacciona el CSS y QUIÉN la tipa. El **type-safety de `animation`** vive en un registry augmentable `MotionCoordinatedPresets` en **`$motion`** (la capa compartida, para que soma lo tipe sin importar eidos — una capa inferior no depende de una superior); **eidos lo puebla** con sus `cascade-*` por declaration-merging — espejo exacto de `EidosMotionPresets`. Esto **cierra** la línea abierta del Apéndice A ("destino de `motion`": se queda, con rol propio). > **Estado — incremento M5 (hecho, sobre `Reveal`).** El enrutado existe: `` se nombra una vez en la raíz y el provider lo enruta a las parts `surface: true` > del morfo COMPILADO (`routeAnimation(kebab)` consulta el set de superficies derivado de > `compileMorfo`; una part no-superficie —el Trigger— recibe `undefined`, nunca se mis-targetea). > Los wrappers Panel/Item emiten `data-animation-style` con el valor enrutado. Unit-tested. > > **Hallazgo — el enrutado da el QUÉ, no el CUÁNDO.** Los presets BUILT-IN de eidos (`scale-fade`, > `slide-fade`…) reaccionan a `data-state` (disparan al MONTAR) → NO componen con superficies > COORDINADAS, donde el cuándo lo gobierna el `data-starting-style`/release del `PresenceGroup`: un > preset data-state dispararía todo al montar y rompería la cascada. Por eso la demo reacciona a > `[data-animation-style='X'][data-starting-style]` (el attr da el QUÉ; los attrs del Presence dan el > CUÁNDO). Reconciliar la LIBRERÍA de presets con superficies coordinadas (un preset/variante que > reaccione a `data-starting/ending-style`) está **HECHO (M6)**: la librería de eidos gana > `MotionConfig.coordinated` — presets `cascade-slide`/`-fade`/`-scale` que eidos genera como > transición (off-state + stagger reversible) sobre los attrs del Presence, no `data-state`. El motor > nunca los corre (CSS puro). Un coordinado hace `animation="cascade-slide"` sin CSS propio. > **Hallazgo gemelo — la FIRMA sema genérica también choca.** El mismo patrón con el otro sistema > visual de eidos: al disparar `runtime.trigger('open'/'close')`, sema estampa `data-event-*` y eidos > corre la firma genérica `emerge` (`present-rise`/`dismiss-fade … forwards`, `generated/base.css`) > ENCIMA de la cascada coordinada. En `Reveal` se vio como "otra animación" al cerrar con el botón > (que va por `runtime.trigger`) ausente al usar el reversa (que pone `open` directo). **Doctrina: un > componente de movimiento COORDINADO silencia la señal sema genérica** — sus eventos declaran > `semantic.channels: []` (el engine corta antes de estampar, `sema/engine.ts`) y `expression: 'none'`: > su percepción ES la coordinación, no la firma. Generaliza el hueco §5: la cascada coordinada > (data-starting/ending-style) es un tercer sistema visual que NO compone con los otros dos de eidos > (presets data-state · firmas data-event) — un coordinado debe optar fuera de ambos. --- ## 6. Routing automático vs choreography opt-in Lo que parecía una decisión binaria ("¿coordinación automática u opt-in?") se disuelve separando **dos cosas distintas**: - **Routing a superficie = automático / zero-config.** El dev nunca cablea superficies: `animation="..."` se enruta solo a la part `surface: true`. Es el corazón del "sin nada más". - **Coreografía padre↔hijo = opt-in por construcción.** Solo ocurre si la part **declara** `children` en morfo (§4). Un componente que no lo declara nunca arrastra a sus hijos. El beneficio combinado: coherencia con "sin nada más" **sin** el riesgo de que un padre empiece a **esperar misteriosamente** a una superficie anidada profunda que el autor no quería coordinar. La coordinación es explícita en el contrato; el routing es implícito en el uso. --- ## 7. Coordinación de presencia — el `PresenceGroup` `Presence` hoy gestiona el lifecycle de **una** superficie: monta, marca `data-starting-style`/`data-ending-style`, espera las animaciones, desmonta. El servicio lo **generaliza a un árbol** con una clase hermana — **`PresenceGroup`** (el nombre es deliberado: coordina _presencia_, state; no _motion_, visual): ```ts // src/uix/soma/layers/presence-group.svelte.ts (nuevo) // Presence generalizado de 1 superficie a un árbol. Pure lifecycle/state — NO visual. class PresenceGroup { static create(structure: AnimationStructure): PresenceGroup; // root → set in context static get(): PresenceGroup | undefined; // children discover it /** A surface (or a nested group) cedes its "when" to this group. */ register(member: PresenceMember): () => void; // returns deregister /** Coordinate the tree for a phase: ordered mount/unmount/await + aggregate `finished`. */ play(phase: 'enter' | 'exit'): Promise; /** Propagate cancellation to every registered member (interrupt/reverse). */ cancel(): void; } ``` `Presence` gana una opción `group?` (descubierta por context). Si hay grupo, la superficie **cede el timing**: en vez de arrancar su `motion.run` por su cuenta en `handleOpen`, se **registra** y espera la señal del grupo (`release(phase)`). Si no hay grupo, `Presence` **degrada al comportamiento de hoy** (isla autónoma) — backward-compat total. **Qué coordina el grupo y qué NO.** El grupo decide _cuándo_ existe / monta / espera / cancela cada superficie (state). El **cómo-se-ve** sigue siendo de eidos: el **stagger visual lo pinta el CSS** (`animation-delay` escalonado por índice, como hoy). Soma **solo monta y espera el `finished` agregado** — **nunca calcula un milisegundo**. Esta es la línea state/visual hecha mecanismo: el grupo no conoce el valor del stagger; lo pone el delay CSS, y el grupo solo agrega los `finished` (que ya incluyen ese delay). **Las dos direcciones** (consecuencia de `children.when`): ``` play('enter'): when 'before' → corre la superficie del padre; al (o durante) su animación, libera a los hijos when 'together' → libera a todos a la vez (el stagger CSS los escalona visualmente) when 'after' → libera a los hijos; cuando su finished agrega, corre la del padre play('exit'): when 'after' → emite exit a los hijos, RETIENE el DOM del padre, agrega su finished, y solo entonces corre la salida del padre y libera la retención (§8.1) ``` **Modelo de coordinación — DECISIÓN (recomendación: híbrido declarativo).** Tres opciones: | Modelo | Cómo | Veredicto | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | **Híbrido declarativo** _(recomendado)_ | morfo declara la estructura; soma coordina el timing y agrega `finished`; **el stagger/escalonado lo pinta CSS** (eidos); el motor ejecuta por-nodo. El JS solo coordina presencia. | respeta la línea state/visual; no reinventa GSAP; reusa `Presence` | | **Variants propagados (Framer)** | el padre propaga su fase a los hijos por context con `staggerChildren`/`when` en el árbol de componentes | mete lógica de coordinación en el árbol de componentes; más cerca de soma que de morfo; el timing visual se filtra a JS | | **Timeline / grafo explícito** | un orquestador construye una timeline con offsets/dependencias y la ejecuta vía WAAPI groups / scheduler | máximo control; pero maquinaria JS pesada y riesgo de reinventar GSAP / alejarse de lo declarativo | El híbrido es el único que mantiene a soma fuera de los valores visuales. ### 7.1. El árbol de presencia es lógico (context), NO DOM La pregunta clave: _¿cómo se gestiona el árbol si entre un padre animable y sus hijos animables hay componentes sin animación? Los padres tienen "saltos" en el árbol._ **Respuesta: el árbol que se coordina es el de superficies animables proyectado sobre el `context` de Svelte, no el árbol DOM.** Y el context **no depende de adyacencia DOM**, así que los saltos se resuelven solos: - Un ancestro animable pone un `PresenceGroup` en context. - Los componentes **sin animación** (wrappers de layout, `{#if}`, una `
    ` tonta) son **transparentes**: no crean grupo ni se registran. El context del grupo **fluye a través de ellos** sin que lo toquen. - El siguiente descendiente animable —a la profundidad DOM que sea— hace `PresenceGroup.get()`, encuentra el grupo del **ancestro animable más cercano** y se **registra**. El padre conoce a sus hijos animables **por registración, no por proximidad DOM**. - Un intermedio que **sí** es animable crea su propio grupo y **corta el descenso** (caja negra, §9) — única frontera del árbol lógico. Implicaciones que el resto del RFC hereda: 1. **El count no sale del DOM** (los descendientes cuelgan a profundidad arbitraria) → §8.2. 2. **El orden del stagger = orden de registración** (= montaje = document order), modulado por el `order` visual de eidos; **nunca la profundidad DOM**. 3. **La retención en exit aguanta el salto**: retener el **nodo del ancestro** retiene transitivamente su subárbol entero (wrappers incluidos) → §8.1 no se rompe. 4. **Des-registración abrupta** (caso de borde): un wrapper intermedio con su propio `{#if}` que se desmonta deja a sus hijos animables sin exit coordinado → el grupo debe tolerar un **set dinámico** de registros (aparecen/desaparecen) y **no colgar** su promesa por un miembro que se fue (liga con el timeout anti-deadlock de §8.2). --- ## 8. Salida, interrupción y reversa (el caso difícil) La entrada en cascada (`enter` + stagger) es trivial; todo framework la borda. El estrés real —donde los sistemas mueren— está en la **salida coordinada** (`after`) y en la **interrupción** (un `enter` a medias que pasa a `exit`). Esta sección combina el lifecycle de soma con el contrato de morfo para resolverlo. ### 8.1. Retención del DOM en exit coordinado Cuando un padre declara `exit: 'after'`, el nodo padre **no puede desmontarse** hasta que todos los hijos orquestados terminen su animación de salida. **Mecanismo:** el `PresenceGroup` del padre **retiene el bloque en el DOM** (vía el `shouldRender` de `Presence` / Svelte). Al dispararse `play('exit')`, emite la señal de salida a sus hijos registrados, **recolecta sus `finished` agregados**, y **solo cuando todas resuelven** ejecuta su propia animación de salida; al terminar, **libera la retención** y el nodo se desmonta. El **escalonado visual** de la salida lo pone el CSS (eidos, `animation-delay`); soma solo **espera** el agregado. Coherente con la línea state/visual: el grupo nunca decide cuánto se escalona, solo cuándo desmontar. ### 8.2. Cierre del conjunto de registración (el problema async) En render condicional o diferido, el padre **no puede** esperar solo "las superficies que se hayan registrado en este tick": podría asumir que no hay hijos antes de que terminen de montar. La **bala de plata es el contrato** — pero la **fuente del count se bifurca** (porque con saltos, §7.1, el DOM no sirve para contar): - **Cardinalidad fija** (parts nombradas: `overlay` + `content`) → el count exacto lo da **morfo** (sabe cuántas superficies-hijas declaró). El padre **bloquea** hasta que `registros == count`, con un **timeout de seguridad anti-deadlock** (si un hijo se excluye lógicamente del render, no se cuelga para siempre). - **Colecciones** (part `item` repetida N veces — el caso del prototipo §13) → morfo declara el **tipo** de superficie hija, **no el número** de instancias (el count es runtime). El count lo aporta el **provider de la colección** (conoce su `items.length`) o un _commit_ explícito cuando la colección termina de montar. > Esta bifurcación es directa consecuencia de §7.1: si el árbol es lógico y hay saltos, > contar por estructura DOM es imposible; el count tiene que venir de una fuente que lo > conozca declarativamente (morfo) o de runtime (provider de la colección). ### 8.3. Interrupción y reversa (máquina de estados) — IMPLEMENTADO (M4 + M6 handoff JS) Un usuario abre y cierra un Dialog rápido. El sistema debe transicionar de un `enter` incompleto a un `exit` (o de vuelta) sin saltos ni huérfanos. **Lo que M4 entrega — corrección estructural:** - **Token de generación de grupo.** `PresenceGroup` gana un `generation` — el análogo a nivel de árbol del `runId` de `Presence`. Cada `requestEnter`/`requestExit`/`cancel` lo incrementa; el `playEnter`/`playExit` agendado captura su valor y **se aborta en cada `await`** cuando queda obsoleto. El punto crítico: un `playExit` superado por una reapertura **NO ejecuta su bucle de unmount** → la superficie que se reabre no se desmonta-y-remonta (sin flash). Es el bug central que el árbol tenía sin guarda (el unmount incondicional de §8.1). - **Propagación de cancelación.** En un flip (un `requestExit` con un enter en vuelo, o viceversa) el grupo llama `member.cancel()` a cada superficie, que propaga `motion.cancel(node)` por-nodo de `arts/motion` — así un `spring`/WAAPI en vuelo no queda **apilado** con el de la fase inversa (`run()` del motor es aditivo: no cancela el previo). `Presence.cancel()` ya hacía `cleanup()` (bump del `runId` → la cola async queda inerte); M4 le añade el `motion.cancel(node)`. - **`finished` resuelve en cancel.** `toHandle` (motor) ahora **traga el rechazo** de `Animation.cancel()` (WAAPI rechaza su `finished` con `AbortError`): sin esto, la reversa filtraría un _unhandled rejection_ por `track`/`pending`. Unifica todos los drivers a la semántica "stop-in-place, resolve" del `spring`. **Reversa fluida — el estado por sustrato:** - **CSS transiciones (los coordinados `cascade-*`):** fluida **sin esfuerzo**. Una `transition` interpola desde el valor computado actual al invertir el target → el prototipo `/temas/animations/presence-group` y los presets coordinados (que usan `transition`, NO `@keyframes`) revierten desde la posición actual gratis (el "caso (1)" de abajo, implícito). Es el sustrato del servicio de coordinación → **su reversa ya es fluida**. - **JS (`spring`) — HECHO (M6):** el motor ahora **expone el estado del driver** (`MotionHandle.peek()` → posición + velocidad por propiedad, sólo física) y **lo reinyecta** en la fase inversa vía `MotionContext.handoff`: el `spring` arranca cada propiedad desde el `handoff[prop]` (x, v) en vez del `from` declarado, así la reversa **continúa desde la posición/velocidad actual** sin saltar ni reiniciar el overshoot. El _handoff_ vive en el engine (`cancel` captura el `peek`; `run` lo consume vía `takeHandoff`, que **además** cancela un run en vuelo si el caller no pre-canceló — cubre el camino _island_ de un overlay-spring y elimina el apilamiento). `waapi`/`rect` no exponen velocidad → reinicio limpio (sin `peek`). Tests en `engine-motion.test.ts`. - **CSS `@keyframes` — pendiente (decisión por preset):** un `@keyframes` de salida **salta** a su `from` al invertir. "Sin saltos" exige una de tres políticas: (1) keyframes interrumpibles (`from` ≈ reposo); (2) leer el computed value y reinyectarlo como `from` vía WAAPI; (3) aceptar el salto donde sea imperceptible (fades). Aplica a los state-presets que usan `@keyframes` (`scale-fade`…), NO a los coordinados (transiciones). Sin consumidor que lo fuerce hoy. Rigor sostenido: M4 garantiza la **corrección** (sin unmount espurio, sin animaciones apiladas, sin rejection filtrado); M6 añade la **continuidad fina del path JS** (handoff con velocidad). El único cabo es la política de `@keyframes`, acotada a los state-presets y diferida hasta que un preset la necesite. --- ## 9. Motor: agregación por el `PresenceGroup`, no `subtree:true` ciego Tentación natural: darle al motor `getAnimations({ subtree: true })` para esperar el árbol. **Se rechaza**, por dos razones: 1. **Rompe la agnosticidad del motor.** `subtree: true` ES jerarquía; el motor dejaría de ser por-nodo y agnóstico de componentes — justo lo que lo mantiene un _art_ puro reusable. 2. **Captura animaciones ajenas.** `subtree: true` devuelve **todas** las animaciones de los descendientes: un hover de un hijo, una transición incidental, **un `PresenceGroup` anidado independiente**. Agregar su `finished` sin filtrar haría esperar a animaciones que no son de esta coreografía, o acoplarse a un grupo hijo que debería ser autónomo. **La solución: la espera del árbol la hace el `PresenceGroup` (soma), no el motor.** El grupo agrega los `finished` de las superficies / grupos **registrados en él** — su lista de miembros, no un barrido DOM. El **scoping sale gratis de la topología de registro** (§7.1): un grupo anidado se registra en el padre como **caja negra** (su `play()`/`finished` representa todo su subárbol) o decide ser independiente y no registrarse; el padre **no desciende** al DOM. Cada superficie-hoja resuelve su propia espera con lo que `Presence` ya usa: `node.getAnimations()` (de **su** nodo, no subtree) + `motion.pending(node)`. **Resultado:** `arts/motion` queda **agnóstico de verdad** — `engine-motion.ts` y `drivers.ts` no se reescriben; el motor sigue ejecutando primitivas por-nodo. La inteligencia de "quién y cuándo" es 100% de soma. --- ## 10. Reduced-motion · firma semántica · SSR/hydration - **Reduced-motion.** Ya existe `ReducePolicy` (`instant`/`opacity-only`/`none`) por preset y la proyección `data-motion='reduce'` (+ `@media (prefers-reduced-motion: reduce)`). La coordinación lo respeta: bajo reduced-motion el grupo puede **colapsar la cascada a instantánea** (todas las superficies resuelven su `finished` de inmediato) — la presencia se coordina igual, sin el realce temporal. La firma cross-modal (sound/haptic) sigue comunicando (ventaja del libro: ningún canal agota la semántica). - **Firma semántica (la composición que nadie más tiene).** El momento-evento (`data-event-*`, la firma de sema) y el momento-estado (la transición coordinada) **componen**: un `commit + fulfill` puede coreografiar su _settle_ con la entrada de los hijos. El `PresenceGroup` ordena el lifecycle; la firma la sigue disparando sema sobre el mismo evento. El servicio **no toca** el engine de sema ni los `data-event-*` — solo se apoya en que ya están ahí. - **SSR / hydration.** La coordinación **degrada sin JS**: el estado base de toda superficie es **presente/visible**. Si el `PresenceGroup` no corre (SSR, hydration pendiente, JS deshabilitado), el contenido **nunca se bloquea ni se oculta** — la coreografía es **realce aditivo**, jamás un prerrequisito para ver el contenido. El `cancel(el)` por-nodo se propaga a `group.cancel()` sin dejar superficies en estado intermedio si el árbol se desmonta a media animación. --- ## 11. Integración de los efectos WIP La carpeta `web/routes/demos/animations/` (40 efectos, sin trackear) **no es una sola cosa**: - **text-effects** (blur, count, scrambled, gradient, focus, circular) → **candidatos a _variants de contenido_** de eidos. Son animación de contenido al entrar (WAAPI + stagger por letra/palabra), encajan como presets aplicables a una superficie de texto. Entran en el catálogo de variants (§5), no como demos sueltas. - **fondos WebGL** (aurora, galaxy, particles, liquid-image, …) → **no-goal** (§2). Se apartan a su propio eje ("ambient"); **no se mezclan** con el servicio de presencia. La integración de los text-effects es una fase tardía del roadmap (§15): primero el núcleo de coordinación, luego ampliar el catálogo. --- ## 12. Encaje con las 4 capas + qué NO hace | Capa | Aporta al servicio | Línea que NO cruza | | --------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- | | **morfo** | declara la **estructura** (`animation: { surface, children: { enter, exit } }`) | no lleva valores visuales/expresivos | | **soma** | coordina la **presencia** del árbol (`PresenceGroup`, `Presence` generalizado) | **nunca** decide el cómo-se-ve ni calcula un ms | | **eidos** | el **QUÉ visual** + valores expresivos (keyframes, easing, stagger-ms, order) | no controla mount/unmount (lo lee, no lo manda) | | **arts/motion** | ejecuta primitivas **por-nodo** (`run`/`pending`/`cancel`) | no orquesta, no gana jerarquía | **Regla 2-de-3:** el campo morfo `animation` lo consumen soma (lifecycle) + eidos (visual) → justificado en el contrato. **Qué NO hace el servicio:** no reinventa GSAP (sin timelines imperativas de propósito general); no mete visualidad en soma (el JS solo coordina presencia); no mete comportamiento en eidos (eidos lee el DOM, no controla el mount). La palabra "orquestar" se reparte por la línea state/visual: la coordinación-de-presencia es de soma; la coordinación-visual-expresiva (stagger CSS) es de eidos. --- ## 13. Validación — prototipo exit-heavy El prototipo **no** valida el caso fácil (enter con stagger). Valida lo difícil: - **Primario — una lista que hace stagger de SALIDA antes de que el contenedor se vaya** (`exit: 'after'`, **colección**). Ejercita de golpe: retención del DOM (§8.1), cierre de registración **por el provider de la colección** (§8.2, el caso runtime-count), agregación de `finished` (§9), y **interrupción** (abrir/cerrar a media animación → §8.3). El escalonado es CSS (eidos); soma coordina la presencia. - **Secundario — Dialog `overlay→content` secuenciado** (`when`, **cardinalidad fija**): valida el camino del count-desde-morfo y la coordinación de dos superficies nombradas. **Hogar del prototipo:** la ruta `/temas/animations` (la demo del sistema de motion de UIX, la misma que cita `eidos-motion.md`) — **no** `web/routes/demos/animations/` (los efectos WebGL, no-goal §2). Criterio de éxito del prototipo: la salida coordinada se completa sin desmontar el padre antes de tiempo; una interrupción no deja superficies montadas "colgadas"; bajo reduced-motion el contenido aparece/desaparece sin bloquearse. **Verificación ejecutable** (scripts reales del repo): `npm run check` (tipos, 0 errores) · `npm run morfo:check` (DOM real vs contrato morfo — valida que `MorfoPart.animation` se emite/consume bien) · `npm run perm:check` (re-valida el morfo a través de transiciones de estado — clave para enter↔exit↔interrupción) · `npm run smoke` (runtime/hydration, con `npm run dev` levantado) · `npm run test` (vitest). --- ## 14. Impacto sobre el código actual + migración **Todo es aditivo y opt-in.** Un componente sin la nueva declaración funciona **exactamente como hoy**. No hay big-bang sobre los ~25 componentes existentes. | Capa | Cambio | Backward-compat | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | **arts/motion** | **casi intacto** — la coordinación vive en soma (§9); único cambio (M4): `toHandle` resuelve `finished` en cancel (no rechaza) para que la reversa no filtre un _unhandled rejection_; `drivers.ts` sin tocar | ✅ | | **morfo** | `types.ts`: nuevo campo **opcional** `MorfoPart.animation` (solo estructura); `schema.ts`/`compile.ts` lo validan/compilan; los morfos lo declaran **incrementalmente** | ✅ (opcional ⇒ morfos actuales válidos) | | **soma (epicentro)** | `presence.svelte.ts` **modificado** (descubre `group?`, cede timing, `cancel`→`motion.cancel`, **degrada sin grupo**); `presence-group.ts` **nuevo** (coordinación + token de generación M4); providers tocados incrementalmente | ✅ (sin grupo = comportamiento actual) | | **eidos** | **gana los valores expresivos del stagger** (tokens/recipe) + keyframes para reversa interrumpible (§8.3); naming `animation`↔`motion` (§5) decide cuánto tocan los wrappers (estratificar = mínimo) | ◐ depende de §5 | | **sema** | **no se toca** — la firma se compone, su engine y los `data-event-*` no cambian | ✅ | | **langs/format/prefs/adom** | **no se tocan** | ✅ | **Pilotos de migración:** Dialog (`overlay`+`content`, cardinalidad fija) + una lista/menú con items (colección, stagger de salida = el prototipo §13). Los dos casos canónicos. --- ## 15. Roadmap de implementación por fases > Cada fase es independiente, verificable (`npm run check` · `npm run test` · `npm run morfo:check` > · `npm run perm:check` · `npm run smoke` + el prototipo en `/temas/animations`) y no rompe lo > anterior. Aprobado el RFC, cada fase se planifica por separado. - ✅ **M1 — Contrato (hecho).** `MorfoPart.animation` en `morfo/types.ts` + `schema.ts` + `compile.ts`. El campo `children` se separó en `{ enter?, exit? }` (no un `when` simétrico) al materializar el prototipo — un contenedor que bracketea a sus hijos no es simétrico (§4). - ✅ **M2 — `PresenceGroup` + `Presence.group` (hecho).** Clase nueva en `soma/layers/presence-group.ts` (sin runes, unit-testable) + `Presence` cede timing por context y degrada sin grupo. - ◐ **M3 — Exit coordinado (hecho) + cierre de registración (diferido).** §8.1 (retención DOM) implementado; §8.2 (count fija/colección + timeout anti-deadlock) **sigue diferido** al primer consumidor que necesite una barrera de cardinalidad fija. - ✅ **M4 — Interrupción / reversa + cancel de grupo (hecho).** §8.3: token de generación (supersede la fase obsoleta → sin unmount espurio), propagación de `motion.cancel(node)` en el flip, y `toHandle` resuelve `finished` en cancel (sin _unhandled rejection_). La reversa fluida desde la posición actual (JS y `@keyframes`) queda para **M6**. _Verificación:_ tests de `presence-group`/`presence`/`engine-motion` + prototipo `/temas/animations/presence-group`. - ✅ **M5 — DX `animation` enrutado + naming (hecho).** Prop transversal `animation` → enrutado a las superficies `surface: true` del morfo compilado (`routeAnimation`), emitido como `data-animation-style` por los wrappers de superficie. **Generalizado**: el wiring (PresenceGroup + routing + auto-stagger) se extrajo al helper `soma/layers/coordination.ts` (`Coordination` + `CoordinatedSurface`), validado por DOS consumidores — `Reveal` (raíz virtual + Panel owner, bracket) y `Rail` (raíz=owner, `together`, sin eventos). Un componente coordinado nuevo es ahora un puñado de líneas. **Naming resuelto (§5): `animation` y `motion` SEPARADAS por rol** — no se unifican (tras M6 son sistemas distintos); `animation` gana type-safety vía el registry augmentable `MotionCoordinatedPresets` en `$motion` (eidos lo puebla con `cascade-*`), espejo de `EidosMotionPresets`. El gating data-state↔data-starting-style se resolvió en M6. - ◐ **M6 — Stagger expresivo + presets coordinados (hechos) + reversa interrumpible (pendiente).** (1) Stagger: tokens canónicos `--motion-stagger-index` (por ítem, **auto-derivado del orden de registro del grupo** vía `PresenceGroup.childIndex`) × `--motion-stagger-each` (ritmo, heredado). (2) **Presets coordinados**: `MotionConfig.coordinated` + `CoordinatedPreset` (en `$motion`); eidos genera (render-css.ts) la transición + off-state + stagger reversible sobre `data-starting/ending-style`; built-ins `cascade-slide`/`-fade`/`-scale` (presets/css.ts), en `generated/base.css`. Un coordinado hace `animation="cascade-slide"` sin CSS de animación propio — cierra el hallazgo §5. Generation-test en `eidos/motion.test.ts`. La demo `/temas/animations/compuesto` consume ya los presets de la librería en un compuesto real (un `Rail` de ` ``` - **entrada del item** → `cascade-slide`, eje `[data-starting-style]`, tokens `--motion-cascade-*` _(coordinado)_ - **press del button** → `press-squeeze`, eje `[data-event-*]`, tokens `--duration-fast`/`--ease-default` _(firma)_ Son **dos nodos** (el `Rail.Item` que se anima vs el `