You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/MOTION_SERVICE_RFC.md

59 KiB

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 implementados + incrementos M5/M6 (contrato morfo · PresenceGroup · exit con retención de DOM · interrupción/reversa §8.3 · enrutado de la prop animation a las superficies declaradas · stagger auto-derivado del orden de coordinación). Primer consumidor real: Reveal (§15). El resto de M5/M6–M9 pendiente — el roadmap (§15) define las fases. Todo es aditivo y opt-in: un componente sin la nueva declaración funciona exactamente como hoy.

Tabla de contenidos


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:

// 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):

// 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 — Migración de los pilotos (Dialog + lista/menú) y documentación de patrón.

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; 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:

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:

<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 — 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 · /rail — 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 — el mecanismo sintético (Presence + PresenceGroup construidos a mano).

Fuentes. Canon semántico: docs/CANON.md. Arquitectura de capas: active_architecture.md. Motion actual (que este RFC extiende): eidos-motion.md. Motor: src/arts/motion/README.md. Lifecycle de presencia: src/uix/soma/layers/presence.svelte.ts.

Powered by TurnKey Linux.