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

100 KiB

RFC — Servicio de motion de UIX (coordinación de presencia cross-layer)

⚠️ RETIRADA — diseño implementado y luego ELIMINADO (2026-06-19, «Plan A»)

El servicio de orquestación paralelo que diseña el cuerpo de este RFC (§4–§9 + Apéndices B/C: MorfoPart.animation, la prop animation ruteada, PresenceGroup, DomCascade, los presets coordinados cascade-* / MotionConfig.coordinated, la neutralización animation: none !important, y sus consumidores Reveal / Rail) se implementó, se validó… y se ELIMINÓ por completo. El código ya no existe; este documento queda como registro histórico del diseño y su razonamiento.

Por qué (corrección del usuario — Apéndice D.2). La animación NO es un eje paralelo: es un canal de la firma del EVENTO, y la firma la posee sema en TODOS sus canales (motion incluido). Modelar el motion coordinado como un motor propio —fuera del evento— producía DOS firmas para el mismo canal que convivían neutralizándose (Grieta 1, D.1). El libro pide UNA firma por evento, coordinada alrededor del evento.

El reparto correcto (hacia donde se reconstruye):

  • sema lanza el evento: proyecta data-event-* + sonido/haptic + el hold.
  • eidos materializa el canal motion + la coordinación visual reaccionando a data-event-* (único dueño de lo visual; arts/motion sobrevive como island de progressive-enhancement para lo que CSS no puede — spring / FLIP).
  • soma posee el lifecycle (retener / esperar / desmontar): el Presence island, que se conserva.
  • La coordinación padre↔hijo sigue siendo necesaria, pero cuelga del evento de sema, no de un motor paralelo.

Qué se conservó: arts/motion (engine spring/waapi/rect), el Presence island (soma/layers/presence.svelte.ts, reducido a una sola superficie), los state-presets (motion prop → data-animation-style sobre data-state: scale-fade, slide-fade, Material shared-axis/fade-through), las firmas sema visuales (present-rise / dismiss-fade) y el stagger Material (--motion-stagger-{index,each}). Modelo vigente: eidos-motion.md (dos momentos --event / --state).

El modelo hacia adelante está en el Apéndice D (re-análisis 2026-06-16): principio rector, discriminante de los tres dominios, plan. La retirada de 2026-06-19 ejecutó de golpe la limpieza que D.6 preveía incremental (pasos 3 + 5): dejar el terreno limpio antes de reconstruir sobre el evento.


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: RETIRADO (2026-06-19). El texto a continuación (§0–§15, Apéndices A–C) documenta el diseño tal como llegó a implementarse —M1–M4 + M5/M6 + M9, con PresenceGroup / DomCascade / presets coordinados y los consumidores Reveal / Rail / dropdown-menu— y que se eliminó por completo. Ver la nota de retirada al inicio y el Apéndice D para el modelo hacia adelante. Se conserva como registro histórico.

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 — 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; 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).

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:

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 — 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 — 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, 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)

  1. 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.
  2. Unificar la firma (la animación-de-evento se declara en el evento) — primero Reveal/Rail.
  3. 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.
  4. Migrar el menú a producción (retirar DomCascade).
  5. Edge-cases (D.5).
  6. 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 ({#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}).

El prototipo de la Fase 0 (/temas/animations/menu-native) se eliminó en la retirada D.8 — había cumplido su función de validar las Decisiones A y B. Su demo hermana de dos momentos (/temas/animations) se conserva (state-presets + firma sema, no coordinación).

D.8 — La retirada ejecutada (2026-06-19, «Plan A»)

En lugar de la limpieza incremental de D.6 (pasos 3 + 5), el usuario optó por retirar de golpe todo el motor de orquestación paralelo y dejar el terreno limpio antes de reconstruir la coordinación como canal de la firma del evento (D.2). La retirada es funcional y verificada — npm run check no añade ningún error (los pre-existentes son de otros tracks: icon-button, spin-field, scroll-area, palabras); las suites de morfo / motion / soma-layers quedan verdes.

Eliminado (archivos borrados):

  • soma/layers/presence-group.ts (+ test) · soma/layers/dom-cascade.svelte.ts (+ test) · soma/layers/coordination.ts.
  • soma/components/reveal/ · soma/components/rail/ · morfo/components/reveal.ts · morfo/components/rail.ts.
  • Demos web/routes/temas/animations/{presence-group,rail,compuesto,reveal,dom-cascade,dropdown-menu,menu-native}/.

Eliminado (código recortado):

  • morfo: MorfoPart.animation + MorfoAnimation / MorfoAnimationChildren (types) · su schema + invariante · compilePartAnimation / CompiledPartAnimation (compile) · re-exports.
  • arts/motion: CoordinatedPreset / CoordinatedPresetName / MotionCoordinatedPresets · MotionConfig.coordinated.
  • eidos: BUILTIN_COORDINATED_PRESETS (cascade-*) · renderCoordinatedPresetRules + su llamada · la neutralización animation: none !important · el bloque declare module '$motion' del registry · coordinated: … en themes/base · las reglas [data-cascade] + [data-animation-style] del cascade en dropdown-menu.css. generated/base.css regenerado.
  • soma: el camino agrupado de Presence (group/groupRole/pending/PresenceMember/release/ unmount/cancel/registro) → presence.svelte.ts reducido a island de una superficie; FloatingShell pierde pending; el dropdown-menu provider pierde la prop animation / data-cascade / getCascadeRows.

Conservado: arts/motion (engine spring/waapi/rect) · el Presence island · los state-presets (scale-fade/slide-fade/Material) · las firmas sema visuales (present-rise/dismiss-fade) · el stagger Material (--motion-stagger-{index,each}) · la doctrina state/visual · el contrato morfo (menos animation).

Próximo (sin implementar): reconstruir la coordinación padre↔hijo como canal de la firma del evento (D.2) — sema lanza el evento, eidos materializa motion + coordinación reaccionando a data-event-*, soma posee el lifecycle (island). El prototipo Svelte-nativo (D.7) validó el cómo visual; falta cablearlo al evento de sema.

D.9 — Modelo verificado (auditoría 2026-06-19): pipeline + las dos reglas duras

Tras la retirada, una auditoría de 8 agentes (5 lectores de ground-truth + 3 críticos adversariales) sobre src/uix/sema/* + el libro fijó el modelo y cazó varios errores de framing. El pipeline correcto, verbo por dueño:

morfo  DEFINE      el contrato semántico del evento (family/intent/verb/target/sequence/hold/a11y) + estructura (parts)
   ↓
soma   DISPARA     origina el evento (runtime.trigger) y envía a sema la carga semántica (engine.emit{target,name,family,intent…})
   │               — el evento es de SOMA; sema es ORNAMENTAL: sin engine, trigger corre igual (prewrite+handler+effects)
   ↓
sema   RESUELVE+PUBLICA   resuelve la firma (cascada de 5 capas → SOLO sound/haptic) y, por el META-canal visual,
   │                      estampa data-event-* en UN target vía adom (dom.apply) + abre/cierra el HOLD
   │               — en paralelo, la misma emisión realiza sound/haptic (canales reales, NO por adom)
   ↓
eidos  REACCIONA   CSS reaccionando a data-event-* (durante el hold) + data-state/data-intent
   ↓
MOTION             (y presence/depth/shape/color) — el stagger/índice lo calcula EIDOS en CSS (sibling-index/nth-child)

Las dos reglas duras (violarlas resucita bugs ya muertos):

  1. Soma NUNCA escribe una --var visual. El índice/ritmo del stagger es animación → es de eidos, que lo computa en CSS desde la estructura (sibling-index() / :nth-child, como ya hacen spinner.css / avatar.css). El DomCascade borrado escribía --motion-stagger-index desde soma — esa era la violación de raíz, no solo el "motor paralelo". Soma aporta estructura (hijos en el DOM) + data-state; nada visual.
  2. data-event-* es un sello de UN signal.target, NO un bus de difusión. El projector estampa un solo elemento; el resolver resuelve solo sound/haptic/hold. No hay fan-out por hijo dirigido por el evento. La cascada de los hijos NO sale de data-event-* — sale de eidos leyendo estructura + data-state.

Errores de framing que la auditoría corrigió (a no repetir):

  • "sema emite el evento" → soma lo ORIGINA; sema lo procesa (runtime.svelte.ts:696; sin engine soma sigue).
  • "el engine no ejecuta nada" → no realiza modalidad, pero RESUELVE la firma + posee el await del hold + la persistencia (engine.ts:198-271).
  • "eidos es un canal par del de sonido" → error de categoría: eidos NO está en SemaChannelSignatures (solo sound+haptic) ni recibe dispatch; es una capa/realm que reacciona a los tokens. Nunca registrar un EidosChannel ni meter motion/color en SemaChannelSignatures.
  • "data-event-* son tokens visuales" → son tokens semánticos compartidos: los lee la cascada de sema (sound/haptic) Y el CSS de eidos (stamp.ts:5-11).
  • el canal visual es META: no realiza modalidad — solo estampa data-event-* + temporiza el hold (engine.ts:243-248, sema/README.md:43).

Doc corregida: GUIA_IMPLEMENTACION_SEMAUIX.md §11 decía "5 canales canónicos (motion, sound, color, presence, haptic)" — stale; reescrita al split real (sema: sound+haptic+meta-visual · eidos: los visuales).

Consecuencia para la Fase 1 (panel + N cards): la cascada NO se conduce estampando un data-event-* rico en el contenedor. El emerge.open del panel cae en UN target (su flourish + sonido). La entrada escalonada de las cards es el momento --state + stagger, que eidos calcula en CSS desde la estructura; soma solo aporta los hijos + data-state. El caso dinámico (ráfaga de apertura vs alta suelta) se distingue por data-state (dominio de soma), no por una --var que soma escriba.

D.10 — Prototipo de Fase 1 validado (2026-06-19)

Prototipo descartable en web/routes/temas/animations/panel-cascade/ (+page.svelte + panel-cascade.css): un contenedor coordinador + N <Card> reales. Valida el modelo en los tres casos difíciles, con el reparto verificado y cero --var visual escrita por JS (confirmado por valores computados frame-a-frame):

  • Cascada externa — entrada (:nth-child → --row, delay = row × ritmo) + salida en cierre de panel (:nth-last-child → --row-rev, reverse). El índice lo computa eidos desde la estructura.
  • Borrado dinámico con retención — al quitar cards, soma retiene el nodo (Svelte out:, leyendo la duración de la regla de eidos) y marca data-leaving (flag de estado); eidos pinta la salida ([data-leaving] → card-fall). Suelto → --row-rev 0 → inmediato; en bloque → cascada inversa. Sin data-phase: el :nth-last-child da los dos casos.
  • Anidamiento — contenido interno de cada card cascadea componiendo el --row heredado (índice externo de su card) + su --inner-row propio (:nth-child scopeado a su lista), secuenciado tras asentar la card. Compone por herencia de custom properties → escala a profundidad arbitraria sin maquinaria.

Hallazgos para la implementación de producción:

  • El CSS de eidos DEBE vivir en .css plano, no en <style> de Svelte: Svelte poda los selectores que dependen de atributos puestos solo en runtime ([data-leaving]) por detección de CSS-no-usado. Un recipe real ya ships así, luego no es problema en producción — pero un prototipo en <style> falla.
  • El índice por nth-child tiene cap (enumerado 1–16, como spinner.css/avatar.css). Para colecciones sin tope: el wrapper de eidos se auto-indexa (eidos escribe su propia var, NO soma), o sibling-index() cuando madure el soporte.
  • La salida en bloque de una colección dinámica es el borde donde el CSS-puro-desde-estructura topa (el nodo saliente ocupa layout + nth-last-child se desplaza al quitar hermanos). El usuario lo validó visualmente OK en este caso, pero la versión glitch-free pide que el contenedor orqueste la retirada como unidad (lifecycle de soma, lo que hacía el pending borrado — sin tocar lo visual). Pendiente para producción.

D.11 — El modelo final: tres ejes ortogonales + el lifecycle completo (2026-06-19)

Tras un re-análisis de fondo (workflow sobre código + canon) y correcciones duras del usuario, este es el modelo cerrado. Supersede los matices de D.2/D.9 donde difieran; D.8 (retirada) y D.10 (prototipo) siguen vigentes como historia.

D.11.0 — Tres ejes ORTOGONALES (el error de raíz era confundirlos)

El sistema NO es «una forma de animar». Son tres cosas separables que no hay que mezclar:

Eje Qué es Qué hace EXACTAMENTE
sema (semántico) el «qué ocurrió» SOLO emite data-event-* en el DOM (+ corre sus 2 canales runtime sound/haptic). NO es animación.
motion (arts/motion, el SERVICIO) el MOTOR de animación corre los drivers JS (spring/waapi/rect/svelte) + el path CSS declarativo + handoff de velocidad. Motor full, par/superior a Framer.
eidos (visual) la materialización LEE data-event-* (+ data-state) y reacciona: genera el CSS, decide estilo/animación, consume el servicio motion para lo JS. Único dueño visual.

Y los dos que orquestan alrededor: morfo DECLARA (semántico + estructura, cero visual); soma DISPARA (runtime.trigger) + ESTADO (data-state) + LIFECYCLE (presence/retención), cero var visual.

La regla mental: sema = el QUÉ (emite el token) · motion = el MOTOR (corre lo JS) · eidos = el CÓMO-SE-VE (lee el token y materializa). Mezclar «sema» con «la capacidad de animar», o comparar el suelo-CSS contra el motor entero de otro framework, es el error a no repetir.

D.11.1 — El lifecycle completo de un evento

1. DECLARACIÓN (estática, en código)
   morfo:  el evento (family/intent/verb/target/sequence/hold) + estructura (parts, data-state, data-stagger)
   eidos:  la realización — presets/signatures/keyframes en EidosConfig.motion
           (derivados a generated/base.css + registrados en uix.motion)

2. TRIGGER (runtime, ORIGEN)
   soma:   runtime.trigger('open')  ← origina desde interacción/lifecycle (sin engine sigue: sema es ornamental)
           + escribe data-state (en el contenedor y, propagado, en los items)

3. EMISIÓN (sema PROCESA)
   sema:   engine.emit → resuelve la firma (cascada 5 capas → SOLO sound/haptic)
           → el META-canal visual ESTAMPA data-event-* en signal.target (UN elemento)
           → abre el HOLD; en paralelo sound/haptic se realizan

4. REACCIÓN (eidos LEE, durante el hold)
   eidos:  el CSS del componente reacciona a data-event-* (+ data-state) → tres salidas posibles (D.11.2)

5. CLEANUP
   sema:   al cerrar el hold, des-estampa data-event-* (transient). Persistente → soma lo limpia (clearTarget)

6. PRESENCE (lifecycle de salida)
   soma:   Presence retiene el nodo durante la salida (await getAnimations / out:), luego desmonta.
           La retención es soma; el visual, eidos.

D.11.2 — La reacción de eidos: una escalera de realización

Leído el data-event-* (+ data-state), el CSS del componente decide CÓMO se realiza. No es siempre una animación — hay una escalera de cuatro escalones, de menos a más:

snap  →  transition  →  animation (keyframe / preset)  →  JS (servicio motion)
  • (a) snap — estilo estático. CSS puro, sin interpolar (la firma en el canal color): [data-field][data-event-intent='threat'] { border-color: var(--color-threat-border) }. Cambia de golpe.
  • (b) transition — estilo INTERPOLADO. Una transition CSS interpola la propiedad cuando su valor cambia: transition: border-color var(--duration-fast). El borde rojo entra suave en vez de saltar. Es el suelo de las micro-interacciones (hover/focus, archetypes.css) y de cambios de estado de UNA propiedad. La corre el navegador.
  • (c) animation — keyframe / preset. La firma (--event) o el preset (--state: enter/exit con forma + stagger, fill backwards). Declarativa; el navegador la corre; motion la tiene en registry + Presence la espera, pero no la ejecuta.
  • (d) JS — el servicio motion. Física (spring), FLIP (rect), reversa interrumpible. La ejecuta uix.motion.run, disparada por soma/Presence leyendo data-animation-style + tokens. El CSS NO llama a JS.

Frontera clave: declarar (CSS) ≠ ejecutar. El navegador corre (a)/(b)/(c); el servicio motion corre (d), disparado por soma. El CSS DECLARA qué hace motion; no lo invoca. Lifecycle: Presence espera tanto los CSSAnimation como los CSSTransition vía getAnimations() al salir — el servicio no gestiona las transition, pero el lifecycle sí las contempla.

D.11.3 — Firma vs realización (el seam, canon §10/§15)

Una firma conserva IDENTIDAD bajo variación; los valores concretos son realización:

  • GENÉRICO (firma, del evento): la identidad — «un emerge aparece» (flourish del contenedor, signature present-rise) y «los hijos de un contenedor que emerge aparecen JUNTOS, por orden estructural».
  • ELÁSTICO (realización, per-componente): los valores — el preset (keyframe), la duración, el ritmo (paralelo/cascada), el easing. Un menú a 40ms y un toast a 0ms (paralelo) realizan la MISMA firma distinto.

→ El timing NO es un parámetro genérico de firma (eso resucitaría la Grieta 1). Es realización per-componente, en su estilo de eidos.

D.11.4 — La cascada/stagger: el sistema EXISTENTE + una regla

La cascada NO es un sistema nuevo. Es el preset declarado + el stagger que ya existía + UNA regla de foundation:

  • El índice (foundation, generado por render-css.ts): [data-stagger] > *:nth-child(N) { --motion-stagger-index: N-1 } (forward) + :nth-last-child → --motion-stagger-index-rev (reverse). Nadie lo escribe; sale de la estructura. (El DomCascade borrado lo escribía desde soma = la violación raíz.)
  • El delay (en CADA preset, ya estaba): enter calc(var(--motion-stagger-index) * var(--motion-stagger-each)); exit usa el reverse. Paralelo = --motion-stagger-each: 0; cascada = N. No hay regla de «tipo»: el tipo lineal es el VALOR del token, en el estilo del componente.
  • La animación: el preset que el item lleva en data-animation-style (declarado en EidosConfig.motion). Keyframe + duración + reduced-motion: del preset, gratis.

Dos dominios para usarlo: (1) explícito — el componente <Cascade> (orquestador fino, sin recipe ni --cascade-*): marca [data-stagger], refleja open→data-state, pasa el preset a los items por contexto; (2) event-driven — un componente real declara en SU morfo el evento emerge + data-stagger en el contenedor; sus items llevan el preset + data-state. La aparición sale del evento declarado, sin wrapper.

D.11.5 — El servicio motion es el motor (par/superior a Framer)

EngineMotion (arts/motion · uix.motion): drivers spring (integrador de Euler semi-implícito: overshoot/settle/drag/snap), waapi, rect (FLIP/genie, medido + batcheado), svelte, css (suelo declarativo). Handoff de velocidad (peek/takeHandoff): la reversa continúa desde posición + velocidad actuales (el caso difícil de la interrupción). Puerto DOM inyectable (MotionDom) → iframe/popup/SSR-test (Framer asume document global). En capacidad de motor, par o superior. Madurez pendiente: el catálogo de presets-JS-por-nombre está a medio poblar (los CSS vienen de serie; los JS se registran por app).

El spring es además el equivalente INTERRUMPIBLE de una transition CSS (D.11.2.b): una transition salta al revertir a media interpolación; el spring continúa desde la velocidad actual (el handoff). Por eso la frontera práctica: interpolación simple no-interrumpible → transition CSS (eidos, browser); física o reversa interrumpible → el driver JS del servicio.

D.11.6 — Qué se corrigió (vs el primer intento de esta sesión)

El primer intento de productización hizo un <Cascade> con recipe propio + un namespace --cascade-* (index/each/base/pass) + keyframes propios. Era reinventar el --motion-stagger-* + los state-presets que ya existían (+ le faltaba reduced-motion + un getComputedStyle crudo violando ActiveDom). Corregido: retirado el --cascade-* entero; la cascada monta sobre el preset declarado + el índice de foundation; <Cascade> quedó como orquestador fino del dominio explícito (consume el sistema, no lo duplica). Verificado en navegador + npm run check/motion.test/eidos-lint limpios.


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.