# 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:
`<motion.div>` por nodo + `<AnimatePresence>` ) 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 + `<AnimatePresence>` 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** (`< motion.div > `); 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 `<Background variant="aurora">` ), 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< HTMLElement , Set < MotionHandle > >`). 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: `<Reveal.Provider
animation="X">` 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< void > ;
/** 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 `<ul>` 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 `<Button>` s; el selector cambia `animation` en vivo) — ver
**Apéndice B.4** ; `reveal` /`rail` conservan hand-CSS a propósito como ejemplo _custom_ . **Reversa fluida
(§8.3): los coordinados (transiciones CSS) ya revierten desde la posición actual; el handoff JS con
velocidad (`spring`) está HECHO — `MotionHandle.peek()` + `MotionContext.handoff` , orquestado en el
engine (`takeHandoff`). Único cabo: la política de `@keyframes` interrumpibles para los state-presets
(acotada, diferida).**
- **M7 — Reduced-motion + SSR/hydration hardening** (§10).
- **M8 — text-effects como variants de contenido** (§11).
- **M9 — Segunda coordinación: el modo children-DOM** — HECHO (F1/F1b/F1c/F2, commit `d797a0ce` ).
El `DomCascade` propaga el lifecycle del owner sobre ítems DOM descubiertos por selector;
cableado en el `dropdown-menu` real (cascade de filas, exit-heavy con `pending` , + 4 fixes de
convivencia). Detalle completo en el **Apéndice C** . Pendiente F3: submenús (`sub-content` como
segundo owner). El otro piloto (Dialog) usa el modo registered-`Presence` y queda para después.
> **Validación end-to-end — primer consumidor real (`Reveal`).** Antes de M5/M6 se cableó un
> componente REAL como primer consumidor del contrato: `Reveal` (disclosure list — morfo
> `src/uix/morfo/components/reveal.ts`, soma `src/uix/soma/components/reveal/`). Su Panel declara
> `animation: { surface: true, children: { enter: 'before', exit: 'after' } }`; el provider **lee
> ese `children` del morfo COMPILADO** (`compileMorfo(...).parts.byKebab.get('panel').animation`)
> para construir el `PresenceGroup` — el contrato es lo que dirige la coordinación, no hand-wiring.
> Verificado a velocidad real en `/temas/animations/reveal`: enter en cascada (panel, luego ítems)
> y exit-heavy (ítems salen primero, panel RETIENE su DOM ~550 ms, luego el panel, luego unmount).
> Cierra el hueco que el motor M1– M4 tenía: **dejaba de ser código sin usar**. Se eligió un
> componente nuevo y aislado (no retrofit de Dialog/dropdown-menu) para validar el stack sin riesgo
> de regresión en un componente con focus-trap / roving-focus / items por DOM-vivo.
---
## Apéndice A — decisiones resueltas vs abiertas
**Resueltas (con el usuario):**
- **Reparto state/visual** — morfo=estructura · soma=presencia/lifecycle · eidos=QUÉ visual +
valores expresivos · arts/motion=ejecución por-nodo. (§0, §4, §7, §12)
- **morfo declara / soma coordina** (no "soma orquesta animaciones": eso sonaba a invasión
visual; el lifecycle de presencia ya es soma por doctrina — `Presence` ). (§7)
- **Routing automático + choreography opt-in-por-declaración.** (§6)
- **Naming: `animation` y `motion` separadas por rol** (M5) — NO se unifican; tras M6 son sistemas distintos (`--state`/eidos vs coordinado/soma), cada una con su registry type-safe. El nombre de la prop comunica el sistema. (§5)
- **Layout animations = no-goal**; `rect` mide un solo nodo. (§2)
- **Scoping = topología de registro**, no `subtree:true` ciego. (§9)
- **Cierre de registración** — fija=morfo / colección=provider. (§8.2)
- **Reversa interrumpible** — M4 entrega la corrección estructural (sin unmount espurio ni motion apilado); la fluidez _desde la posición actual_ (JS y `@keyframes` ) es M6. (§8.3)
- **Árbol lógico, no DOM**; no-animables transparentes; set dinámico de registros. (§7.1)
**Abiertas (a fijar durante la implementación):**
- **Modelo de coordinación** — recomendación: híbrido declarativo (§7); confirmar contra los
pilotos en M2– M3.
- **Shape exacto** del plan de coordinación que emite `compile.ts` y de `PresenceMember` .
- **Política de late-joiners** (un hijo que monta tras cerrarse la ventana de registro: ¿se une
a la coreografía en curso o arranca autónomo?).
- **Política de keyframes interrumpibles** (cuál de las tres vías de §8.3 por preset).
---
## Apéndice B — Convivencia con la firma sema (los tres sistemas visuales)
> **Guía de composición.** Consolida cómo el motion coordinado de este servicio **convive** con
> los otros dos sistemas visuales de eidos. Es material de _cómo se componen_ (con diagrama y un
> ejemplo con tokens); el _por qué_ está en §5 (el hallazgo) y §10 (la firma cross-modal). Para el
> modelo base de **dos** momentos (`--event` / `--state`) ver
> [`eidos-motion.md`](./eidos-motion.md); este apéndice añade el **tercer eje** —el coordinado— y
> la regla que evita que pelee con la firma.
### B.1 — Los tres sistemas visuales
eidos reacciona en CSS a **tres** familias de atributos distintas, sobre el **mismo nodo** . Las
tres las emite `lib/render-css.ts` con funciones hermanas:
| Sistema | Selector | Lo escribe | Generador / mapa | Qué expresa |
| -------------------------------- | --------------------------------------------------- | -------------------------------- | -------------------------------------- | ------------------------------------------------- |
| **Firma** (momento `--event` ) | `[data-event-family][data-event-phase='active']` | sema (durante el hold) | `renderSignatureRules` (`signatures`) | _qué SIGNIFICA_ el acto (commit/threat/contact…) |
| **State-preset** (`--state`) | `[data-animation-style][data-state]` | soma (effects) | `renderCssPresetRules` (`presets`) | la transición a/desde un estado persistente |
| **Coordinado** (este servicio) | `[data-animation-style][data-starting/ending-style]` | soma (`PresenceGroup`/`Presence`)| `renderCoordinatedPresetRules` (`coordinated`) | _cómo APARECE/SALE_ una superficie en el árbol |
Son **ejes ortogonales de atributo** : sólo entran en conflicto cuando dos animan la **misma
propiedad** (`transform`/`opacity`) del **mismo nodo a la vez** . La firma sema y el motion
coordinado son, además, **dos sistemas de feedback que responden a preguntas distintas** del
mismo evento — _qué significa_ vs _cómo aparece_ :
```
EVENTO ( open · close · activate )
│
┌───────────────────────┴───────────────────────┐
SEMA · ¿qué SIGNIFICA? MOTION · ¿cómo APARECE?
familia / intent soma · PresenceGroup
sound · haptic (canales runtime) (lifecycle del árbol)
estampa data-event-* pone data-starting/ending-style
│ │
└───────────────────────┬───────────────────────┘
▼
EIDOS · capas visuales (CSS, mismo nodo)
┌─────────────────────────┬─────────────────────────┬─────────────────────────┐
│ firma [data-event-*] │ state [data-state] │ coordinado │
│ momento --event │ momento --state │ [data-starting/ │
│ (signatures) │ (presets) │ ending-style] │
│ ✕ MUDA en coordinados │ transición de estado │ (coordinated) │
│ (channels: []) │ │ ✓ gana el eje visual │
└─────────────────────────┴─────────────────────────┴─────────────────────────┘
▼
DOM · una sola animación visual
```
### B.2 — La regla: el coordinado gana el eje visual, la firma genérica se silencia
Si un componente coordinado deja salir además su **firma genérica** , la familia `emerge`
materializa su `present-rise` /`dismiss-fade` — _otra_ transición de `transform` /`opacity` sobre
el **mismo nodo** a la vez que la cascada coordinada. Dos animaciones peleando por la misma
propiedad: el bug que pegó el prototipo ("aparece otra animación con un ligero desplazamiento").
La doctrina: un componente coordinado **silencia su firma genérica** pero **mantiene el evento en
la capa sema**. En `reveal.ts` son tres piezas:
```ts
scope: ['soma', 'sema'], // el evento SIGUE en sema (telemetría / a11y / cascade per-app)
expression: 'none', // declara: no materializo firma perceptual por defecto
events: [{ name: 'open', semantic: { family: 'emerge', /* … */ channels: [] } }]
```
`channels: []` corta en seco en el engine (`sema/engine.ts` — el early-return antes de proyectar):
sin `data-event-*` estampado → la capa de firma queda muda → sólo corre el coordinado. La
percepción del componente **es** la cascada, no una firma genérica encima.
**No es exclusión, es composición selectiva.** Tres modos, todos válidos:
| Modo | Ejemplo | Feedback |
| -------------------------- | ------------------------------------- | ------------------------------------------------------------ |
| ** (a) sema sola** | Button, Checkbox | la firma (`data-event-*`); sin coordinado |
| ** (b) coordinado solo** | Rail | la cascada; firma genérica silenciada (`channels: []`) |
| ** (c) ambos compuestos** | Reveal (`open` con `channels:['sound']` ) | la cascada (visual) + sonido `emerge` — el opt-out visual lo da eidos (ver nota) |
El conflicto está en el **eje visual** , y silenciar `channels: []` lo elimina apagando TODO. El
modo (c) —componer la cascada con sound/haptic— tiene un matiz **verificado en el engine** : el
meta-canal visual estampa `data-event-*` **siempre que el evento tenga algún canal** (`channels ≠
[]`), sin mirar _cuál_ . Así que un coordinado con `channels: ['sound']` obtiene el sonido **y** la
firma visual genérica (`present-rise`) — que pelearía con la cascada. **La solución vive en MOTION,
no en sema** (sema queda intacto): eidos emite, junto a cada preset coordinado, una regla que
**neutraliza su propia firma visual** sobre la superficie —
`[data-animation-style='cascade-X'][data-event-phase='active'] { animation: none !important }`
(`render-css.ts` → `renderCoordinatedPresetRules` ). La firma usa `animation` → muere; la cascada es
`transition` → sobrevive. Es **aditivo** (inerte hasta que un coordinado dispare un evento sema) y
coherente con la doctrina: **eidos es el dueño del eje visual** , así que el opt-out de SU firma vive
en eidos. Resultado: un coordinado declara `channels: ['sound']` y compone sonido + cascada, **sin
tocar el engine de sema** (§10 sigue valiendo). Test en `eidos/motion.test.ts` .
### B.3 — Ejemplo con tokens: `Button` (modo a) y su composición (modo c)
`Button` es el caso limpio del **modo (a)** : su morfo declara `expression: 'family-default'` y un
único evento `contact-activate` (`family: 'contact'`, **sin intent** ) — su feedback es la **firma
de la familia `contact` **, no motion coordinado. Los "tokens" viven en **dos planos** que no se
pisan:
| Plano | Chrome / estado (locales) | Firma perceptual (global) |
| ------------------------------ | ---------------------------------------------------------- | ----------------------------------------------------- |
| **Dónde** | `button.css` + recipe `--button-*` | `BUILTIN_SIGNATURES` → `generated/base.css` |
| **Tokens** | `--button-palette-solid` , `--button-height-md` , `--button-transition-*` | `press-squeeze` + `--duration-fast` + `--ease-default` |
| **Reacciona a** | `data-variant` / `data-size` / `data-color` / `:hover` / `:active` | `data-event-family='contact'` (el hold de sema) |
| **Alcance** | **per-instancia** (cada button elige variant/size/color) | **canon global** (un press se siente igual en todo el sistema) |
El recorrido del press (el plano de la firma):
```
click → soma: runtime.trigger('contact-activate') (button-provider.svelte.ts)
→ engine sema estampa data-event-family="contact" data-event-phase="active" durante el hold
→ la firma 'press' matchea → animation: press-squeeze var(--duration-fast) var(--ease-default)
(press-squeeze: scale + shadow se aplana + esquina se cuadra)
→ + sound · haptic (canales runtime de contact, si on) → fin del hold → reposo
```
Reparto honesto: **el chrome lo tokeniza el componente; la firma es canon** (el libro pide que un
`contact` se sienta igual en todo el sistema, así que NO hay un `--button-press-duration` por
instancia — la firma vive en `--duration-fast` global). El tinte de intención (`data-color`) es un
eje aparte que NO carga el press: el press es genérico `contact` sin intent (libro cap. 22 §11).
**Modo (c) — el mismo Button componiendo con la cascada.** Metido en un `Rail` que aparece
coordinado, tiene **los dos ejes a la vez, con sus propios tokens, sin pelearse** :
```svelte
< Rail.Provider animation = "cascade-slide" >
< Rail.Item > < Button variant = "solid" > Guardar< / Button > < / Rail.Item >
< / Rail.Provider >
```
- **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 `<Button>` dentro) y **dos momentos**
(aparecer ≠ pulsar): por eso componen sin conflicto.
### B.4 — Demos de referencia
- [`/temas/animations/compuesto` ](../../../web/routes/temas/animations/compuesto/+page.svelte ) — la
cara ** "librería"**: un `Rail` de `<Button>` s que usa los presets coordinados **predefinidos** de
eidos (`cascade-slide/-fade/-scale`) con **cero CSS de animación** en la página; el selector
cambia `animation` en vivo, y cada Button compone su press (modo c).
- [`/temas/animations/reveal` ](../../../web/routes/temas/animations/reveal/+page.svelte ) ·
[`/rail` ](../../../web/routes/temas/animations/rail/+page.svelte ) — la cara ** "custom"**: hand-CSS
para presets propios (`cascade-drop`/`-blur`), demuestra cómo extender más allá de la librería.
- [`/temas/animations/presence-group` ](../../../web/routes/temas/animations/presence-group/+page.svelte )
— el mecanismo sintético (`Presence` + `PresenceGroup` construidos a mano).
---
## Apéndice C — M9: el modo children-DOM (la segunda coordinación)
> **Estado: IMPLEMENTADO y verificado en navegador real (commit `d797a0ce`).** F1 (esqueleto +
> contrato + `DomCascade`), F1b (demo aislado), F1c (exit-heavy con `pending`), F2 (el
> `dropdown-menu` real). Cubre el segundo de los dos modos de coordinación del servicio. F3
> (submenús) queda fuera.
### C.1 — Por qué un segundo modo
El `PresenceGroup` (§7) coordina **superficies-`Presence` registradas** : cada hijo animable es un
`Presence` que se registra en el grupo por context (Reveal/Rail). Pero un **menú o lista real** no
tiene esa forma: tiene **UNA** superficie `Presence` (el _content_ /overlay que monta como unidad) y
sus ** ítems son DOM plano** que el runtime no envuelve — se descubren por selector (`getItems`/
`querySelectorAll` ), markup arbitrario del consumidor. No hay nada que registrar en un grupo, ni un
sitio de binding Svelte donde poner `--motion-stagger-index` .
**3 grietas que el menú destapó** (una por capa):
1. **registro** — no hay un `Presence` por ítem que registrar en un grupo.
2. **index per-ítem** — el preset de eidos hace `calc(var(--motion-stagger-index) * …)` ; en
Reveal/Rail lo pone un binding Svelte (`style:--motion-stagger-index`), inaplicable a ítems DOM
arbitrarios del consumidor.
3. **escritura de CSS-var por elemento** — `ActiveDom` no tenía un método para escribir una
custom property en UN elemento (`apply` = attrs/data-\*; `writeStyle` = `<style>` global; la
doctrina prohíbe `el.style` directo). Se añadió `writeProperty` /`removeProperty` a `$adom`
(el usuario aprobó extender adom para esto — sema NO se toca, adom SÍ).
### C.2 — El diseño: el owner propaga su lifecycle a los ítems DOM
En vez de registrar ítems, el **owner `Presence` PROPAGA su lifecycle** sobre cada ítem DOM,
**reutilizando el preset coordinado de eidos SIN cambios**. Mientras el owner está
`data-starting/ending-style` , el propagador escribe en cada ítem los mismos attrs que un child-
`Presence` habría producido — `data-animation-style` (el preset ruteado) + el `data-starting/
ending-style` ESPEJADO del owner + `--motion-stagger-index` /`-count` por orden DOM — de modo que
`[data-animation-style='cascade-X'][data-starting-style]` matchea y la cascada corre. El owner
`Presence` ya posee el TIMING (starting → next-frame → transición); el propagador solo espeja.
**`DomCascade`** (`src/uix/soma/layers/dom-cascade.svelte.ts`) es ese propagador:
```ts
new DomCascade({
dom, // ActiveDom
animationStyle, // Active< string | undefined > — el preset ruteado; undefined ⇒ inerte
ownerTransitionAttrs, // Active< …> = Presence.transitionAttrs del owner
items // () => readonly HTMLElement[] — los nodos a cascadear, en orden DOM
})
```
- `sync/clear` son PUROS sobre `opts.dom` (testeables sin componente, server-test con un mock).
- `watch()` corre el `$effect` reactivo (de ahí el `.svelte.ts` ): 3 ramas por prioridad —
`items===0` (`clear`) · `ending` (espeja el exit) · `appeared && style` (`beginEnter`). El
`$effect` de lógica **NO tiene cleanup** (un `clear()` por re-run cortaba el enter a media
transición); el teardown vive en un 2º `$effect` sin-deps.
- **`beginEnter`** conduce el enter de filas recién montadas: estas montan en el ON-state, así que
estampar el off-state con la transición ACTIVA haría que el opacity **interpole** hacia 0 (y,
soltado un frame después, apenas se mueva). En su lugar estampa el off-state con `transition:
none` (SALTO instantáneo) → fuerza un reflow → restaura la transición y suelta
`data-starting-style` → las filas hacen _ease in_ con el stagger. (El `Presence` no sufre esto:
su `data-starting-style` está en el render de montaje, que nunca transiciona.)
- **`pending()`** (F1c) — el cabo del §9: el owner `getAnimations()` NO ve las transiciones de los
ítems (son descendientes DOM, no el nodo owner; el motor sigue sin `{subtree:true}` ). `pending()`
agrega los `finished` de los ítems en UNA promesa (diferida 2 frames para que las transiciones de
salida ya hayan registrado), que el owner `Presence` espera vía `PresenceOptions.pending` →
retiene el subárbol hasta que la cascada de salida settlea. Usa
`Promise.all(finishers.map(f => f.then(noop, noop)))` (settle, no `Promise.all` crudo) para que
una transición cancelada aislada no colapse la espera.
**El contrato (morfo).** La parte owner declara
`animation: { surface: true, staggerChildren: '<item-kebab>' }` (`MorfoPart.animation.staggerChildren`,
validado en `schema.ts` : requiere `surface:true` , mutuamente excluyente con `children` ). Es el
PRIMER consumidor real de `staggerChildren` . El valor nombra la item-part líder; **el provider
resuelve los nodos DOM** (puede ampliar a todas las filas visibles — ver C.3).
### C.3 — F2: el `dropdown-menu` real + los fixes de convivencia
El owner es el `content` (ya era un `Presence` isla); los ítems son sus filas, descubiertas por un
`getCascadeRows` (TODAS las filas visibles — item/checkbox/radio/sub-trigger + separators +
group-headings, incl. disabled, scoped al content excluyendo submenús). Prop pública opt-in
`animation?: CoordinatedPresetName` ruteada al `DomCascade` ; `undefined` ⇒ el menú se comporta como
siempre. El `pending` se cablea reenviándolo por `createFloatingShellRoot` (los otros 6 floating
consumers quedan byte-idénticos). **El menú destapó CUATRO colisiones** , cada una con su fix —
todas son del componente, no del motor, y aditivas:
1. **Colisión de `transition`.** Las filas declaran `transition: background, color` (hover); el
preset coordinado anima con `transition: opacity, transform` — MISMA propiedad shorthand, y el
CSS de componente carga DESPUÉS del preset generado → la regla de la fila gana y BORRA la
cascada. Fix: la fila base usa **longhands** (`transition-property/duration/timing`, sin
`transition-delay` — el shorthand lo reseteaba a 0s y mataba el stagger) + una regla
`[data-…-item][data-animation-style]` (specificity 0,2,0) que cede `transition` al preset.
2. **Opacidad de disabled.** Un `[data-disabled]` tiene `opacity` dim al mismo specificity que el
off-state → ganaba y fijaba la fila, que no podía desvanecerse. Fix: gatear el opacity dim con
`:not([data-starting-style]):not([data-ending-style])` para que el off-state (0) se vea durante
la cascada.
3. **La firma `dismiss-fade` del PANEL.** El content tiene su propia `CSSAnimation` `dismiss-fade`
(firma sema del evento `close` ) que desvanece TODO el panel en ~240ms → con él se van las filas
que cascadeaban dentro («cierra de una»). La neutralización de firma del preset
(`[data-animation-style][data-event-phase='active'] { animation: none !important }`) SOLO cubre
las filas (tienen `data-animation-style` ), NO el panel. Fix: el provider estampa `data-cascade`
en el content (vía `$effect` + `dom.apply` ) sii hay `animation` ruteado, y eidos neutraliza
`[data-dropdown-menu-content][data-cascade] { animation: none !important }` +
`[…][data-cascade][data-state='closed'][data-ending-style] { opacity: 1 }` (evita el colapso
instantáneo del `[data-state='closed']{opacity:0}` durante el exit). El panel se queda quieto y
desmonta solo cuando `pending` settlea.
4. **El toggle no cerraba** (preexistente). El `Dismissal` cierra en `pointerdown` + el `onclick`
del trigger reabre. Fix: el provider excluye el trigger del dismissal
(`runtime.partRef('trigger')` + `contains` en `onInteractOutside` ).
### C.4 — Doctrina / lecciones (children-DOM)
- **El espejado pasivo del `transitionStatus` del owner solo captura el enter si los hijos existen
durante la fase `starting` ** (efímera, 1 rAF). Hijos descubiertos por DOM/selector montan tarde
→ el cascade debe CONDUCIR su propio enter (`beginEnter`), no espejar.
- **Un elemento que monta en on-state no puede recibir el off-state con la transición activa** —
interpola en vez de saltar. Hay que SUPRIMIR la transición (`transition:none` + reflow) al
estampar el off-state del ENTER, y restaurarla al soltar. El EXIT es lo contrario (quiere
interpolar) → transición activa.
- **La neutralización de firma sema del preset solo alcanza a quien lleva `data-animation-style` .**
El owner (panel) la necesita por separado (un marcador propio: `data-cascade` ), o su firma
`dismiss-fade` /keyframe colapsa el panel antes de que las filas terminen.
- **Un `$effect` que escribe DOM con cleanup `clear()` se auto-interrumpe en cada re-run.** Separar
lógica (sin cleanup) de teardown (effect sin-deps).
- **Diagnóstico:** con el preview MCP roto y estados transitorios, un sampler de `opacity` /
`getAnimations()` frame-a-frame en navegador headless real (Playwright) fue lo decisivo; los
observadores/snapshots y los `console.log` capturados ayudaron pero no bastaron.
### C.5 — Demos
- [`/temas/animations/dom-cascade` ](../../../web/routes/temas/animations/dom-cascade/+page.svelte ) —
el modo children-DOM AISLADO (owner `Presence` + ítems DOM estáticos + `DomCascade.watch()` ),
validó F1b (enter) y F1c (exit-heavy con `pending` ) antes del menú.
- [`/temas/animations/dropdown-menu` ](../../../web/routes/temas/animations/dropdown-menu/+page.svelte )
— el `<DropdownMenu>` REAL con la prop `animation` + selector de preset + ritmo; convive con
focus-trap / dismissal / submenús.
---
## Apéndice D — Re-análisis 2026-06-16: grietas y principios del re-diseño
> **Estado.** Sesión de **análisis y diseño, SIN cambios de código.** Tras una lectura íntegra del libro
> fundacional [`docs/Disenando_lo_que_ocurre_v2_3.md`](../../../docs/Disenando_lo_que_ocurre_v2_3.md), el
> servicio de motion entra en **re-diseño**. Nada de lo de abajo está implementado: es el diagnóstico y los
> principios acordados con el usuario, base del plan a ejecutar. El cuerpo del RFC (§1– §15, Apéndices A– C)
> documenta lo que HOY existe; este apéndice documenta hacia dónde —y por qué— pivota.
### D.1 — Las dos grietas
**Grieta 1 — dos firmas para el canal motion del mismo evento.** El motion coordinado (cascada, vía
`data-starting/ending-style` ) y la firma sema visual (vía `data-event-*` ) son DOS realizaciones del canal
motion del MISMO evento, y conviven NEUTRALIZÁNDOSE (`[data-animation-style][data-event-phase='active'] {
animation: none !important }`, `lib/render-css.ts:813` ). El libro pide UNA firma por evento, coordinada
(su Apéndice B; cap. 20: «los canales se coordinan **alrededor de un evento** , no entre sí en abstracto»).
**F1 (Reveal/Rail, modo c) está comprometida igual que F2 (dropdown)** — la neutralización es sistemática
en F1 (el preset la emite para las superficies con `data-animation-style` ) y manual en F2 (`data-cascade`
porque el panel-owner no lleva ese attr). La mecánica de presencia del `PresenceGroup` (registro por
contexto, retención DOM, agregación de `finished` , generación/cancel) es **sólida** ; lo comprometido es la
**coexistencia con la firma sema**.
**Origen de la Grieta 1:** la animación se modeló como **eje PARALELO** (`parts[].animation.surface` + prop
`animation` + `data-animation-style` ruteado), desacoplada del evento al que pertenece. De ahí salen las dos
firmas. Si la animación viviera DENTRO del evento, habría una sola.
### D.2 — Principio rector (corrección del usuario): la animación vive en el EVENTO
- **La animación es un canal de la firma del EVENTO. Se declara en el evento que la define**, no en una
prop/eje paralelo (paralelo al Apéndice A del libro para el intent: «la semántica se declara; el runtime
no adivina»).
- **Simple vs compuesto** (distinción que el análisis previo NO hacía):
- _Simple_ (botón, toggle): un evento, su firma. El motion ES la firma.
- _Compuesto_ (menú, lista): el **contenedor** declara el evento (`emerge.open`) y **su firma incluye cómo
se presentan los hijos** (la cascada). Los hijos **participan en el evento del padre** ; **no declaran
firma propia para aparecer**.
- **Por evento, no por componente:** la opción del menú **no** tiene firma para _aparecer_ (eso es
`emerge.open` del menú) pero **sí** la tiene para _su_ acto evaluable, `commit.select` . Misma opción, dos
cosas distintas.
- **La prop `animation` cuando hay un evento que debería portar la animación es override-explícito o
violación** (tipos/lint), no la vía primaria.
### D.3 — El discriminante (caso «SVG animado») y los tres dominios
Pregunta única que decide DÓNDE vive una animación:
> **¿La animación realiza un evento perceptivo (comunica un cambio del contexto operativo: algo apareció /
> se fijó / reclama atención / sigue en curso)?**
- **Sí** → firma del evento (morfo). La prop `animation` = override / violación.
- **No** → **animación de contenido / intrínseca** → la prop `animation` es la **vía primaria y legítima** .
No hay evento que la porte.
El **SVG animado** (logo que late, ilustración ambiental, loop decorativo) es el caso paradigmático del
segundo: no es `emerge` /`commit`/`signal`, es presentación. Por tanto **la prop `animation` tiene un dominio
propio legítimo**, y «colapsar los tres sistemas en uno» (lo que decía mi plan inicial) era **incorrecto** .
Hay **tres dominios** que hay que **delimitar** (no colapsar):
| Dominio | Disparador | Dónde se declara | Ejemplo |
| --- | --- | --- | --- |
| **Evento** (firma sema) | `data-event-*` | en el evento (morfo) | aparición del menú = `emerge.open` ; spinner = `sustain.progress` ; checkmark = `commit.complete` |
| **Estado** | `data-state` | recipe del componente | hamburguesa ↔ X (open/closed) |
| **Contenido / intrínseco** | prop `animation` | prop DX | SVG que late, ilustración animada |
Matiz: un spinner «solo gira» pero **comunica** «proceso vivo» → firma de `sustain` , no contenido. El logo
que late no comunica nada operativo → contenido. Mismo medio (SVG), dominios distintos.
### D.4 — Decisiones de base del re-diseño (pendientes de aprobar/prototipar)
- **A — Lifecycle: Svelte nativo, no custom.** Apoyar presencia/coordinación en `{#if}` +
`transition:…|global` + `introstart` /`introend`/`outrostart`/`outroend` + la retención de DOM nativa, con
**eidos como dueño del visual** vía un shim mínimo. NO se obviaron por incompatibilidad (son compatibles
con Svelte 5); se construyó sobre `Presence` heredado sin re-evaluar el camino nativo. Se come `DomCascade`
entero, media maquinaria de `PresenceGroup` (retención DOM + `pending` + doble-rAF), **elimina el bug
ease-vs-jump** (Svelte aplica el estado inicial antes del paint) y **regala el cleanup-on-abrupt-unmount**
(D.5). Coste a diseñar: el shim state/visual (no meter duración/easing en JS) y la coordinación
motion↔sonido sin neutralizar.
- **B — Una firma por evento, no neutralización** (D.2): la animación-de-evento se declara en el evento; se
retira `animation: none !important` + los `channels:[]` /`expression:'none'` defensivos.
- **Descubrimiento por registro-de-contexto, no DOM-search ni ids.** Lo que ya hacen Reveal/Rail
(`PresenceGroup.register` + `childIndex` ). El menú es el único que cayó al `querySelectorAll`
(`getCascadeRows`) + propagación imperativa — fue atajo. La propuesta del usuario `parent-animation="id"`
es mejor que el DOM-search, pero el **contexto de Svelte la supera** (suscripción jerárquica sin ids). El
**roving-focus** se queda con consulta-DOM donde el orden visual estricto lo exija.
### D.5 — Edge-cases (pregunta de Gemini) y qué se conserva
- **Cleanup en desmontaje abrupto (router) — gap real.** El único teardown de `Presence` desregistra del
grupo (`presence.svelte.ts:92`), NO llama `cancel()` /`motion.cancel(node)`. Un spring en vuelo sigue
corriendo sobre un nodo detached hasta converger solo (trabajo desperdiciado + retención temporal; no fuga
permanente — `track().finally` limpia el Map). La interrupción COORDINADA (reversa/`cancel`/`dispose`) sí
está limpia (AbortController + `safeCancel` + handoff de velocidad). **Svelte nativo resuelve el caso
abrupto de fábrica** → refuerza A.
- **Política de interrupción:** el mecanismo (handoff de velocidad, `peek` /`takeHandoff`) ya existe; falta
**declararla como contrato** (no es edge-case nuevo: es otra cara de la Grieta 1 — press-mientras-cierra
cruza `--event` y `--state` ).
- **SSR/CLS:** el diseño ya mitiga (estado base = presente/visible; motion solo en transiciones, no en el
primer montaje); falta el contrato explícito `data-initial-state` (M7).
- **Se conserva:** `arts/motion` (spring con handoff, FLIP) para lo que CSS no puede — el _progressive
enhancement_ legítimo; el contrato morfo; la doctrina state/visual.
### D.6 — Plan en fases (a reformular sobre D.2/D.3, aún sin aprobar)
0. **Prototipo descartable** del cascade del menú con Svelte-nativo + registro-por-contexto + firma unificada
(motion+sonido sin neutralizar). Caso más difícil (owner + hijos-DOM + portal + roving-focus). Criterios
binarios medibles en navegador real (Playwright). NO producción. Valida A y B a la vez.
1. Unificar la firma (la animación-de-evento se declara en el evento) — primero Reveal/Rail.
2. **Delimitar los tres dominios** (D.3) — NO «colapsar a uno»: declarar por componente qué gobierna cada
animación; prop `animation` reservada a contenido, prohibida (tipos/lint) donde haya evento que la porte.
DevX: tipado estricto + flag `UIX_DEBUG_MOTION` .
3. Migrar el menú a producción (retirar `DomCascade` ).
4. Edge-cases (D.5).
5. Limpieza (retirar lo que el nativo reemplace; el spring-JS de Dialog probablemente sobrevive como island).
**Fuera de alcance:** `words` /`palabras`, `chronos` , `time-picker` , y el **engine de sema** (sema no se
toca; la coexistencia se resuelve en motion/eidos). Todo aditivo / opt-in.
### D.7 — Fase 0 validada (prototipo Svelte-nativo)
Prototipo descartable en [`/temas/animations/menu-native` ](../../../web/routes/temas/animations/menu-native/+page.svelte )
(`{#if}` + `transition:…|global` + un shim genérico que lee tokens de eidos). Valida la **Decisión A**
(Svelte nativo) en el caso difícil (menú) + los edge-cases, **medido en navegador real** :
- **Entrada** escalonada **sin ease-vs-jump** (los ítems montan en off-state; midpoints de opacidad
`[86, 149, 193, 236, 320, 361] ms` ).
- **Salida** con stagger **invertido** (`[510, …, 236] ms`) + **exit-heavy nativo** : el `{#if}` retiene el
panel hasta que el último ítem termina (~553 ms) — **sin `pending()` / `getAnimations()` / `DomCascade`** .
- **Adición dinámica** a una lista abierta → entrada **individual** (delay 0). El **contenedor arbitra** con
los flags `opening` /`closing`: durante la ráfaga de apertura/cierre = cascada (índice × ritmo); una
mutación suelta = delay 0. (Confirma la distinción «evento-del-contenedor vs evento-del-ítem» de §D.3.)
- **Roll-up del panel** = firma de cierre **opcional** del panel (`when: { exit: 'after' }`): los ítems
salen, **luego** el panel colapsa su `height` (con retardo ajustable). El panel tiene firma de salida
**propia** , coordinada con la de los hijos — no solo «seguir el flujo».
**Toda la maquinaria de F2** (`DomCascade` / `pending` / `beginEnter` / doble-rAF) **desaparece** , sustituida
por `{#if}` + `transition:…|global` + el shim. Decisión A confirmada en el caso más difícil.
**Hallazgos para producción:** (1) `grid-template-rows` **no anima** por la vía de Svelte/WAAPI (no crea
animación) → colapso por `height` medido (un elemento) o `interpolate-size: allow-keywords` (CSS). (2) Chrome
real computa layout aunque la pestaña esté oculta; el preview headless no (`getBoundingClientRect` → 0).
**Pendiente de la Fase 0** (mejor sobre el componente real / con `uix` montado): el shim toggle-de-attrs
(visual 100 % en eidos reutilizando el preset coordinado), el sonido sin neutralización (prueba de la
Grieta 1), y el registro-por-contexto real (ítems como componentes, no `{#each}` ).
---
> **Fuentes.** Canon semántico: [`docs/CANON.md`](../../../docs/CANON.md). Arquitectura de
> capas: [`active_architecture.md`](../active_architecture.md). Motion actual (que este RFC
> extiende): [`eidos-motion.md`](./eidos-motion.md). Motor: `src/arts/motion/README.md`.
> Lifecycle de presencia: `src/uix/soma/layers/presence.svelte.ts`.