feat(motion): servicio de coordinación de presencia (M1–M6)

Servicio de motion cross-layer: morfo declara superficies animables +
coordinación; soma coordina presencia/lifecycle; eidos posee lo visual;
arts/motion ejecuta por-nodo. RFC en eidos/MOTION_SERVICE_RFC.md.

- M1 — contrato MorfoPart.animation (types/schema/compile); children
  como { enter?, exit? }.
- M2 — PresenceGroup (soma/layers/presence-group.ts), rune-free;
  Presence.group descubre el coordinador por context.
- M3 — exit con retención de DOM (§8.1).
- M4 — interrupción/reversa (§8.3): token de generación + motion.cancel
  en flip + toHandle resuelve finished en cancel (sin AbortError suelto).
- M5 — prop `animation` enrutada a las parts surface:true del morfo
  compilado (routeAnimation); Panel/Item emiten data-animation-style.
- M6 — stagger auto-derivado del orden de registro (--motion-stagger-*,
  inversa en exit) + presets coordinados en la librería de eidos
  (MotionConfig.coordinated; cascade-slide/-fade/-scale) que reaccionan
  a data-starting/ending-style, NO a data-state.

Helper Coordination (soma/layers/coordination.ts) extraído y validado
por DOS consumidores reales: Reveal (raíz virtual + Panel owner) y Rail
(raíz=owner, together). Demos en /temas/animations/{reveal,rail,
presence-group}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 29729e27f5
commit 48b183672a

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { createEngineMotion } from './engine-motion';
import type { MotionDom, MotionHandle, StatePreset } from './types';
// Minimal MotionDom. The JS presets below return a MotionHandle directly, so the
// frame scheduler is never exercised; `dom` only needs to be non-null so `runJs`
// proceeds instead of short-circuiting to the settled handle.
const dom: MotionDom = {
requestFrame: (cb) => (cb(0), 0),
cancelFrame: () => {},
prefersReducedMotion: { matches: false }
};
// A node that names a preset via `data-animation-style` — all `run` reads from it.
const el = (name: string) =>
({
getAttribute: (k: string) => (k === 'data-animation-style' ? name : null)
}) as unknown as HTMLElement;
describe('EngineMotion — JS handle tracking + cancel semantics', () => {
it('pending() stays unsettled until the JS preset finished resolves', async () => {
let resolve!: () => void;
const preset: StatePreset = {
driver: 'spring',
enter: (): MotionHandle => ({
finished: new Promise<void>((r) => (resolve = r)),
cancel() {}
})
};
const motion = createEngineMotion({ dom, presets: { gated: preset } });
const node = el('gated');
const handle = motion.run(node, 'enter');
let settled = false;
void handle.finished.then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false);
resolve();
await handle.finished;
expect(settled).toBe(true);
});
it('a cancelled (rejecting) handle resolves finished + pending — no unhandled rejection (RFC §8.3)', async () => {
// Simulate a WAAPI `Animation.cancel()` rejecting its `finished` with
// AbortError. `toHandle` must swallow it so a reversal does not leak an
// unhandled rejection through `track`/`pending`.
const preset: StatePreset = {
driver: 'waapi',
enter: (): MotionHandle => ({ finished: Promise.reject(new Error('cancelled')), cancel() {} })
};
const motion = createEngineMotion({ dom, presets: { rejecting: preset } });
const node = el('rejecting');
const handle = motion.run(node, 'enter');
await expect(handle.finished).resolves.toBeUndefined();
await expect(motion.pending(node)).resolves.toBeUndefined();
});
it('cancel(el) stops the tracked JS motion handle', () => {
let cancelled = false;
const preset: StatePreset = {
driver: 'spring',
enter: (): MotionHandle => ({
finished: new Promise<void>(() => {}),
cancel() {
cancelled = true;
}
})
};
const motion = createEngineMotion({ dom, presets: { c: preset } });
const node = el('c');
motion.run(node, 'enter');
motion.cancel(node);
expect(cancelled).toBe(true);
});
});

@ -197,6 +197,13 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
* Normalise a `MotionRun` result to a single `MotionHandle`. An `Animation` * Normalise a `MotionRun` result to a single `MotionHandle`. An `Animation`
* (or `Animation[]`) and a `MotionHandle` both expose `finished` + `cancel`; * (or `Animation[]`) and a `MotionHandle` both expose `finished` + `cancel`;
* cancellation also aborts the shared signal so a `spring` stops its frame loop. * cancellation also aborts the shared signal so a `spring` stops its frame loop.
*
* `finished` swallows rejection on purpose (the second `then` arm). A WAAPI
* `Animation.cancel()` REJECTS its `finished` with `AbortError`; without this,
* an interruption/reversa (RFC §8.3 calls `cancel(el)` mid-flight) would leak an
* unhandled rejection through `track`'s `.finally` and `pending`'s `Promise.all`.
* Resolving-on-cancel unifies every driver to `spring`'s "stop-in-place, resolve"
* semantic, so awaiters (`pending`, soma `Presence`) never see a throw.
*/ */
function toHandle( function toHandle(
result: Animation | readonly Animation[] | MotionHandle, result: Animation | readonly Animation[] | MotionHandle,
@ -205,7 +212,10 @@ function toHandle(
if (Array.isArray(result)) { if (Array.isArray(result)) {
const anims = result as readonly Animation[] const anims = result as readonly Animation[]
return { return {
finished: Promise.all(anims.map((a) => a.finished)).then(() => {}), finished: Promise.all(anims.map((a) => a.finished)).then(
() => {},
() => {}
),
cancel() { cancel() {
controller.abort() controller.abort()
for (const a of anims) safeCancel(a) for (const a of anims) safeCancel(a)
@ -214,7 +224,10 @@ function toHandle(
} }
const single = result as { finished: Promise<unknown>; cancel: () => void } const single = result as { finished: Promise<unknown>; cancel: () => void }
return { return {
finished: Promise.resolve(single.finished).then(() => {}), finished: Promise.resolve(single.finished).then(
() => {},
() => {}
),
cancel() { cancel() {
controller.abort() controller.abort()
safeCancel(single) safeCancel(single)

@ -9,6 +9,7 @@ export type { SpringConfig, SpringPhysics } from './drivers'
export { isCssStatePreset } from './types' export { isCssStatePreset } from './types'
export type { export type {
CoordinatedPreset,
CssPhase, CssPhase,
CssStatePreset, CssStatePreset,
EventSignature, EventSignature,

@ -130,15 +130,38 @@ export interface JsStatePreset {
export type StatePreset = CssStatePreset | JsStatePreset export type StatePreset = CssStatePreset | JsStatePreset
/**
* A COORDINATED preset (RFC: eidos/MOTION_SERVICE_RFC.md §M6) — for animable
* surfaces driven by a `PresenceGroup`, where the WHEN is governed by soma's
* `data-starting-style` / `data-ending-style` (the group's release), NOT `data-state`.
*
* Unlike `CssStatePreset` (keyframes on `data-state`, which fire on MOUNT and would
* fight the coordinated cascade — the §5 finding), this declares the "outside" state
* as a TRANSITION: eidos generates `[data-animation-style='X'][data-starting-style],
* [...][data-ending-style] { …off… }` + the transition + the canonical reversible
* stagger. The motion engine never runs these (pure CSS) — they live in
* `MotionConfig.coordinated`, NOT `presets`, so the engine returns a settled handle.
*/
export interface CoordinatedPreset {
/** The "outside" state — CSS prop → value, e.g. `{ opacity: '0', transform: 'translateX(-16px)' }`. */
readonly off: Readonly<Record<string, string>>
/** Duration token key (`'moderate'`) or raw. Overridable per-container via `--motion-cascade-duration`. */
readonly duration?: string
/** Ease token key (`'out'`) or raw. Overridable via `--motion-cascade-ease`. */
readonly ease?: string
}
/** /**
* The motion config: keyframes + the two animation surfaces. Eidos generates CSS * The motion config: keyframes + the two animation surfaces. Eidos generates CSS
* from `keyframes` + `signatures` + the css `presets`; the engine resolves + * from `keyframes` + `signatures` + the css `presets`; the engine resolves +
* runs the `presets` (settled for css, driver for js). * runs the `presets` (settled for css, driver for js). `coordinated` is M6 —
* CSS-only presets for `PresenceGroup`-driven surfaces (the engine never sees them).
*/ */
export interface MotionConfig { export interface MotionConfig {
readonly keyframes?: Readonly<Record<KeyframeName, KeyframeStops>> readonly keyframes?: Readonly<Record<KeyframeName, KeyframeStops>>
readonly signatures?: Readonly<Record<string, EventSignature>> readonly signatures?: Readonly<Record<string, EventSignature>>
readonly presets?: Readonly<Record<string, StatePreset>> readonly presets?: Readonly<Record<string, StatePreset>>
readonly coordinated?: Readonly<Record<string, CoordinatedPreset>>
} }
export function isCssStatePreset(preset: StatePreset): preset is CssStatePreset { export function isCssStatePreset(preset: StatePreset): preset is CssStatePreset {

@ -0,0 +1,666 @@
# 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](#0-tesis)
- [1. El estudio — referentes y dónde topan](#1-el-estudio--referentes-y-dónde-topan)
- [2. No-goals explícitos](#2-no-goals-explícitos)
- [3. Dónde está UIX hoy (estado verificado + gap)](#3-dónde-está-uix-hoy-estado-verificado--gap)
- [4. Superficie animable declarada en morfo (solo estructura)](#4-superficie-animable-declarada-en-morfo-solo-estructura)
- [5. Variants separables + naming (`animation` ↔ `data-animation-style`)](#5-variants-separables--naming-animation--data-animation-style)
- [6. Routing automático vs choreography opt-in](#6-routing-automático-vs-choreography-opt-in)
- [7. Coordinación de presencia — el `PresenceGroup`](#7-coordinación-de-presencia--el-presencegroup)
- [7.1. El árbol de presencia es lógico (context), NO DOM](#71-el-árbol-de-presencia-es-lógico-context-no-dom)
- [8. Salida, interrupción y reversa (el caso difícil)](#8-salida-interrupción-y-reversa-el-caso-difícil)
- [9. Motor: agregación por el `PresenceGroup`, no `subtree:true` ciego](#9-motor-agregación-por-el-presencegroup-no-subtreetrue-ciego)
- [10. Reduced-motion · firma semántica · SSR/hydration](#10-reduced-motion--firma-semántica--ssrhydration)
- [11. Integración de los efectos WIP](#11-integración-de-los-efectos-wip)
- [12. Encaje con las 4 capas + qué NO hace](#12-encaje-con-las-4-capas--qué-no-hace)
- [13. Validación — prototipo exit-heavy](#13-validación--prototipo-exit-heavy)
- [14. Impacto sobre el código actual + migración](#14-impacto-sobre-el-código-actual--migración)
- [15. Roadmap de implementación por fases](#15-roadmap-de-implementación-por-fases)
- [Apéndice A — decisiones resueltas vs abiertas](#apéndice-a--decisiones-resueltas-vs-abiertas)
---
## 0. Tesis
> **Una animación se _aplica_, no se _envuelve_. La coreografía se _declara_ en el contrato,
> no se _escribe_ en cada call-site. Y la coordinación entre superficies es lifecycle
> (state), no pintura (visual).**
Hoy, para animar de forma coordinada hay que envolver elementos a mano (Framer:
`<motion.div>` por nodo + `<AnimatePresence>`) o escribir timelines imperativas (GSAP). UIX
ya tiene un contrato declarativo cross-layer (**morfo**) y un lifecycle de presencia headless
(**`Presence`**, en soma). La apuesta de este servicio es **proyectar la coreografía sobre
ese contrato**: el componente declara _qué partes son superficies animables y cómo se
coordinan_, soma generaliza `Presence` de una superficie a un **árbol de presencia**, y el
desarrollador solo **nombra** la animación. El sistema sabe **dónde** aplicarla (la superficie
está declarada) y **cuándo** (la coordinación está declarada).
Tres propiedades que ningún referente reúne, y que NO incluyen "coreografía declarativa" a
secas (Framer ya la tiene):
- **(a)** la coreografía vive en el **contrato (morfo)**, una vez por componente, no en el
JSX de cada uso;
- **(b)** **enrutado automático a la superficie** — Framer no lo tiene porque allí envuelves
manualmente;
- **(c)** **composición con la firma semántica** (sema): la misma `commit + fulfill` que
dispara sound/haptic/visual coordina también la entrada de los hijos.
---
## 1. El estudio — referentes y dónde topan
| Framework | Modelo | Coreografía padre↔hijo | Límite estructural |
| ---------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Framer Motion** | `variants` declarativos + `<AnimatePresence>` para exit | ✅ `when: 'beforeChildren'/'afterChildren'`, `staggerChildren`, `delayChildren` | la coreografía vive en el **JSX de la app** (cada call-site la cablea); el target se **envuelve a mano** (`<motion.div>`); atado a React |
| **GSAP** | timelines imperativas (`tl.to(...).to(..., '<')`) | ✅ manual, control total | **imperativo**: el guion se escribe a mano; ninguna relación con el contrato del componente |
| **Svelte transitions** | directivas `in:`/`out:`/`transition:`, `animate:flip` | ⚠️ parcial (`flip` por lista; sin `when`/stagger jerárquico) | por-elemento; sin coordinación de árbol con dependencias |
| **AutoAnimate** | `autoAnimate(el)` zero-config sobre cambios de children | ❌ (anima cambios, no coreografía dirigida) | una sola caja; sin secuencia/paralelo declarado |
| **Chakra (Ark)** | `data-state` + `Presence` (un eje) | ❌ | colapsa la animación de presence en un solo eje; sin firma ni árbol |
**El límite común** —incluso Framer, el más fino— es que la coreografía es **algo que el
consumidor escribe en cada uso** y el target es **algo que el consumidor envuelve**. Nadie la
trata como **propiedad declarada del componente** ni la **compone con la semántica del
evento**. Ahí está el hueco (a)(b)(c) de §0.
> **Honestidad obligatoria (§1 no sobreafirma):** Framer Motion **ya hace** coreografía
> declarativa padre↔hijo. Nuestro `children: { enter, exit }` (§4) es ~1:1 con su `when`. Reclamar
> "coreografía declarativa" como novedad sería falso y haría perder credibilidad al resto.
> Lo nuevo es **dónde vive** (el contrato, no el call-site), el **enrutado** y la
> **composición con sema**.
---
## 2. No-goals explícitos
Declarar el alcance evita que un revisor lo pida y que el servicio se desenfoque.
1. **Layout animations / shared layout / reordenación automática** — **no-goal a propósito.**
El "shared layout" de Framer (un elemento que parece viajar entre dos posiciones de
layout, `layoutId`) NO entra. Matiz técnico: el driver `rect` (FLIP) de `arts/motion`
mide **un solo nodo** (`el.getBoundingClientRect()` antes/después) — sirve para que **una**
superficie absorba su propio salto de layout, **no** para transiciones de elemento
compartido entre dos árboles. El RFC corta esa expectativa de raíz.
2. **Fondos / efectos WebGL** (`web/routes/demos/animations/background/*`: aurora, galaxy,
particles, liquid-image, …) — **fuera del servicio.** No son superficies enter/exit; son
efectos ambientales full-canvas. Pertenecen a otro eje (un art / componentes "ambient"
tipo `<Background variant="aurora">`), que se diseñará por separado.
3. **Reinventar GSAP** — el servicio **no** construye timelines imperativas de propósito
general. El JS coordina **presencia** (timing de mount/unmount/await), no compone
tweens arbitrarios.
Lo que **sí** es goal: los **text-effects** WIP (blur/count/scrambled/…) entran como
candidatos a _variants de contenido_ (§11), porque sí son animación de contenido al entrar.
---
## 3. Dónde está UIX hoy (estado verificado + gap)
Verificado leyendo el código, no por memoria:
- **El motor es por-nodo.** `EngineMotion.run(el, phase)` lee el `data-animation-style` de
_ese_ nodo, resuelve el preset y lo corre; `pending(el)` / `cancel(el)` se indexan por
elemento (`Map<HTMLElement, Set<MotionHandle>>`). No conoce árbol, padres ni hijos.
→ `src/arts/motion/engine-motion.ts`.
- **`Presence` es una isla por superficie.** Coordina una sola superficie (`opts.ref.current`)
y espera `node.getAnimations()` **sin `{ subtree: true }`**. Un Dialog o un ContextMenu
crean **varios `new Presence` independientes** (overlay, content) que coinciden en el tiempo
por casualidad, no por coordinación. → `src/uix/soma/layers/presence.svelte.ts`.
- **La coreografía se delega a la app.** El propio código de los presets Material shared-axis
lo dice: `// Per-element here; an app triggers both sides together for the "shared" effect.`
→ `src/uix/eidos/lib/motion/presets/css.ts`.
- **La selección del variant vive en eidos.** El momento-estado se aplica vía prop
`motion="scale-fade"` → atributo `data-animation-style`. → `eidos-motion.md` (TL;DR).
- **morfo no declara nada de animación.** `MorfoPart` declara `data` / `aria` / `keyboard` /
`states` / `archetype` / `parts`, pero **ningún concepto de "superficie animable"** ni de
coordinación. Lo más cercano es `events[].semantic.sequence` (`pre|coincident|post`), que
ordena el momento-evento vs el momento-estado **del mismo componente**, no entre superficies.
→ `src/uix/morfo/types.ts`.
**El gap, en una frase:** UIX anima superficies sueltas; no hay un árbol de presencia que las
coordine. Este RFC añade ese árbol **en soma** (donde ya vive el lifecycle de presencia),
declarado **en morfo** y vestido **en eidos**.
---
## 4. Superficie animable declarada en morfo (solo estructura)
Una `MorfoPart` gana un campo opcional. **Solo lleva estructura** — nada visual ni expresivo:
```ts
// src/uix/morfo/types.ts — añadido a MorfoPart
animation?: {
/** Esta part es una superficie animable: tiene lifecycle de presencia coordinable. */
surface?: boolean
/** Dependencia de coordinación con las superficies hijas (opt-in: declararlo activa la coreografía). */
children?: {
/**
* Relación temporal del padre con sus hijos, INDEPENDIENTE por fase. Cada valor:
* - 'before' → el padre actúa ANTES (enter: entra y luego suelta a los hijos;
* exit: se va antes que ellos)
* - 'after' → el padre actúa DESPUÉS (exit: RETIENE su DOM hasta que los hijos
* terminen de salir — §8.1)
* - 'together' → padre e hijos en paralelo (default de la fase ausente)
*/
enter?: 'before' | 'after' | 'together'
exit?: 'before' | 'after' | 'together'
}
}
```
**Por qué `enter`/`exit` por separado y no un `when` simétrico.** Un único `when` ata las dos
fases a la misma relación, y el caso más común —un **contenedor que bracketea a sus hijos**—
**no es simétrico**: entra ANTES que ellos (`enter: 'before'`) y sale DESPUÉS (`exit: 'after'`,
reteniendo su DOM hasta que terminen). El prototipo (§13) lo destapó: con un `when` simétrico el
bracket era inexpresable. Separar las fases lo hace declarable sin coste — la fase ausente cae
a `'together'`.
**Por qué van en morfo y el stagger-ms NO.** `enter`/`exit` tienen **consecuencia de
lifecycle**: `exit: 'after'` obliga a soma a **retener el DOM del padre** hasta que los hijos
terminen su salida (§8.1). Eso es state, lo necesita soma → es estructura → morfo. En cambio el
**valor** del stagger (80 ms), el easing, los keyframes y el orden visual son **expresivos** →
viven en eidos (tokens/recipe), nunca en morfo. La regla 2-de-3 lo confirma: el campo lo
consumen **soma** (lifecycle) y **eidos** (lo viste) — dos capas → justificado en el contrato.
> **Nota sobre `order`.** El _orden_ del escalonado (forward/reverse) es expresivo y vive en
> eidos. Soma no lo necesita: el escalonado lo pinta el CSS (§7). Lo que soma observa es el
> **orden de registración** (§7.1), que el RFC fija = document order.
El compilador (`compile.ts`) traduce este campo a un _plan de coordinación_ que el
`PresenceGroup` consume; el validador (`schema.ts`) comprueba que `children` solo aparezca en
parts con `surface: true` y que las partes referenciadas existan.
---
## 5. Variants separables + naming (`animation` ↔ `data-animation-style`)
**El objetivo de DX:** el dev escribe `animation="scale-fade"` en **cualquier** componente
animable y el sistema lo **enruta** a la superficie declarada (la part con `surface: true`),
sin que el dev sepa qué part es. "El sistema ya sabe dónde."
Esto convierte un variant en **separable y reutilizable**: `scale-fade` no pertenece al
Dialog; pertenece al catálogo y se aplica a cualquier superficie. Es el momento-estado de
`eidos-motion.md`, elevado de "prop de un wrapper concreto" a "prop transversal enrutado".
**Decisión-prerrequisito (no una nota): `animation` ↔ `motion`/`data-animation-style`.**
Tener dos props que nombran animación es deuda cara. La ruta recomendada es **estratificar,
no duplicar**:
| Capa | Identificador | Rol |
| ---------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| **Contrato de bajo nivel (motor)** | `data-animation-style` (atributo) | lo que `EngineMotion.run` lee del nodo; **se conserva** |
| **DX de alto nivel (autor)** | `animation` (prop transversal) | azúcar enrutado: el wrapper lo resuelve a la superficie declarada y emite `data-animation-style` allí |
Así `data-animation-style` sigue siendo el contrato estable (cero ruptura para eidos/motor) y
`animation` es la cara ergonómica. La prop `motion` actual de los wrappers de eidos queda como
**alias** de `animation` (o se deprecia en una fase posterior; decisión del roadmap §15), sin
romper consumidores.
> **Estado — incremento M5 (hecho, sobre `Reveal`).** El enrutado existe: `<Reveal.Provider
animation="X">` se nombra una vez en la raíz y el provider lo enruta a las parts `surface: true`
> del morfo COMPILADO (`routeAnimation(kebab)` consulta el set de superficies derivado de
> `compileMorfo`; una part no-superficie —el Trigger— recibe `undefined`, nunca se mis-targetea).
> Los wrappers Panel/Item emiten `data-animation-style` con el valor enrutado. Unit-tested.
>
> **Hallazgo — el enrutado da el QUÉ, no el CUÁNDO.** Los presets BUILT-IN de eidos (`scale-fade`,
> `slide-fade`…) reaccionan a `data-state` (disparan al MONTAR) → NO componen con superficies
> COORDINADAS, donde el cuándo lo gobierna el `data-starting-style`/release del `PresenceGroup`: un
> preset data-state dispararía todo al montar y rompería la cascada. Por eso la demo reacciona a
> `[data-animation-style='X'][data-starting-style]` (el attr da el QUÉ; los attrs del Presence dan el
> CUÁNDO). Reconciliar la LIBRERÍA de presets con superficies coordinadas (un preset/variante que
> reaccione a `data-starting/ending-style`) está **HECHO (M6)**: la librería de eidos gana
> `MotionConfig.coordinated` — presets `cascade-slide`/`-fade`/`-scale` que eidos genera como
> transición (off-state + stagger reversible) sobre los attrs del Presence, no `data-state`. El motor
> nunca los corre (CSS puro). Un coordinado hace `animation="cascade-slide"` sin CSS propio.
> **Hallazgo gemelo — la FIRMA sema genérica también choca.** El mismo patrón con el otro sistema
> visual de eidos: al disparar `runtime.trigger('open'/'close')`, sema estampa `data-event-*` y eidos
> corre la firma genérica `emerge` (`present-rise`/`dismiss-fade … forwards`, `generated/base.css`)
> ENCIMA de la cascada coordinada. En `Reveal` se vio como "otra animación" al cerrar con el botón
> (que va por `runtime.trigger`) ausente al usar el reversa (que pone `open` directo). **Doctrina: un
> componente de movimiento COORDINADO silencia la señal sema genérica** — sus eventos declaran
> `semantic.channels: []` (el engine corta antes de estampar, `sema/engine.ts`) y `expression: 'none'`:
> su percepción ES la coordinación, no la firma. Generaliza el hueco §5: la cascada coordinada
> (data-starting/ending-style) es un tercer sistema visual que NO compone con los otros dos de eidos
> (presets data-state · firmas data-event) — un coordinado debe optar fuera de ambos.
---
## 6. Routing automático vs choreography opt-in
Lo que parecía una decisión binaria ("¿coordinación automática u opt-in?") se disuelve
separando **dos cosas distintas**:
- **Routing a superficie = automático / zero-config.** El dev nunca cablea superficies:
`animation="..."` se enruta solo a la part `surface: true`. Es el corazón del "sin nada más".
- **Coreografía padre↔hijo = opt-in por construcción.** Solo ocurre si la part **declara**
`children` en morfo (§4). Un componente que no lo declara nunca arrastra a sus hijos.
El beneficio combinado: coherencia con "sin nada más" **sin** el riesgo de que un padre
empiece a **esperar misteriosamente** a una superficie anidada profunda que el autor no quería
coordinar. La coordinación es explícita en el contrato; el routing es implícito en el uso.
---
## 7. Coordinación de presencia — el `PresenceGroup`
`Presence` hoy gestiona el lifecycle de **una** superficie: monta, marca
`data-starting-style`/`data-ending-style`, espera las animaciones, desmonta. El servicio lo
**generaliza a un árbol** con una clase hermana — **`PresenceGroup`** (el nombre es
deliberado: coordina _presencia_, state; no _motion_, visual):
```ts
// src/uix/soma/layers/presence-group.svelte.ts (nuevo)
// Presence generalizado de 1 superficie a un árbol. Pure lifecycle/state — NO visual.
class PresenceGroup {
static create(structure: AnimationStructure): PresenceGroup; // root → set in context
static get(): PresenceGroup | undefined; // children discover it
/** A surface (or a nested group) cedes its "when" to this group. */
register(member: PresenceMember): () => void; // returns deregister
/** Coordinate the tree for a phase: ordered mount/unmount/await + aggregate `finished`. */
play(phase: 'enter' | 'exit'): Promise<void>;
/** Propagate cancellation to every registered member (interrupt/reverse). */
cancel(): void;
}
```
`Presence` gana una opción `group?` (descubierta por context). Si hay grupo, la superficie
**cede el timing**: en vez de arrancar su `motion.run` por su cuenta en `handleOpen`, se
**registra** y espera la señal del grupo (`release(phase)`). Si no hay grupo, `Presence`
**degrada al comportamiento de hoy** (isla autónoma) — backward-compat total.
**Qué coordina el grupo y qué NO.** El grupo decide _cuándo_ existe / monta / espera / cancela
cada superficie (state). El **cómo-se-ve** sigue siendo de eidos: el **stagger visual lo pinta
el CSS** (`animation-delay` escalonado por índice, como hoy). Soma **solo monta y espera el
`finished` agregado** — **nunca calcula un milisegundo**. Esta es la línea state/visual hecha
mecanismo: el grupo no conoce el valor del stagger; lo pone el delay CSS, y el grupo solo
agrega los `finished` (que ya incluyen ese delay).
**Las dos direcciones** (consecuencia de `children.when`):
```
play('enter'):
when 'before' → corre la superficie del padre; al (o durante) su animación, libera a los hijos
when 'together' → libera a todos a la vez (el stagger CSS los escalona visualmente)
when 'after' → libera a los hijos; cuando su finished agrega, corre la del padre
play('exit'):
when 'after' → emite exit a los hijos, RETIENE el DOM del padre, agrega su finished,
y solo entonces corre la salida del padre y libera la retención (§8.1)
```
**Modelo de coordinación — DECISIÓN (recomendación: híbrido declarativo).** Tres opciones:
| Modelo | Cómo | Veredicto |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Híbrido declarativo** _(recomendado)_ | morfo declara la estructura; soma coordina el timing y agrega `finished`; **el stagger/escalonado lo pinta CSS** (eidos); el motor ejecuta por-nodo. El JS solo coordina presencia. | respeta la línea state/visual; no reinventa GSAP; reusa `Presence` |
| **Variants propagados (Framer)** | el padre propaga su fase a los hijos por context con `staggerChildren`/`when` en el árbol de componentes | mete lógica de coordinación en el árbol de componentes; más cerca de soma que de morfo; el timing visual se filtra a JS |
| **Timeline / grafo explícito** | un orquestador construye una timeline con offsets/dependencias y la ejecuta vía WAAPI groups / scheduler | máximo control; pero maquinaria JS pesada y riesgo de reinventar GSAP / alejarse de lo declarativo |
El híbrido es el único que mantiene a soma fuera de los valores visuales.
### 7.1. El árbol de presencia es lógico (context), NO DOM
La pregunta clave: _¿cómo se gestiona el árbol si entre un padre animable y sus hijos
animables hay componentes sin animación? Los padres tienen "saltos" en el árbol._
**Respuesta: el árbol que se coordina es el de superficies animables proyectado sobre el
`context` de Svelte, no el árbol DOM.** Y el context **no depende de adyacencia DOM**, así que
los saltos se resuelven solos:
- Un ancestro animable pone un `PresenceGroup` en context.
- Los componentes **sin animación** (wrappers de layout, `{#if}`, una `<ul>` tonta) son
**transparentes**: no crean grupo ni se registran. El context del grupo **fluye a través de
ellos** sin que lo toquen.
- El siguiente descendiente animable —a la profundidad DOM que sea— hace `PresenceGroup.get()`,
encuentra el grupo del **ancestro animable más cercano** y se **registra**. El padre conoce
a sus hijos animables **por registración, no por proximidad DOM**.
- Un intermedio que **sí** es animable crea su propio grupo y **corta el descenso** (caja
negra, §9) — única frontera del árbol lógico.
Implicaciones que el resto del RFC hereda:
1. **El count no sale del DOM** (los descendientes cuelgan a profundidad arbitraria) → §8.2.
2. **El orden del stagger = orden de registración** (= montaje = document order), modulado por
el `order` visual de eidos; **nunca la profundidad DOM**.
3. **La retención en exit aguanta el salto**: retener el **nodo del ancestro** retiene
transitivamente su subárbol entero (wrappers incluidos) → §8.1 no se rompe.
4. **Des-registración abrupta** (caso de borde): un wrapper intermedio con su propio `{#if}`
que se desmonta deja a sus hijos animables sin exit coordinado → el grupo debe tolerar un
**set dinámico** de registros (aparecen/desaparecen) y **no colgar** su promesa por un
miembro que se fue (liga con el timeout anti-deadlock de §8.2).
---
## 8. Salida, interrupción y reversa (el caso difícil)
La entrada en cascada (`enter` + stagger) es trivial; todo framework la borda. El estrés real
—donde los sistemas mueren— está en la **salida coordinada** (`after`) y en la **interrupción**
(un `enter` a medias que pasa a `exit`). Esta sección combina el lifecycle de soma con el
contrato de morfo para resolverlo.
### 8.1. Retención del DOM en exit coordinado
Cuando un padre declara `exit: 'after'`, el nodo padre **no puede desmontarse** hasta que
todos los hijos orquestados terminen su animación de salida.
**Mecanismo:** el `PresenceGroup` del padre **retiene el bloque en el DOM** (vía el
`shouldRender` de `Presence` / Svelte). Al dispararse `play('exit')`, emite la señal de salida
a sus hijos registrados, **recolecta sus `finished` agregados**, y **solo cuando todas
resuelven** ejecuta su propia animación de salida; al terminar, **libera la retención** y el
nodo se desmonta. El **escalonado visual** de la salida lo pone el CSS (eidos,
`animation-delay`); soma solo **espera** el agregado. Coherente con la línea state/visual: el
grupo nunca decide cuánto se escalona, solo cuándo desmontar.
### 8.2. Cierre del conjunto de registración (el problema async)
En render condicional o diferido, el padre **no puede** esperar solo "las superficies que se
hayan registrado en este tick": podría asumir que no hay hijos antes de que terminen de
montar. La **bala de plata es el contrato** — pero la **fuente del count se bifurca** (porque
con saltos, §7.1, el DOM no sirve para contar):
- **Cardinalidad fija** (parts nombradas: `overlay` + `content`) → el count exacto lo da
**morfo** (sabe cuántas superficies-hijas declaró). El padre **bloquea** hasta que
`registros == count`, con un **timeout de seguridad anti-deadlock** (si un hijo se excluye
lógicamente del render, no se cuelga para siempre).
- **Colecciones** (part `item` repetida N veces — el caso del prototipo §13) → morfo declara
el **tipo** de superficie hija, **no el número** de instancias (el count es runtime). El
count lo aporta el **provider de la colección** (conoce su `items.length`) o un _commit_
explícito cuando la colección termina de montar.
> Esta bifurcación es directa consecuencia de §7.1: si el árbol es lógico y hay saltos,
> contar por estructura DOM es imposible; el count tiene que venir de una fuente que lo
> conozca declarativamente (morfo) o de runtime (provider de la colección).
### 8.3. Interrupción y reversa (máquina de estados) — IMPLEMENTADO (M4)
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`.
**Lo que M4 NO entrega todavía — la precisión honesta:** la reversa **fluida desde la
posición/velocidad actual** NO es gratis y queda para **M6**:
- **JS (`spring`/`waapi`):** hoy M4 hace **cancel-del-viejo + arranca-el-nuevo** — un reinicio
limpio, pero el nuevo run parte del `from` declarado por el preset, NO de la posición
interpolada actual (el motor no expone lectura de valor/velocidad). Un _handoff_ que preserve
velocidad exige que el motor exponga el estado actual del driver → M6.
- **CSS `@keyframes`:** **salta** al `from` del keyframe de salida al invertir. "Sin saltos"
exige una de tres políticas (decisión de M6, por preset): (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). El prototipo
`/temas/animations/presence-group` usa **transiciones CSS** (no `@keyframes`), que SÍ
interrumpen desde el valor actual — por eso ahí la reversa se ve fluida sin esfuerzo (el caso
(1) implícito).
No prometer fluidez universal es parte del rigor: M4 garantiza la **corrección** (sin unmount
espurio, sin animaciones apiladas, sin rejection filtrado); la **continuidad perceptual fina**
del path JS/`@keyframes` es trabajo de M6.
---
## 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 (incremento hecho) + naming.** 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. Pendiente: el alias de `motion`/estratificación formal (§5). El gating
data-state↔data-starting-style va a 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`. Pendiente: migrar los demos a los presets (visual) + reversa fluida desde
la posición actual (§8.3).
- **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: estratificar** `animation` (DX) sobre `data-animation-style` (contrato). (§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).
- **Destino final de la prop `motion`** (alias permanente vs deprecación; decidir en M5).
---
> **Fuentes.** Canon semántico: [`docs/CANON.md`](../../../docs/CANON.md). Arquitectura de
> capas: [`active_architecture.md`](../active_architecture.md). Motion actual (que este RFC
> extiende): [`eidos-motion.md`](./eidos-motion.md). Motor: `src/arts/motion/README.md`.
> Lifecycle de presencia: `src/uix/soma/layers/presence.svelte.ts`.

@ -1731,6 +1731,15 @@
--calendar-transition-duration: var(--duration-fast); --calendar-transition-duration: var(--duration-fast);
--calendar-transition-ease: var(--ease-default); --calendar-transition-ease: var(--ease-default);
--calendar-disabled-opacity: 0.52; --calendar-disabled-opacity: 0.52;
--chronos-cell-min-block-sm: calc(var(--control-height-md) * 2.6);
--chronos-cell-min-block-md: calc(var(--control-height-md) * 3.2);
--chronos-cell-min-block-lg: calc(var(--control-height-md) * 4);
--chronos-num-row: var(--space-7);
--chronos-lane-height-sm: var(--space-5);
--chronos-lane-height-md: var(--space-6);
--chronos-lane-height-lg: var(--space-7);
--chronos-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--chronos-disabled-opacity: 0.52;
--pagination-gap-xs: var(--space-0-5); --pagination-gap-xs: var(--space-0-5);
--pagination-gap-sm: var(--space-1); --pagination-gap-sm: var(--space-1);
--pagination-gap-md: var(--space-1-5); --pagination-gap-md: var(--space-1-5);
@ -5167,6 +5176,47 @@
} }
} }
[data-animation-style='cascade-slide'] {
transition: opacity var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out)), transform var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out));
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='cascade-slide'][data-ending-style] {
transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='cascade-slide'][data-starting-style], [data-animation-style='cascade-slide'][data-ending-style] {
opacity: 0;
transform: translateX(-16px);
}
[data-animation-style='cascade-fade'] {
transition: opacity var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out));
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='cascade-fade'][data-ending-style] {
transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='cascade-fade'][data-starting-style], [data-animation-style='cascade-fade'][data-ending-style] {
opacity: 0;
}
[data-animation-style='cascade-scale'] {
transition: opacity var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out)), transform var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out));
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='cascade-scale'][data-ending-style] {
transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms));
}
[data-animation-style='cascade-scale'][data-starting-style], [data-animation-style='cascade-scale'][data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
@media (forced-colors: active) { @media (forced-colors: active) {
:focus-visible { :focus-visible {
outline: 2px solid Highlight; outline: 2px solid Highlight;

@ -16,7 +16,7 @@
* measured size a component sets). The keyframe is a template. * measured size a component sets). The keyframe is a template.
*/ */
import type { CssStatePreset, EventSignature, KeyframeStops } from '$motion' import type { CoordinatedPreset, CssStatePreset, EventSignature, KeyframeStops } from '$motion'
const D = 'var(--motion-distance-md)' const D = 'var(--motion-distance-md)'
const NEG_D = `calc(${D} * -1)` const NEG_D = `calc(${D} * -1)`
@ -376,3 +376,18 @@ export const BUILTIN_CSS_PRESETS: Readonly<Record<string, CssStatePreset>> = {
reduce: 'opacity-only' reduce: 'opacity-only'
} }
} }
/**
* Coordinated presets (M6) — for `PresenceGroup`-driven surfaces. They react to
* soma's `data-starting-style` / `data-ending-style` (the group's release), NOT
* `data-state`, so they compose with the coordinated cascade instead of firing on
* mount. A component sets `animation="cascade-…"` + the rhythm
* (`--motion-stagger-each` / `--motion-stagger-count`); eidos generates the transition,
* the off-state, and the reversible stagger. `cascade-` prefix keeps them out of the
* `data-state` preset namespace. Custom ones: add to `EidosConfig.motion.coordinated`.
*/
export const BUILTIN_COORDINATED_PRESETS: Readonly<Record<string, CoordinatedPreset>> = {
'cascade-slide': { off: { opacity: '0', transform: 'translateX(-16px)' } },
'cascade-fade': { off: { opacity: '0' } },
'cascade-scale': { off: { opacity: '0', transform: 'scale(0.9)' } }
}

@ -49,6 +49,7 @@ import { toKebab } from './utils'
import { STATIC_SCALING } from './primitives/static' import { STATIC_SCALING } from './primitives/static'
import type { import type {
CssStatePreset, CssStatePreset,
CoordinatedPreset,
CssPhase, CssPhase,
EventSignature, EventSignature,
KeyframeName, KeyframeName,
@ -669,6 +670,12 @@ function renderMotionBlocks(motion: MotionConfig): string {
blocks.push(...renderReducedMotionRules(name, preset)) blocks.push(...renderReducedMotionRules(name, preset))
} }
// M6: coordinated presets — transition-based, gated on the Presence's
// data-starting/ending-style (NOT data-state). The motion engine never runs these.
for (const [name, preset] of Object.entries(motion.coordinated ?? {})) {
blocks.push(...renderCoordinatedPresetRules(name, preset))
}
return blocks.join('\n\n') return blocks.join('\n\n')
} }
@ -736,6 +743,32 @@ function phaseDeclarations(
return declarations return declarations
} }
// M6: a coordinated preset is COMPLETE CSS for a `PresenceGroup`-driven surface —
// the off-state (gated on the Presence's data-starting/ending-style), the transition
// over exactly the off properties, and the reversible canonical stagger. A component
// just sets `animation="<name>"` + the rhythm (`--motion-stagger-each` / `-count`);
// no per-component animation CSS. Duration/ease are overridable via `--motion-cascade-*`
// (inheriting, so a container sets them once for its whole cascade).
function renderCoordinatedPresetRules(name: string, preset: CoordinatedPreset): string[] {
const dur = `var(--motion-cascade-duration, var(--duration-${preset.duration ?? 'moderate'}))`
const ease = `var(--motion-cascade-ease, var(--ease-${preset.ease ?? 'out'}))`
const sel = `[data-animation-style='${name}']`
const transition = Object.keys(preset.off)
.map((prop) => `${prop} ${dur} ${ease}`)
.join(', ')
const offDecls = Object.entries(preset.off).map(([prop, value]) => `${prop}: ${value};`)
return [
renderBlock(sel, [
`transition: ${transition};`,
'transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));'
]),
renderBlock(`${sel}[data-ending-style]`, [
'transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms));'
]),
renderBlock(`${sel}[data-starting-style], ${sel}[data-ending-style]`, offDecls)
]
}
// Reduced motion: 'none' runs as-is (a pure fade is acceptable); 'instant' // Reduced motion: 'none' runs as-is (a pure fade is acceptable); 'instant'
// kills the animation (snap to the steady `[data-state]` style); 'opacity-only' // kills the animation (snap to the steady `[data-state]` style); 'opacity-only'
// keeps a brief fade and drops transforms. Emitted both under the projected // keeps a brief fade and drops transforms. Emitted both under the projected

@ -13,7 +13,12 @@ import type {
} from '../config-types' } from '../config-types'
import { THEME_BASE_RECIPE_TOKENS } from '../recipes/base' import { THEME_BASE_RECIPE_TOKENS } from '../recipes/base'
import { RADIX_EXTRA_LIGHT_SCALES, RADIX_EXTRA_DARK_SCALES } from './radix-scales' import { RADIX_EXTRA_LIGHT_SCALES, RADIX_EXTRA_DARK_SCALES } from './radix-scales'
import { BUILTIN_KEYFRAMES, BUILTIN_SIGNATURES, BUILTIN_CSS_PRESETS } from '../motion/presets/css' import {
BUILTIN_KEYFRAMES,
BUILTIN_SIGNATURES,
BUILTIN_CSS_PRESETS,
BUILTIN_COORDINATED_PRESETS
} from '../motion/presets/css'
export const THEME_BASE_COLOR_ROLES: ColorRoleMap = { export const THEME_BASE_COLOR_ROLES: ColorRoleMap = {
primary: 'purple', primary: 'purple',
@ -481,7 +486,8 @@ export const THEME_BASE_OPTIONS: EidosConfig = defineEidosConfig({
motion: { motion: {
keyframes: BUILTIN_KEYFRAMES, keyframes: BUILTIN_KEYFRAMES,
signatures: BUILTIN_SIGNATURES, signatures: BUILTIN_SIGNATURES,
presets: BUILTIN_CSS_PRESETS presets: BUILTIN_CSS_PRESETS,
coordinated: BUILTIN_COORDINATED_PRESETS
}, },
themes: { themes: {
'base-light': { 'base-light': {

@ -46,6 +46,31 @@ describe('eidos motion — CSS generation', () => {
); );
}); });
it('emits coordinated presets (M6) — gated on data-starting/ending-style, not data-state', () => {
// The off-state is applied while soma holds data-starting-style (before enter)
// or data-ending-style (during exit) — the group's release, NOT data-state.
expect(css).toContain(
"[data-animation-style='cascade-slide'][data-starting-style], [data-animation-style='cascade-slide'][data-ending-style] {"
);
// The transition covers exactly the off properties (slide = opacity + transform),
// with the INHERITING `--motion-cascade-*` overrides (a container sets them once).
expect(css).toContain(
'transform var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out))'
);
// fade is opacity-only → no transform in its rules.
expect(css).toContain("[data-animation-style='cascade-fade'][data-starting-style]");
// The reversible canonical stagger: enter counts UP, exit counts DOWN.
expect(css).toContain(
'transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms))'
);
expect(css).toContain(
'transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms))'
);
// Crucially: coordinated presets do NOT emit data-state rules (those fire on
// MOUNT and would break the coordinated cascade — the §5 finding).
expect(css).not.toContain("[data-animation-style='cascade-slide'][data-state=");
});
it('emits the Material transition presets (shared-axis + fade-through)', () => { it('emits the Material transition presets (shared-axis + fade-through)', () => {
expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`); expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`);
expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`); expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`);
@ -56,7 +81,9 @@ describe('eidos motion — CSS generation', () => {
}); });
it('emits side-aware variants for slide-fade (data-side)', () => { it('emits side-aware variants for slide-fade (data-side)', () => {
expect(css).toContain(`[data-animation-style='slide-fade'][data-side='top'][data-state='open']`); expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='top'][data-state='open']`
);
expect(css).toContain( expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='bottom'][data-state='open']` `[data-animation-style='slide-fade'][data-side='bottom'][data-state='open']`
); );
@ -72,7 +99,9 @@ describe('eidos motion — CSS generation', () => {
); );
expect(css).toContain('@media (prefers-reduced-motion: reduce)'); expect(css).toContain('@media (prefers-reduced-motion: reduce)');
// instant preset kills the animation // instant preset kills the animation
expect(css).toContain(`[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']`); expect(css).toContain(
`[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']`
);
expect(css).toMatch( expect(css).toMatch(
/\[data-motion='reduce'\] \[data-animation-style='collapse'\]\[data-state='open'\] \{\s*animation: none !important;/ /\[data-motion='reduce'\] \[data-animation-style='collapse'\]\[data-state='open'\] \{\s*animation: none !important;/
); );
@ -183,7 +212,9 @@ describe('eidos motion — validation', () => {
} }
}); });
expect(report.ok).toBe(false); expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe(true); expect(report.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe(
true
);
}); });
}); });

@ -436,3 +436,91 @@ describe('compileMorfo — invariants', () => {
expect(Object.isFrozen(compiled.contracts)).toBe(true) expect(Object.isFrozen(compiled.contracts)).toBe(true)
}) })
}) })
describe('compileMorfo — animation surfaces (RFC: MOTION_SERVICE_RFC.md §4)', () => {
// Synthetic morfo: a list whose Provider is an animable surface that
// coordinates its Item children on EXIT (`when: 'after'` — the parent waits
// for the items to leave). Mirrors the exit-heavy prototype of RFC §13.
const listMorfo = {
name: 'AnimList',
kebab: 'anim-list',
scope: ['soma', 'eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'ul',
optional: false,
data: [],
aria: [],
animation: { surface: true, children: { exit: 'after' } }
},
{
name: 'Item',
kebab: 'item',
kind: 'public',
defaultElement: 'li',
optional: false,
data: [],
aria: [],
animation: { surface: true }
},
{
name: 'Label',
kebab: 'label',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as never
it('normalizes a coordinating surface (surface + children.exit)', () => {
const compiled = compileMorfo(listMorfo)
const provider = compiled.parts.byKebab.get('provider')!
expect(provider.animation).toEqual({
surface: true,
children: { enter: 'together', exit: 'after' }
})
})
it('normalizes a leaf surface (surface only, no children)', () => {
const compiled = compileMorfo(listMorfo)
const item = compiled.parts.byKebab.get('item')!
expect(item.animation).toEqual({ surface: true, children: undefined })
})
it('leaves animation undefined for non-surface parts', () => {
const compiled = compileMorfo(listMorfo)
const label = compiled.parts.byKebab.get('label')!
expect(label.animation).toBeUndefined()
})
it("defaults children.enter/exit to 'together' when omitted", () => {
const morfo = {
name: 'Together',
kebab: 'together',
scope: ['soma'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: [],
animation: { surface: true, children: {} }
}
]
} as never
const compiled = compileMorfo(morfo)
expect(compiled.parts.byKebab.get('provider')!.animation).toEqual({
surface: true,
children: { enter: 'together', exit: 'together' }
})
})
})

@ -29,6 +29,7 @@
import type { import type {
Morfo, Morfo,
MorfoA11ySemantic, MorfoA11ySemantic,
MorfoAnimation,
MorfoAriaEntry, MorfoAriaEntry,
MorfoArchetype, MorfoArchetype,
MorfoCondition, MorfoCondition,
@ -139,6 +140,24 @@ export interface SourceDeps {
readonly needsTranslations: boolean readonly needsTranslations: boolean
} }
/**
* Compiled, normalized animation/presence structure of a part (RFC:
* eidos/MOTION_SERVICE_RFC.md §4). `surface` is always a boolean; `children.when`
* is defaulted to `'together'`. `undefined` when the part declares no
* `animation`. The soma `PresenceGroup` (phase M2) derives the coordination
* tree from these compiled views — the compiler never coordinates, it only
* normalizes the declared structure.
*/
export interface CompiledPartAnimation {
readonly surface: boolean
readonly children:
| {
readonly enter: 'before' | 'after' | 'together'
readonly exit: 'before' | 'after' | 'together'
}
| undefined
}
/** /**
* Compiled view of a single part. Replaces walking `MorfoPart` at runtime. * Compiled view of a single part. Replaces walking `MorfoPart` at runtime.
* *
@ -170,6 +189,11 @@ export interface CompiledPart {
readonly parentKebab: string | undefined readonly parentKebab: string | undefined
/** Direct child kebabs (one level deep). Empty for leaves. */ /** Direct child kebabs (one level deep). Empty for leaves. */
readonly childKebabs: readonly string[] readonly childKebabs: readonly string[]
/**
* Normalized animation/presence structure (RFC: eidos/MOTION_SERVICE_RFC.md
* §4). `undefined` when this part is not an animable surface.
*/
readonly animation: CompiledPartAnimation | undefined
} }
// ── Action plan ──────────────────────────────────────────────────────────── // ── Action plan ────────────────────────────────────────────────────────────
@ -472,7 +496,8 @@ function walkParts(
needsTranslations needsTranslations
}), }),
parentKebab, parentKebab,
childKebabs: Object.freeze((part.parts ?? []).map((p) => p.kebab)) childKebabs: Object.freeze((part.parts ?? []).map((p) => p.kebab)),
animation: compilePartAnimation(part.animation)
}) })
partsByKebab.set(part.kebab, compiledPart) partsByKebab.set(part.kebab, compiledPart)
@ -624,6 +649,28 @@ function compileEvent(event: MorfoEvent): ActionPlan {
}) })
} }
/**
* Compile a part's declared `animation` into its normalized presence structure
* (RFC: eidos/MOTION_SERVICE_RFC.md §4). STRUCTURE only — no visual values.
* `surface` defaults to `false`; `children.when` defaults to `'together'`.
* Returns `undefined` for parts that declare no `animation` (most parts) so the
* soma runtime can cheaply skip them.
*/
function compilePartAnimation(
animation: MorfoAnimation | undefined
): CompiledPartAnimation | undefined {
if (!animation) return undefined
return Object.freeze({
surface: animation.surface ?? false,
children: animation.children
? Object.freeze({
enter: animation.children.enter ?? 'together',
exit: animation.children.exit ?? 'together'
})
: undefined
})
}
// ── Source dep collection ────────────────────────────────────────────────── // ── Source dep collection ──────────────────────────────────────────────────
interface DepSink { interface DepSink {

@ -0,0 +1,55 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Rail — a horizontal strip of items that CASCADE in/out, parent-controlled via
* `open` (no trigger, no events). The SECOND consumer of the coordinated-motion
* helper (`soma/layers/coordination.ts`), built to prove it is reusable across a
* DIFFERENT shape than `Reveal`:
*
* - the ROOT (`Provider`) IS the owner surface (vs Reveal's virtual root + separate
* Panel owner);
* - the relation is `{ enter: 'together', exit: 'together' }` (all parallel, the
* visual stagger is CSS only) — vs Reveal's `{ before, after }` bracket;
* - NO sema events — the parent owns `open`, the component is pure visual
* coordination. So `scope` is just `['soma']` (no events ⇒ no 'sema' needed) and
* there is no `channels: []`/`expression` to silence (nothing emits).
*
* RFC: eidos/MOTION_SERVICE_RFC.md.
*/
export const railMorfo = {
name: 'Rail',
kebab: 'rail',
scope: ['soma'],
texts: {
label: '#?components.rail.label|Rail'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'list',
optional: false,
// ROOT + owner animable surface. `together` = rail + items enter/exit in
// parallel; the visual stagger is CSS only (data-starting/ending-style).
animation: { surface: true, children: { enter: 'together', exit: 'together' } },
data: [],
aria: []
},
{
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'div',
role: 'listitem',
optional: false,
animation: { surface: true },
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,133 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Reveal — disclosure list (Trigger + Panel + Items). The FIRST real consumer of
* the motion-coordination contract (RFC: eidos/MOTION_SERVICE_RFC.md). The Panel
* is an animable OWNER surface that coordinates its Item CHILD surfaces:
*
* panel.animation.children = { enter: 'before', exit: 'after' }
*
* — the panel appears, THEN the items cascade in; on close the items leave first
* while the panel RETAINS its DOM (exit-heavy, §8.1), then the panel goes. soma
* reads these compiled `animation.children` to build the `PresenceGroup` (it is
* the contract that drives the coordination, not hand-wiring). The visual stagger
* is CSS (the demo / eidos) — soma only sequences WHEN each surface enters/exits.
*
* Disclosure semantics (Trigger aria-expanded/aria-controls, Panel region) mirror
* Collapsible; the only addition is the parent↔children animation coordination.
* `emerge` family (transitional, no intent) for open/close.
*/
export const revealMorfo = {
name: 'Reveal',
kebab: 'reveal',
// 'sema' because the morfo declares events (open/close) — they participate in the
// sema layer even though `channels: []` silences their perceptual surface.
scope: ['soma', 'sema'],
// The reveal's perception IS the coordinated cascade (eidos transitions on the
// Presence's data-starting/ending-style) — NOT a generic sema signature. So the
// open/close events carry `channels: []` (no perceptual surface) and expression is
// 'none': otherwise the generic emerge `present-rise`/`dismiss-fade` signature
// fires on the event and FIGHTS the coordinated motion (a real bug the demo hit).
// Doctrine: a coordinated-motion component silences the generic sema signal.
expression: 'none',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/',
texts: {
label: '#?components.reveal.label|Reveal'
},
events: [
{
name: 'open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('panel'),
// State is set in the soma handler → `post`, so a (future) perceptual
// hold never blocks the functional open (CLAUDE.md sequencing doctrine).
sequence: 'post',
// No perceptual surface — the coordinated cascade IS the feedback.
channels: []
}
},
{
name: 'close',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('panel'),
sequence: 'post',
channels: []
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'virtual',
defaultElement: 'none',
optional: false,
data: [],
aria: []
},
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{
attr: 'aria-controls',
value: v.partRef('panel'),
condition: { when: 'part-present', part: 'panel' },
severity: 'recommended'
}
]
},
{
name: 'Panel',
kebab: 'panel',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
role: 'region',
optional: false,
states: ['open', 'closed'],
// OWNER animable surface — coordinates its Item children. `before` enter
// = panel in, then items cascade; `after` exit = items out first while the
// panel retains its DOM, then the panel (§8.1 exit-heavy). soma derives the
// PresenceGroup `when` from exactly this.
animation: { surface: true, children: { enter: 'before', exit: 'after' } },
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('trigger'),
condition: { when: 'part-present', part: 'trigger' },
severity: 'recommended'
}
]
},
{
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'div',
optional: false,
// CHILD animable surface — the group releases it per the panel's
// `children` relation. No state/aria of its own; it is a coordinated leaf.
animation: { surface: true },
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -25,6 +25,8 @@ export type {
MorfoEventSemantic, MorfoEventSemantic,
MorfoA11ySemantic, MorfoA11ySemantic,
MorfoEvent, MorfoEvent,
MorfoAnimationChildren,
MorfoAnimation,
MorfoPart, MorfoPart,
Morfo Morfo
} from './types'; } from './types';
@ -59,6 +61,7 @@ export {
type ActionPlan, type ActionPlan,
type CompiledMorfo, type CompiledMorfo,
type CompiledPart, type CompiledPart,
type CompiledPartAnimation,
type CssSelectorContract, type CssSelectorContract,
type DataAttrContract, type DataAttrContract,
type KeyboardPlan, type KeyboardPlan,

@ -84,3 +84,37 @@ describe('validateMorfo — data attrs', () => {
).toThrow(/emit is only valid for non-enum data attrs/); ).toThrow(/emit is only valid for non-enum data attrs/);
}); });
}); });
describe('validateMorfo — animation surfaces (RFC: MOTION_SERVICE_RFC.md §4)', () => {
function withAnimation(animation: unknown) {
return {
...baseMorfo,
parts: [{ ...baseMorfo.parts[0], animation }]
};
}
it('accepts a surface with no children', () => {
expect(() => validateMorfo(withAnimation({ surface: true }))).not.toThrow();
});
it('accepts a coordinating surface (surface + children.exit)', () => {
expect(() =>
validateMorfo(withAnimation({ surface: true, children: { exit: 'after' } }))
).not.toThrow();
});
it('rejects children declared on a non-surface part', () => {
expect(() => validateMorfo(withAnimation({ children: { exit: 'after' } }))).toThrow(
MorfoInvariantError
);
expect(() => validateMorfo(withAnimation({ children: { exit: 'after' } }))).toThrow(
/must itself be a surface/
);
});
it('rejects an unknown phase value', () => {
expect(() =>
validateMorfo(withAnimation({ surface: true, children: { exit: 'eventually' } }))
).toThrow();
});
});

@ -77,6 +77,7 @@ const elementSchema = union(
literal('footer'), literal('footer'),
literal('img'), literal('img'),
literal('svg'), literal('svg'),
literal('path'),
literal('label'), literal('label'),
literal('form'), literal('form'),
literal('fieldset'), literal('fieldset'),
@ -315,6 +316,21 @@ const focusSchema = object({
// Sium lacks `lazy()`, so we validate a single part without its `parts?` // Sium lacks `lazy()`, so we validate a single part without its `parts?`
// children, then the walker recurses. // children, then the walker recurses.
// Animation — presence-coordination structure (RFC: eidos/MOTION_SERVICE_RFC.md
// §4). Shape only; the cross-field invariant (children ⇒ surface) lives in
// `validateInvariants`. The `when` enum is validated structurally here.
const animationWhenSchema = union(literal('before'), literal('after'), literal('together'));
const animationChildrenSchema = object({
enter: optional(animationWhenSchema),
exit: optional(animationWhenSchema)
});
const animationSchema = object({
surface: optional(boolean()),
children: optional(animationChildrenSchema)
});
const partShallowSchema = object({ const partShallowSchema = object({
name: string(), name: string(),
kebab: string(), kebab: string(),
@ -328,6 +344,7 @@ const partShallowSchema = object({
data: array(dataSchema), data: array(dataSchema),
aria: array(ariaEntrySchema), aria: array(ariaEntrySchema),
keyboard: optional(array(keyboardSchema)), keyboard: optional(array(keyboardSchema)),
animation: optional(animationSchema),
parts: optional(array(object({}, { unknownKeys: 'passthrough' }))) parts: optional(array(object({}, { unknownKeys: 'passthrough' })))
// ^ children passed through opaquely — shape is checked by the walker // ^ children passed through opaquely — shape is checked by the walker
}); });
@ -572,6 +589,19 @@ function validateInvariants(morfo: Morfo): void {
} }
} }
// Animation (RFC: eidos/MOTION_SERVICE_RFC.md §4/§6). A part that coordinates
// child surfaces is itself a surface, so `animation.children` requires
// `animation.surface: true`. Choreography is opt-in BY DECLARATION (RFC §6):
// only a part that declares `children` awaits/staggers its descendants.
for (const { part, path } of flat) {
if (part.animation?.children && part.animation.surface !== true) {
throw new MorfoInvariantError(
`part "${part.kebab}" declares animation.children but animation.surface is not true — a part that coordinates child surfaces must itself be a surface`,
path
);
}
}
if (morfo.focus) { if (morfo.focus) {
const f = morfo.focus; const f = morfo.focus;
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) { if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {

@ -684,6 +684,64 @@ export const ARCHETYPE_VOCABULARY = [
// ── Part ────────────────────────────────────────────────────────────────── // ── Part ──────────────────────────────────────────────────────────────────
// ── Animation — presence-coordination structure ─────────────────────────────
//
// The morfo half of the motion service (RFC: eidos/MOTION_SERVICE_RFC.md §4,
// phase M1). STRUCTURE only — no visual/expressive values: stagger-ms, easing
// and keyframes live in eidos; the runtime coordination (PresenceGroup) lives
// in soma. Justified in the contract by the 2-of-3 rule (soma lifecycle + eidos
// paints both consume it).
/**
* How a parent surface coordinates the presence lifecycle of its child
* surfaces. This lives in the contract (not in eidos) because it has a
* LIFECYCLE consequence the runtime must honor — see `when`.
*/
export interface MorfoAnimationChildren {
/**
* Parent↔child relation on ENTER. `'before'` = the parent enters, then its
* children; `'after'` = children enter, then the parent; `'together'` =
* parallel (default).
*/
enter?: 'before' | 'after' | 'together';
/**
* Parent↔child relation on EXIT. `'after'` = children leave first while the
* parent RETAINS its DOM, then the parent leaves (RFC §8.1); `'before'` = the
* parent leaves first; `'together'` = parallel (default).
*
* Enter and exit are SEPARATE because the natural container BRACKETS its
* children — it appears BEFORE them and leaves AFTER them. A single symmetric
* relation can't express that; `{ enter: 'before', exit: 'after' }` can.
*/
exit?: 'before' | 'after' | 'together';
}
/**
* Declares a part as an animable surface and (optionally) how it coordinates
* the presence lifecycle of its child surfaces.
*
* STRUCTURE only — this is the cross-layer contract, not the look. Which
* animation plays, the stagger duration, easing and keyframes are EIDOS's
* (visual/expressive); the lifecycle coordination is SOMA's (PresenceGroup,
* phase M2). Morfo only declares *which parts are surfaces* and the
* parent↔child dependency.
*
* Backward-compatible: optional. A part that doesn't declare `animation`
* animates exactly as today (its `Presence` an island). `children` is opt-in
* coordination (RFC §6): a parent only awaits/staggers its children when it
* declares `children`.
*/
export interface MorfoAnimation {
/** This part is an animable surface (has a coordinable presence lifecycle). */
surface?: boolean;
/**
* Coordination with this part's child surfaces. Declaring it OPTS the part
* into parent↔child choreography. Requires `surface: true` (a part that
* coordinates children is itself a surface) — enforced by `validateMorfo`.
*/
children?: MorfoAnimationChildren;
}
/** /**
* A single part of a component. Parts compose into a tree. * A single part of a component. Parts compose into a tree.
* *
@ -751,6 +809,13 @@ export interface MorfoPart {
keyboard?: readonly MorfoKeyboard[]; keyboard?: readonly MorfoKeyboard[];
/** Nested parts (e.g. `Accordion.Item` contains `Header`, `Trigger`, `Content`). */ /** Nested parts (e.g. `Accordion.Item` contains `Header`, `Trigger`, `Content`). */
parts?: readonly MorfoPart[]; parts?: readonly MorfoPart[];
/**
* Animation / presence-coordination structure (RFC: eidos/MOTION_SERVICE_RFC.md
* §4, phase M1). Marks this part as an animable surface and declares how it
* coordinates its child surfaces' lifecycle. STRUCTURE only — no visual values.
* Optional; absence = not an animable surface (today's behavior).
*/
animation?: MorfoAnimation;
} }
// ── Sema expression mode ───────────────────────────────────────────────── // ── Sema expression mode ─────────────────────────────────────────────────

@ -0,0 +1,31 @@
<script lang="ts">
import { writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { RailItemProvider } from '../rail-provider.svelte';
import type { RailItemProps } from '../types';
let { ref = $bindable(null), children, ...restProps }: RailItemProps = $props();
// CHILD surface — same shape as Reveal's item: `data-rail-item` marker, a child
// Presence (via the shared Coordination), and the auto `--motion-stagger-index`.
const state = RailItemProvider.create({
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, { 'data-rail-item': '' }));
</script>
{#if state.isPresent}
<div
bind:this={ref}
{...mergedProps}
{...state.transitionAttrs}
data-animation-style={state.animationStyle}
style:--motion-stagger-index={state.staggerIndex}
>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,57 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RailProvider } from '../rail-provider.svelte';
import type { RailProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'rail'),
open = $bindable(false),
onOpenChange = () => {},
animation,
children,
child,
...restProps
}: RailProps = $props();
// Root AND owner surface: the rail element coordinates its Item children. The ref
// is attached by the runtime part (in `state.props`); `state.isPresent` /
// `transitionAttrs` come from the owner Presence.
const state = RailProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
open: writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
),
animation: readableActive(() => animation)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent}
{#if child}
{@render child({
props: {
...mergedProps,
...state.transitionAttrs,
'data-animation-style': state.animationStyle
}
})}
{:else}
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
{@render children?.()}
</div>
{/if}
{/if}

@ -0,0 +1,4 @@
export { default as Provider } from './components/rail.svelte';
export { default as Item } from './components/rail-item.svelte';
export type { RailProps as ProviderProps, RailItemProps as ItemProps } from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import { compileMorfo, type Morfo } from '$uix/morfo';
import { validateMorfo } from '$uix/morfo/schema';
import { railMorfo } from '$uix/morfo/components/rail';
import { createEngineMotion } from '$motion';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { PresenceGroup } from '$soma/layers/presence-group';
import { RailItemProvider, RailProvider } from './rail-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const motion = createEngineMotion();
const soma = {
dom,
motion,
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(RailProvider.ctx, 'set').mockImplementation((value) => value as never);
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
return { dom };
}
describe('Rail — second consumer of the coordination helper (RFC: MOTION_SERVICE_RFC.md §7)', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('the morfo is valid: the root Provider is the owner surface, Item is a child', () => {
expect(() => validateMorfo(railMorfo)).not.toThrow();
const compiled = compileMorfo(railMorfo);
// Provider (root) declares `children` → it is the owner; `together` relation.
expect(compiled.parts.byKebab.get('provider')?.animation?.children).toEqual({
enter: 'together',
exit: 'together'
});
expect(compiled.parts.byKebab.get('item')?.animation).toEqual({
surface: true,
children: undefined
});
});
it('the root registers as owner; items as children; stagger + routing via the shared helper', () => {
installSomaHarness();
const railEl = document.createElement('div');
const itemEls = [
document.createElement('div'),
document.createElement('div'),
document.createElement('div')
];
const { result, cleanup } = withEffectRoot(() => {
const provider = RailProvider.create({
id: state('rail'),
ref: state<HTMLElement | null>(railEl),
open: state(true),
animation: state<string | undefined>('cascade-slide')
});
vi.spyOn(RailProvider, 'require').mockReturnValue(provider);
const items = itemEls.map((el) =>
RailItemProvider.create({ ref: state<HTMLElement | null>(el) })
);
return { provider, items };
});
// The root IS the owner → owner + 3 children = 4 members in ONE group.
expect(result.provider.coord.group.size).toBe(4);
// Items auto-derive their stagger (owner excluded from the child index).
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
// Routing reaches both declared surfaces.
expect(result.provider.coord.routeAnimation('provider')).toBe('cascade-slide');
expect(result.provider.coord.routeAnimation('item')).toBe('cascade-slide');
cleanup();
});
});

@ -0,0 +1,121 @@
import { context } from '../../provider';
import { type Active, type State, type StateProps } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
import { railMorfo } from '../../../morfo/components/rail';
// ── Provider (root + owner surface) ─────────────────────────────────────────
interface RailOpts extends StateProps<{ open: boolean }> {
id: Active<string>;
ref: State<HTMLElement | null>;
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
animation: Active<string | undefined>;
}
/**
* Rail root — unlike Reveal's virtual root, the Provider IS the owner surface: it
* holds the runtime, the open state, the `Coordination`, AND registers itself as the
* group's `owner` (the rail element coordinates its Item children). Parent-controlled
* via `open`; no trigger, no events.
*/
export class RailProvider {
static readonly ctx = context<RailProvider>('Rail');
static get(): RailProvider | undefined {
return this.ctx.getOr(undefined) as RailProvider | undefined;
}
static require(): RailProvider {
return this.ctx.get();
}
static create(opts: RailOpts) {
return new RailProvider(opts);
}
readonly opts: RailOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
readonly coord: Coordination;
/** OWNER member — the rail element; its presence triggers the coordinated enter/exit. */
readonly surface: CoordinatedSurface;
private constructor(opts: RailOpts) {
this.opts = opts;
RailProvider.ctx.set(this);
this.soma = Soma.require();
this.runtime = this.soma.runtime(railMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: RailProvider.ctx,
syncAttrs: true
});
this.coord = new Coordination({
morfo: railMorfo,
dom: this.soma.dom,
motion: this.soma.motion,
open: opts.open,
animation: opts.animation
});
this.surface = this.coord.surface('provider', opts.ref, 'owner');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Item (child surface) ──────────────────────────────────────────────────────
interface RailItemOpts {
ref: State<HTMLElement | null>;
}
export class RailItemProvider {
static create(opts: RailItemOpts) {
return new RailItemProvider(opts);
}
readonly opts: RailItemOpts;
readonly provider: RailProvider;
/** CHILD member — mounts and waits for the group to release it (cascade). */
readonly surface: CoordinatedSurface;
private constructor(opts: RailItemOpts) {
this.opts = opts;
this.provider = RailProvider.require();
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
get staggerIndex(): number {
return this.surface.staggerIndex;
}
}

@ -0,0 +1,23 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
export type RailProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Whether the rail is open. Bindable. @default false */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Motion preset name, routed to the declared surfaces (RFC §5). */
animation?: string;
}> &
Without<PrimitiveDivAttributes, { open: boolean }>;
export type RailItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
/** Element reference. Bindable — the child Presence animates this node. */
ref?: HTMLElement | null;
/** Inline style (e.g. `--motion-stagger-index`, set automatically by the item). */
style?: string | Record<string, unknown> | null;
children?: Snippet;
};

@ -0,0 +1,32 @@
<script lang="ts">
import { writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { RevealItemProvider } from '../reveal-provider.svelte';
import type { RevealItemProps } from '../types';
let { ref = $bindable(null), children, ...restProps }: RevealItemProps = $props();
// CHILD surface. No runtime part — it is a coordinated leaf; `data-reveal-item`
// (the morfo's part marker) + the child Presence are all it needs. `bind:this`
// feeds the ref the Presence animates; mergeProps normalises `style` for the div.
const state = RevealItemProvider.create({
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, { 'data-reveal-item': '' }));
</script>
{#if state.isPresent}
<div
bind:this={ref}
{...mergedProps}
{...state.transitionAttrs}
data-animation-style={state.animationStyle}
style:--motion-stagger-index={state.staggerIndex}
>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RevealPanelProvider } from '../reveal-provider.svelte';
import type { RevealPanelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'reveal-panel'),
children,
...restProps
}: RevealPanelProps = $props();
// OWNER surface. The ref is attached by the runtime part (in `state.props`);
// `state.isPresent` / `state.transitionAttrs` come from the owner Presence, so
// the panel mounts on open and RETAINS its DOM through the children's exit.
const state = RevealPanelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent}
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RevealTriggerProvider } from '../reveal-provider.svelte';
import type { RevealTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'reveal-trigger'),
children,
child,
...restProps
}: RevealTriggerProps = $props();
const state = RevealTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,31 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { createId } from '$active-uix/id';
import { RevealProvider } from '../reveal-provider.svelte';
import type { RevealProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'reveal'),
open = $bindable(false),
onOpenChange = () => {},
animation,
children
}: RevealProps = $props();
// Virtual root — owns the runtime + the coordination group, renders no element.
RevealProvider.create({
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
),
animation: readableActive(() => animation)
});
</script>
{@render children?.()}

@ -0,0 +1,11 @@
export { default as Provider } from './components/reveal.svelte';
export { default as Trigger } from './components/reveal-trigger.svelte';
export { default as Panel } from './components/reveal-panel.svelte';
export { default as Item } from './components/reveal-item.svelte';
export type {
RevealProps as ProviderProps,
RevealTriggerProps as TriggerProps,
RevealPanelProps as PanelProps,
RevealItemProps as ItemProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import { compileMorfo, type Morfo } from '$uix/morfo';
import { validateMorfo } from '$uix/morfo/schema';
import { revealMorfo } from '$uix/morfo/components/reveal';
import { createEngineMotion } from '$motion';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { PresenceGroup } from '$soma/layers/presence-group';
import { RevealItemProvider, RevealPanelProvider, RevealProvider } from './reveal-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
async function flushRuntimeTrigger() {
await Promise.resolve();
await tick();
}
function installSomaHarness() {
const dom = createActiveDom();
const motion = createEngineMotion();
const soma = {
dom,
motion,
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
// No component context in a bare effect root — stub the context publication
// (both the provider's own ctx and the group's, set by PresenceGroup.create).
vi.spyOn(RevealProvider.ctx, 'set').mockImplementation((value) => value as never);
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
return { dom };
}
describe('Reveal — contract → coordination wiring (RFC: MOTION_SERVICE_RFC.md §4)', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('the morfo is valid and the Panel declares the bracket coordination', () => {
expect(() => validateMorfo(revealMorfo)).not.toThrow();
const panel = compileMorfo(revealMorfo).parts.byKebab.get('panel');
expect(panel?.animation?.surface).toBe(true);
// `{ enter: 'before', exit: 'after' }` — what the provider feeds the group.
expect(panel?.animation?.children).toEqual({ enter: 'before', exit: 'after' });
// The Item is a coordinated child surface with no relation of its own.
const item = compileMorfo(revealMorfo).parts.byKebab.get('item');
expect(item?.animation).toEqual({ surface: true, children: undefined });
});
it('the provider builds a PresenceGroup that the panel (owner) + items (children) register into', () => {
installSomaHarness();
const panelEl = document.createElement('div');
const itemEls = [document.createElement('div'), document.createElement('div')];
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open: state(false),
animation: state<string | undefined>(undefined)
});
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
const panel = RevealPanelProvider.create({
id: state('reveal-panel'),
ref: state<HTMLElement | null>(panelEl)
});
const items = itemEls.map((el) =>
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
);
return { provider, panel, items };
});
expect(result.provider.group).toBeInstanceOf(PresenceGroup);
// owner + 2 children all registered as group members.
expect(result.provider.group.size).toBe(3);
cleanup();
});
it('routes the `animation` DX prop ONLY to the declared surfaces (RFC §5)', () => {
installSomaHarness();
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open: state(false),
animation: state<string | undefined>('slide')
});
return { provider };
});
// Panel + Item are `surface: true` in the morfo → routed; Trigger is not.
// Routing now lives on the shared `Coordination` helper.
expect(result.provider.coord.routeAnimation('panel')).toBe('slide');
expect(result.provider.coord.routeAnimation('item')).toBe('slide');
expect(result.provider.coord.routeAnimation('trigger')).toBeUndefined();
cleanup();
});
it('items auto-derive their stagger index from the group order (M6)', () => {
installSomaHarness();
const panelEl = document.createElement('div');
const itemEls = [
document.createElement('div'),
document.createElement('div'),
document.createElement('div')
];
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open: state(false),
animation: state<string | undefined>(undefined)
});
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
RevealPanelProvider.create({
id: state('reveal-panel'),
ref: state<HTMLElement | null>(panelEl)
});
const items = itemEls.map((el) =>
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
);
return { items };
});
// The dev numbers nothing — the cascade offsets itself from the registration order.
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
cleanup();
});
it('toggle flips the open state through the open/close events', async () => {
installSomaHarness();
const open = state(false);
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open,
animation: state<string | undefined>(undefined)
});
return { provider };
});
result.provider.toggle();
await flushRuntimeTrigger();
expect(open.current).toBe(true);
result.provider.toggle();
await flushRuntimeTrigger();
expect(open.current).toBe(false);
cleanup();
});
});

@ -0,0 +1,220 @@
import { context } from '../../provider';
import { state, type Active, type State, type StateProps } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { PresenceGroup } from '../../layers/presence-group';
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
import { revealMorfo } from '../../../morfo/components/reveal';
// ── Provider (virtual root) ────────────────────────────────────────────────
interface RevealOpts extends StateProps<{ open: boolean }> {
id: Active<string>;
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
animation: Active<string | undefined>;
}
/**
* Reveal root — owns the morfo runtime, the open state, and the `Coordination`
* (the shared coordinated-motion wiring: PresenceGroup + animation routing + auto
* stagger, all derived from the morfo's `animation` contract). The Panel registers
* as the `owner` surface, each Item as a `child` — see `soma/layers/coordination.ts`.
*/
export class RevealProvider {
static readonly ctx = context<RevealProvider>('Reveal');
static get(): RevealProvider | undefined {
return this.ctx.getOr(undefined) as RevealProvider | undefined;
}
static require(): RevealProvider {
return this.ctx.get();
}
static create(opts: RevealOpts) {
return new RevealProvider(opts);
}
readonly opts: RevealOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
/** Shared coordination wiring, built from the morfo contract (RFC §7 / M5 / M6). */
readonly coord: Coordination;
// Cross-part id sources read by partRef('trigger') / partRef('panel') for the
// Trigger's aria-controls and the Panel's aria-labelledby.
triggerId = state('');
panelId = state('');
private constructor(opts: RevealOpts) {
this.opts = opts;
RevealProvider.ctx.set(this);
this.soma = Soma.require();
this.runtime = this.soma.runtime(revealMorfo, {
states: {
open: () => opts.open.current
},
parts: {
trigger: () => this.triggerId.current,
panel: () => this.panelId.current
},
events: {
// State is set in the HANDLER, so both are `sequence: 'post'` in the
// morfo (the perceptual hold must not block the functional change).
open: () => {
this.opts.open.current = true;
},
close: () => {
this.opts.open.current = false;
}
}
});
this.coord = new Coordination({
morfo: revealMorfo,
dom: this.soma.dom,
motion: this.soma.motion,
open: opts.open,
animation: opts.animation
});
}
/** The coordination group (Panel owner + Item children). */
get group(): PresenceGroup {
return this.coord.group;
}
toggle() {
void this.runtime.trigger(this.opts.open.current ? 'close' : 'open');
}
}
// ── Trigger ─────────────────────────────────────────────────────────────────
interface RevealTriggerOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class RevealTriggerProvider {
static create(opts: RevealTriggerOpts) {
return new RevealTriggerProvider(opts);
}
readonly opts: RevealTriggerOpts;
readonly provider: RevealProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: RevealTriggerOpts) {
this.opts = opts;
this.provider = RevealProvider.require();
this.provider.triggerId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly onclick = () => {
this.provider.toggle();
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
onclick: this.onclick
}));
}
// ── Panel (owner surface) ─────────────────────────────────────────────────────
interface RevealPanelOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class RevealPanelProvider {
static create(opts: RevealPanelOpts) {
return new RevealPanelProvider(opts);
}
readonly opts: RevealPanelOpts;
readonly provider: RevealProvider;
readonly runtimePart: SomaRuntimePart;
/** OWNER member of the group — its presence triggers the coordinated enter/exit. */
readonly surface: CoordinatedSurface;
private constructor(opts: RevealPanelOpts) {
this.opts = opts;
this.provider = RevealProvider.require();
this.provider.panelId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('panel', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
this.surface = this.provider.coord.surface('panel', opts.ref, 'owner');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
/** The routed `data-animation-style` for this surface (RFC §5). */
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Item (child surface) ──────────────────────────────────────────────────────
interface RevealItemOpts {
ref: State<HTMLElement | null>;
}
export class RevealItemProvider {
static create(opts: RevealItemOpts) {
return new RevealItemProvider(opts);
}
readonly opts: RevealItemOpts;
readonly provider: RevealProvider;
/** CHILD member — mounts and waits for the group to release it (cascade). */
readonly surface: CoordinatedSurface;
private constructor(opts: RevealItemOpts) {
this.opts = opts;
this.provider = RevealProvider.require();
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
/** The routed `data-animation-style` for this surface (RFC §5). */
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
/** The auto `--motion-stagger-index`, derived from the group order (M6). */
get staggerIndex(): number {
return this.surface.staggerIndex;
}
}

@ -0,0 +1,39 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
export type RevealProps = {
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Whether the panel is open. Bindable. @default false */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/**
* Motion preset name, routed to the declared animable surfaces (Panel + Items)
* as `data-animation-style` — name it once here, the system applies it where the
* morfo says (RFC §5). `undefined` = no preset.
*/
animation?: string;
children?: Snippet;
};
export type RevealTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, Record<never, never>>;
export type RevealPanelProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, Record<never, never>>;
export type RevealItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
/** Element reference. Bindable — the child Presence animates this node. */
ref?: HTMLElement | null;
/** Inline style (e.g. `--i` for the CSS stagger index). */
style?: string | Record<string, unknown> | null;
children?: Snippet;
};

@ -0,0 +1,137 @@
/**
* Coordination — the shared soma wiring for a COORDINATED-motion component
* (RFC: eidos/MOTION_SERVICE_RFC.md §7 + the M5/M6 increments). It reads a
* component's morfo `animation` contract and does, ONCE, what every coordinated
* component needs:
*
* - derives the parent↔children `when` (from the owner part's `animation.children`)
* and the set of `surface: true` parts — the CONTRACT drives the coordination;
* - creates the `PresenceGroup`;
* - routes the DX `animation` prop to the declared surfaces (`routeAnimation`);
* - mints per-surface members (`CoordinatedSurface`) — an owner/child `Presence`
* that exposes the routed `data-animation-style` and the auto stagger index.
*
* A coordinated component's ROOT provider creates ONE `Coordination`; each animable
* part provider creates a `CoordinatedSurface` via `coord.surface(...)`. This is
* what makes a second coordinated component a handful of lines instead of the full
* hand-wiring `Reveal` first did — both `Reveal` and `Rail` consume it.
*
* The opt-OUT doctrine (silence the generic sema signature, react to
* `data-starting/ending-style` not `data-state`) lives in the morfo (`channels: []`,
* `expression: 'none'`) + the component's CSS — NOT here. This helper only owns the
* lifecycle coordination.
*/
import { compileMorfo, type Morfo } from '$uix/morfo';
import { readableActive, type Active } from '$libs/reactive';
import type { ActiveDom } from '$adom';
import type { EngineMotion } from '$motion';
import { Presence } from './presence.svelte';
import { PresenceGroup, type PresenceRole, type PresenceWhen } from './presence-group';
interface CoordinationStructure {
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
readonly surfaces: ReadonlySet<string>;
}
// Cached by morfo identity (compileMorfo is itself cached) — the structure is a
// pure function of the contract.
const STRUCTURE_CACHE = new WeakMap<Morfo, CoordinationStructure>();
function structureOf(morfo: Morfo): CoordinationStructure {
const cached = STRUCTURE_CACHE.get(morfo);
if (cached) return cached;
let when: { enter: PresenceWhen; exit: PresenceWhen } = { enter: 'together', exit: 'together' };
const surfaces = new Set<string>();
for (const [kebab, part] of compileMorfo(morfo).parts.byKebab) {
if (part.animation?.surface) surfaces.add(kebab);
// The owner is the part that declares `children` — its `when` is the tree's.
if (part.animation?.children) when = part.animation.children;
}
const structure: CoordinationStructure = { when, surfaces };
STRUCTURE_CACHE.set(morfo, structure);
return structure;
}
export interface CoordinationOptions {
readonly morfo: Morfo;
readonly dom: ActiveDom;
readonly motion: EngineMotion;
/** Shared open state — every surface (owner + children) observes the same flag. */
readonly open: Active<boolean>;
/** DX preset name routed to the declared surfaces. Omit for none. */
readonly animation?: Active<string | undefined>;
}
export class Coordination {
readonly group: PresenceGroup;
private readonly surfaces: ReadonlySet<string>;
private readonly opts: CoordinationOptions;
private readonly animation: Active<string | undefined>;
constructor(opts: CoordinationOptions) {
this.opts = opts;
const structure = structureOf(opts.morfo);
this.surfaces = structure.surfaces;
this.animation = opts.animation ?? readableActive((): string | undefined => undefined);
this.group = PresenceGroup.create({ when: structure.when, dom: opts.dom });
}
/**
* Route the DX `animation` to a part ONLY if the morfo declares it a surface
* (RFC §5). A non-surface part gets `undefined`, so it can never be mis-targeted.
*/
routeAnimation(kebab: string): string | undefined {
return this.surfaces.has(kebab) ? this.animation.current : undefined;
}
/** Mint a coordinated member (an animable surface) for a part. */
surface(kebab: string, ref: Active<HTMLElement | null>, role: PresenceRole): CoordinatedSurface {
const presence = new Presence({
dom: this.opts.dom,
motion: this.opts.motion,
open: this.opts.open,
ref,
group: this.group,
groupRole: role
});
return new CoordinatedSurface(this, presence, kebab);
}
}
/**
* A single animable surface of a `Coordination`: its registered `Presence`, the
* routed `data-animation-style`, and the auto `--motion-stagger-index`. The part
* provider exposes these to its svelte wrapper.
*/
export class CoordinatedSurface {
constructor(
private readonly coord: Coordination,
readonly presence: Presence,
private readonly kebab: string
) {}
get isPresent(): boolean {
return this.presence.isPresent;
}
get transitionAttrs() {
return this.presence.transitionAttrs;
}
/** The routed `data-animation-style` for this surface (RFC §5). */
get animationStyle(): string | undefined {
return this.coord.routeAnimation(this.kebab);
}
/**
* The canonical `--motion-stagger-index`, derived from this surface's position in
* the group's registration order (M6) — the cascade offsets itself; the dev never
* numbers the items. Owner surfaces are not children, so this is 0 for them.
*/
get staggerIndex(): number {
return this.coord.group.childIndex(this.presence);
}
}

@ -1,5 +1,15 @@
// ── Behavior layers (classes — consumed by Providers) ──────────────────────── // ── Behavior layers (classes — consumed by Providers) ────────────────────────
export { Presence, type PresenceOptions, type TransitionStatus } from './presence.svelte'; export { Presence, type PresenceOptions, type TransitionStatus } from './presence.svelte';
export {
PresenceGroup,
type PresenceGroupOptions,
type PresenceFrameScheduler,
type PresenceMember,
type PresenceRole,
type PresenceWhen,
type PresencePhase
} from './presence-group';
export { Coordination, CoordinatedSurface, type CoordinationOptions } from './coordination';
export { FocusScope } from './focus-scope.svelte'; export { FocusScope } from './focus-scope.svelte';
export { Dismissal, type DismissalOpts, type DismissalBehavior } from './dismissal.svelte'; export { Dismissal, type DismissalOpts, type DismissalBehavior } from './dismissal.svelte';
export { TextSelection, type TextSelectionOpts } from './text-selection.svelte'; export { TextSelection, type TextSelectionOpts } from './text-selection.svelte';

@ -0,0 +1,326 @@
import { describe, expect, it } from 'vitest';
import { PresenceGroup, type PresenceMember, type PresencePhase } from './presence-group';
// Frame scheduler stub — runs the callback synchronously so the registration
// window collapses to "now" in tests. The coordination core (playEnter) does
// not touch the dom; this only feeds `requestEnter`.
const syncDom = { requestFrame: (cb: () => void) => (cb(), 0) };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
/** Records release/cancel calls; an optional gate controls when `release` resolves. */
function member(
role: 'owner' | 'child',
id: string,
calls: string[],
gate?: { promise: Promise<void> }
): PresenceMember {
return {
role,
release(phase: PresencePhase) {
calls.push(`${id}:${phase}`);
return gate ? gate.promise : Promise.resolve();
},
unmount() {
calls.push(`${id}:unmount`);
},
cancel() {
calls.push(`${id}:cancel`);
}
};
}
// Frame scheduler that QUEUES callbacks (unlike syncDom which runs them inline),
// so a test can interleave requestEnter/requestExit BEFORE the scheduled play runs
// — the timing the interruption guard (RFC §8.3) actually defends against.
function manualDom() {
const cbs: Array<() => void> = [];
return {
dom: {
requestFrame: (cb: () => void) => {
cbs.push(cb);
return cbs.length;
}
},
flush() {
for (const cb of cbs.splice(0)) cb();
}
};
}
describe('PresenceGroup — enter coordination (RFC: MOTION_SERVICE_RFC.md §7)', () => {
it('together: releases every member in parallel', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls));
group.register(member('child', 'b', calls));
await group.playEnter();
expect([...calls].sort()).toEqual(['a:enter', 'b:enter', 'owner:enter']);
});
it('before: owner releases, children only after the owner finishes', async () => {
const calls: string[] = [];
const ownerGate = deferred();
const group = new PresenceGroup({ when: { enter: 'before', exit: 'together' }, dom: syncDom });
group.register(member('owner', 'owner', calls, ownerGate));
group.register(member('child', 'a', calls));
const done = group.playEnter();
await Promise.resolve();
// Owner released; the child must wait for the owner's finished.
expect(calls).toEqual(['owner:enter']);
ownerGate.resolve();
await done;
expect(calls).toEqual(['owner:enter', 'a:enter']);
});
it('after: children release, owner only after they finish', async () => {
const calls: string[] = [];
const childGate = deferred();
const group = new PresenceGroup({ when: { enter: 'after', exit: 'together' }, dom: syncDom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls, childGate));
const done = group.playEnter();
await Promise.resolve();
// Child released; the owner must wait for the children's finished.
expect(calls).toEqual(['a:enter']);
childGate.resolve();
await done;
expect(calls).toEqual(['a:enter', 'owner:enter']);
});
it('playEnter resolves only after every member finishes (aggregation §9)', async () => {
const calls: string[] = [];
const gate = deferred();
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls, gate));
group.register(member('child', 'a', calls, gate));
let settled = false;
const done = group.playEnter().then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
gate.resolve();
await done;
expect(settled).toBe(true);
});
it('register returns a deregister that removes the member', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
const off = group.register(member('child', 'a', calls));
expect(group.size).toBe(1);
off();
expect(group.size).toBe(0);
await group.playEnter();
expect(calls).toEqual([]);
});
it('childIndex returns each child position in registration order (M6)', () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
const owner = member('owner', 'owner', calls);
const a = member('child', 'a', calls);
const b = member('child', 'b', calls);
const c = member('child', 'c', calls);
group.register(owner);
group.register(a);
group.register(b);
group.register(c);
// Owner is skipped; children index from 0 in document (registration) order.
expect(group.childIndex(a)).toBe(0);
expect(group.childIndex(b)).toBe(1);
expect(group.childIndex(c)).toBe(2);
});
it('cancel propagates to every member (RFC §8.3)', () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls));
group.cancel();
expect([...calls].sort()).toEqual(['a:cancel', 'owner:cancel']);
});
it('requestEnter coordinates after the registration-window frame', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.requestEnter();
// syncDom runs the frame synchronously; a macrotask drains the microtask
// queue so the triggered playEnter has fully settled.
await new Promise((r) => setTimeout(r, 0));
expect(calls).toEqual(['owner:enter']);
});
});
describe('PresenceGroup — exit coordination + DOM retention (RFC: MOTION_SERVICE_RFC.md §8.1)', () => {
it('after: children exit first while the owner is retained, then owner, then unmount', async () => {
const calls: string[] = [];
const childGate = deferred();
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: syncDom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls, childGate));
const done = group.playExit();
await Promise.resolve();
// Child is exiting; the owner is RETAINED — it has neither exited nor
// unmounted (no owner:exit, no unmount yet).
expect(calls).toEqual(['a:exit']);
childGate.resolve();
await done;
// Owner exits only after the children, then the whole tree unmounts.
expect(calls).toEqual(['a:exit', 'owner:exit', 'owner:unmount', 'a:unmount']);
});
it('before: owner exits first, then children, then unmount', async () => {
const calls: string[] = [];
const group = new PresenceGroup({ when: { enter: 'together', exit: 'before' }, dom: syncDom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls));
await group.playExit();
expect(calls).toEqual(['owner:exit', 'a:exit', 'owner:unmount', 'a:unmount']);
});
it('together: all exit in parallel; nobody unmounts before the exit aggregates', async () => {
const calls: string[] = [];
const gate = deferred();
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls, gate));
group.register(member('child', 'a', calls, gate));
const done = group.playExit();
await Promise.resolve();
// Both released; nobody unmounts until the exit aggregates (retention).
expect([...calls].sort()).toEqual(['a:exit', 'owner:exit']);
gate.resolve();
await done;
// Unmounts come strictly after each surface's own exit.
expect(calls.indexOf('owner:unmount')).toBeGreaterThan(calls.indexOf('owner:exit'));
expect(calls.indexOf('a:unmount')).toBeGreaterThan(calls.indexOf('a:exit'));
});
it('requestExit coordinates the exit after the next frame', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.requestExit();
// Drain the microtask queue (macrotask) so playExit + the unmount sweep
// have fully settled, not just the first await.
await new Promise((r) => setTimeout(r, 0));
expect(calls).toEqual(['owner:exit', 'owner:unmount']);
});
});
describe('PresenceGroup — interruption / reversa (RFC: MOTION_SERVICE_RFC.md §8.3)', () => {
it('re-open mid-exit cancels the exit and does NOT tear the tree down', async () => {
const calls: string[] = [];
const childGate = deferred();
const md = manualDom();
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: md.dom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls, childGate));
// Start the exit; the child's release is gated → playExit is mid-flight,
// the owner is retained.
group.requestExit();
md.flush();
await Promise.resolve();
expect(calls).toEqual(['a:exit']);
// Re-open mid-exit: a reversal cancels the in-flight members and supersedes
// the exit's generation.
group.requestEnter();
expect(calls).toEqual(['a:exit', 'owner:cancel', 'a:cancel']);
// Resolve the gated child exit so the superseded playExit resumes — it must
// bail at its interruption guard instead of running the unmount loop.
childGate.resolve();
md.flush();
await new Promise((r) => setTimeout(r, 0));
expect(calls).not.toContain('owner:unmount');
expect(calls).not.toContain('a:unmount');
// The reversing enter ran instead.
expect(calls).toContain('owner:enter');
expect(calls).toContain('a:enter');
});
it('close mid-enter cancels the enter and proceeds to the exit (final intent wins)', async () => {
const calls: string[] = [];
const gate = deferred();
const md = manualDom();
const group = new PresenceGroup({ when: { enter: 'together', exit: 'together' }, dom: md.dom });
group.register(member('owner', 'owner', calls, gate));
group.requestEnter();
md.flush();
await Promise.resolve();
expect(calls).toEqual(['owner:enter']);
// Close mid-enter: reversal cancels the enter, supersedes with an exit.
group.requestExit();
expect(calls).toEqual(['owner:enter', 'owner:cancel']);
// Resolve the gate; the superseded enter bails, the exit runs and tears down
// (the user's final intent is closed).
gate.resolve();
md.flush();
await new Promise((r) => setTimeout(r, 0));
expect(calls).toContain('owner:exit');
expect(calls).toContain('owner:unmount');
});
});

@ -0,0 +1,294 @@
/**
* PresenceGroup — coordinates the presence LIFECYCLE of a tree of animable
* surfaces (RFC: eidos/MOTION_SERVICE_RFC.md §7). This is the soma half of the
* motion service: pure state/lifecycle, NEVER visual. `Presence` manages one
* surface; `PresenceGroup` generalizes it to a parent surface + its child
* surfaces — sequencing WHEN each starts (per the morfo's
* `animation.children.{enter,exit}`) and aggregating their `finished`. The visual
* stagger/easing stays in eidos (CSS), the surface motion in `arts/motion`; the
* group only owns the timing.
*
* Discovery is by Svelte context, NOT DOM proximity (RFC §7.1): the parent
* surface's provider calls `PresenceGroup.create(...)`; child surfaces find it
* with `PresenceGroup.get()` and register. Non-animable wrappers in between are
* transparent — they neither create a group nor register, so the context flows
* through them.
*
* No runes here on purpose: the coordination core is plain async + a plain
* member set, so it is unit-testable without a component/DOM. The reactive
* trigger (open → playEnter) lives in the owner `Presence` and the provider —
* which is why this is a plain `.ts`, not a `.svelte.ts`.
*
* Scope — phases M2 + M3 + M4: ENTER coordination (M2) + EXIT with DOM retention
* (M3, §8.1) + interruption/reversa (M4, §8.3: a generation token supersedes a
* stale phase so a reversing enter never lets the exit tear down a surface it
* wants to keep — no unmount→remount flash) + registration + context +
* degradation (no group ⇒ each `Presence` is an island, exactly as before).
* Count-based registration close-out (§8.2) is still deferred to the first
* consumer that needs a fixed-cardinality barrier.
*/
import { context } from '../provider/context';
/** Parent↔child lifecycle relation, compiled from the morfo (RFC §4). */
export type PresenceWhen = 'before' | 'after' | 'together';
/** A member's place in the group: the coordinating surface vs a coordinated child. */
export type PresenceRole = 'owner' | 'child';
export type PresencePhase = 'enter' | 'exit';
/**
* A surface the group coordinates. The leaf is a `Presence`; a nested
* `PresenceGroup` is also a member (a black box — RFC §9). The member owns the
* "what/how" (its own motion); the group owns the "when".
*/
export interface PresenceMember {
/** Coordinating surface (`owner`) or coordinated child (`child`). */
readonly role: PresenceRole;
/**
* Start this surface's motion for the phase and resolve when ITS OWN
* animation finishes. The group never reads visual values — it only awaits.
*/
release(phase: PresencePhase): Promise<void>;
/**
* Tear down after the group's coordinated exit settles (RFC §8.1). The group
* calls this once the whole tree's exit has aggregated — never before, so a
* retained owner survives its children's exit.
*/
unmount(): void;
/** Cancel any in-flight motion on this surface (interruption — RFC §8.3, M4). */
cancel(): void;
}
/**
* Minimal frame scheduler the group needs for its registration window. Declared
* as a method (bivariant) so the real `ActiveDom` satisfies it structurally —
* the group never passes a frame timestamp, so the callback takes no args. Keeps
* this art-adjacent coordinator decoupled from the full `$adom` surface.
*/
export interface PresenceFrameScheduler {
requestFrame(callback: () => void): unknown;
}
export interface PresenceGroupOptions {
/**
* Parent↔child coordination per phase (from `morfo.animation.children`). Enter
* and exit are independent so a container can BRACKET its children —
* `{ enter: 'before', exit: 'after' }` appears before them and leaves after.
*/
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
/**
* Frame scheduler for the registration window (RFC §8.2): children that mount
* in the same pass as the owner register before the scheduled frame, so the
* owner-triggered `requestEnter` sees the full member set. `ActiveDom`
* satisfies this — the group only needs `requestFrame`.
*/
readonly dom: PresenceFrameScheduler;
}
export class PresenceGroup {
/** Discovered by child surfaces via Svelte context (RFC §7.1). */
static readonly ctx = context<PresenceGroup>('PresenceGroup');
/** Optional read — `undefined` when there is no group ancestor (the common case). */
static get(): PresenceGroup | undefined {
return PresenceGroup.ctx.getOr(undefined) as PresenceGroup | undefined;
}
/** Required read — throws when called outside a group scope. */
static require(): PresenceGroup {
return PresenceGroup.ctx.get();
}
/**
* Create the group and publish it in context. Call from the parent surface's
* provider constructor (component init), so descendant surfaces discover it.
*/
static create(opts: PresenceGroupOptions): PresenceGroup {
const group = new PresenceGroup(opts);
PresenceGroup.ctx.set(group);
return group;
}
private readonly opts: PresenceGroupOptions;
private readonly members = new Set<PresenceMember>();
/**
* Generation token (RFC §8.3) — the group-level analogue of `Presence`'s
* `runId`. Every `requestEnter`/`requestExit`/`cancel` bumps it; a scheduled
* `playEnter`/`playExit` captures the value at request time and bails at each
* await boundary once it goes stale. This is what stops a superseded exit from
* tearing down a surface the reversing enter wants to keep.
*/
private generation = 0;
/** The phase currently mid-flight — lets `requestEnter`/`requestExit` detect a reversal. */
private active: PresencePhase | undefined;
constructor(opts: PresenceGroupOptions) {
this.opts = opts;
}
/**
* Register a member. Returns a deregister function — call it from the
* member's cleanup (`$effect` teardown) so the set stays accurate as surfaces
* mount/unmount (RFC §7.1: dynamic member set).
*/
register(member: PresenceMember): () => void {
this.members.add(member);
return () => {
this.members.delete(member);
};
}
/** How many members are registered (the owner uses this to decide whether to coordinate). */
get size(): number {
return this.members.size;
}
/**
* The 0-based position of a CHILD among the children, in registration (document)
* order — the canonical stagger index. A coordinated child derives its
* `--motion-stagger-index` from this, so the cascade offsets itself from the
* coordination order and the dev never hand-numbers the items (RFC §15 / M6).
*/
childIndex(member: PresenceMember): number {
let index = 0;
for (const m of this.members) {
if (m === member) return index;
if (m.role === 'child') index++;
}
return 0;
}
/**
* Schedule a coordinated enter after a registration-window frame, so children
* that registered in the owner's mount pass are included (RFC §8.2). The owner
* `Presence` calls this when its `open` flips true.
*/
requestEnter(): void {
// Reversal (RFC §8.3): an exit is in flight — cancel it so the two phases do
// not stack motion on the same surfaces, then supersede it via a fresh `gen`.
if (this.active === 'exit') this.cancel();
const gen = ++this.generation;
this.opts.dom.requestFrame(() => {
void this.playEnter(gen);
});
}
/**
* Schedule a coordinated exit on the next frame (RFC §8.1). Members are already
* registered (they mounted on enter), so no registration window is needed — the
* frame defer just lets every member record its `ending` intent first.
*/
requestExit(): void {
// Reversal (RFC §8.3): an enter is in flight — cancel it before superseding.
if (this.active === 'enter') this.cancel();
const gen = ++this.generation;
this.opts.dom.requestFrame(() => {
void this.playExit(gen);
});
}
/**
* Coordinate the tree's ENTER (RFC §7, §9). Sequences each member's `release`
* per `when` and aggregates their `finished`: the owner releases before the
* children (`before`), after them (`after`), or all in parallel (`together` —
* the CSS stagger does the visual offset). Resolves when the whole tree settles.
*/
async playEnter(gen: number = this.generation): Promise<void> {
if (this.superseded(gen)) return;
this.active = 'enter';
const members = [...this.members];
const owner = members.filter((m) => m.role === 'owner');
const children = members.filter((m) => m.role === 'child');
switch (this.opts.when.enter) {
case 'before':
await releaseAll(owner, 'enter');
if (this.superseded(gen)) return;
await releaseAll(children, 'enter');
break;
case 'after':
await releaseAll(children, 'enter');
if (this.superseded(gen)) return;
await releaseAll(owner, 'enter');
break;
case 'together':
default:
await releaseAll(members, 'enter');
break;
}
if (this.superseded(gen)) return;
this.active = undefined;
}
/**
* Coordinate the tree's EXIT (RFC §8.1). Sequences each member's `release` per
* `when` (the inverse intent of enter), then tears the tree down. For
* `when: 'after'` the children leave first while the owner RETAINS its DOM,
* then the owner runs its own exit — only after the whole exit aggregates does
* anyone unmount. The visual stagger is CSS (eidos); soma only sequences,
* awaits, and unmounts.
*/
async playExit(gen: number = this.generation): Promise<void> {
if (this.superseded(gen)) return;
this.active = 'exit';
const members = [...this.members];
const owner = members.filter((m) => m.role === 'owner');
const children = members.filter((m) => m.role === 'child');
switch (this.opts.when.exit) {
case 'before':
await releaseAll(owner, 'exit');
if (this.superseded(gen)) return;
await releaseAll(children, 'exit');
break;
case 'after':
// Children leave first; the owner RETAINS its DOM (stays mounted)
// until their exit aggregates, then runs its own exit (RFC §8.1).
await releaseAll(children, 'exit');
if (this.superseded(gen)) return;
await releaseAll(owner, 'exit');
break;
case 'together':
default:
await releaseAll(members, 'exit');
break;
}
// Interruption guard (RFC §8.3): a reversing `requestEnter` bumps
// `generation`; if we were superseded mid-exit, DO NOT tear down — the new
// enter keeps the surfaces mounted (no unmount→remount flash). Only when we
// are still the current phase does the coordinated exit tear the tree down:
// the owner's unmount drops the retained subtree, siblings unmount themselves.
if (this.superseded(gen)) return;
this.active = undefined;
for (const member of members) member.unmount();
}
/**
* Propagate cancellation to every member (interruption — RFC §8.3). Bumps the
* generation so any in-flight `playEnter`/`playExit` bails at its next guard (no
* spurious unmount), clears the active phase, and stops each surface's motion via
* `member.cancel()` (→ `motion.cancel(node)`). Called automatically on a reversal
* by `requestEnter`/`requestExit`; also the public interruption entry point.
*/
cancel(): void {
this.generation++;
this.active = undefined;
for (const member of this.members) member.cancel();
}
/** Whether a newer request has superseded the coordination tagged `gen` (RFC §8.3). */
private superseded(gen: number): boolean {
return gen !== this.generation;
}
}
/** Release every member for a phase in parallel; resolve when all finish (RFC §9 aggregation). */
function releaseAll(members: readonly PresenceMember[], phase: PresencePhase): Promise<void> {
if (members.length === 0) return Promise.resolve();
return Promise.all(members.map((m) => m.release(phase))).then(() => undefined);
}

@ -107,4 +107,28 @@ describe('soma Presence — motion.run (JS-driver gating)', () => {
expect(presence.isPresent).toBe(false); expect(presence.isPresent).toBe(false);
cleanup(); cleanup();
}); });
it('cancel() stops in-flight JS motion via motion.cancel(node) (RFC §8.3)', () => {
const cancelled: HTMLElement[] = [];
const node = fakeNode();
const open = state(true);
const ref = state(node);
const fd = makeFakeDom();
const motion = {
run: () => ({ finished: new Promise<void>(() => {}), cancel() {} }),
cancel: (n: HTMLElement) => cancelled.push(n)
} as never;
const { result: presence, cleanup } = withEffectRoot(
() => new Presence({ dom: fd.dom, open, ref, motion })
);
flushSync();
fd.flush();
presence.cancel();
// Propagates the per-node cancel to the motion engine (RFC §8.3) — the runId
// bump already neutralises the async tail; this stops the JS animation itself.
expect(cancelled).toEqual([node]);
cleanup();
});
}); });

@ -2,6 +2,7 @@ import { type Active, type ActiveProps } from '$libs/reactive';
import { watch } from 'runed'; import { watch } from 'runed';
import type { ActiveDom } from '$adom'; import type { ActiveDom } from '$adom';
import type { EngineMotion } from '$motion'; import type { EngineMotion } from '$motion';
import type { PresenceGroup, PresenceMember, PresencePhase, PresenceRole } from './presence-group';
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@ -20,6 +21,20 @@ export interface PresenceOptions extends ActiveProps<{ open: boolean; ref: HTMLE
* `this.soma.motion`; the engine reads the node's `data-animation-style`. * `this.soma.motion`; the engine reads the node's `data-animation-style`.
*/ */
motion?: EngineMotion; motion?: EngineMotion;
/**
* Presence-coordination group (RFC: eidos/MOTION_SERVICE_RFC.md §7). When
* present, this surface CEDES its enter timing to the group: it mounts and
* waits to be released instead of self-driving (the group sequences releases
* per the morfo's `animation.children.{enter,exit}` and aggregates `finished`).
* Absent (the common case) ⇒ this `Presence` is an island, exactly as before.
*/
group?: PresenceGroup;
/**
* Role within the group. The parent/coordinating surface is `'owner'`; nested
* surfaces are `'child'` (the default). Only the owner triggers the group's
* coordinated enter; children mount and wait to be released.
*/
groupRole?: PresenceRole;
} }
export type TransitionStatus = 'starting' | 'ending' | undefined; export type TransitionStatus = 'starting' | 'ending' | undefined;
@ -41,7 +56,7 @@ export type TransitionStatus = 'starting' | 'ending' | undefined;
* → getAnimations().finished * → getAnimations().finished
* → shouldRender=false + transitionStatus=undefined → onComplete(false) * → shouldRender=false + transitionStatus=undefined → onComplete(false)
*/ */
export class Presence { export class Presence implements PresenceMember {
readonly opts: PresenceOptions; readonly opts: PresenceOptions;
shouldRender = $state(false); shouldRender = $state(false);
@ -49,11 +64,23 @@ export class Presence {
private runId = 0; private runId = 0;
private frameIds = new Set<number>(); private frameIds = new Set<number>();
/** Run id captured when a grouped surface mounts and waits to be released (RFC §7). */
private pendingRunId = 0;
/** Deregister callback from the coordination group, if any. */
private deregister: (() => void) | undefined;
constructor(opts: PresenceOptions) { constructor(opts: PresenceOptions) {
this.opts = opts; this.opts = opts;
this.shouldRender = opts.open.current; this.shouldRender = opts.open.current;
// RFC §7.1: cede to a coordination group if one is in scope. Register now
// (synchronously, in the provider's init) and deregister on teardown so the
// group's member set tracks mount/unmount. No group ⇒ island (below).
if (opts.group) {
this.deregister = opts.group.register(this);
$effect(() => () => this.deregister?.());
}
watch( watch(
() => opts.open.current, () => opts.open.current,
(isOpen) => { (isOpen) => {
@ -79,6 +106,13 @@ export class Presence {
// ── Open ───────────────────────────────────────────────────────────────── // ── Open ─────────────────────────────────────────────────────────────────
private handleOpen() { private handleOpen() {
// Grouped surface: cede the release timing to the group (RFC §7). The
// island path below is unchanged.
if (this.opts.group) {
this.handleOpenGrouped();
return;
}
this.cleanup(); this.cleanup();
this.shouldRender = true; this.shouldRender = true;
this.transitionStatus = 'starting'; this.transitionStatus = 'starting';
@ -101,6 +135,13 @@ export class Presence {
// ── Close ──────────────────────────────────────────────────────────────── // ── Close ────────────────────────────────────────────────────────────────
private handleClose() { private handleClose() {
// Grouped surface: cede the exit to the group, which coordinates the tree's
// exit and unmounts after (DOM retention §8.1). The island path is unchanged.
if (this.opts.group) {
this.handleCloseGrouped();
return;
}
this.cleanup(); this.cleanup();
const enabled = this.opts.enabled ?? true; const enabled = this.opts.enabled ?? true;
@ -125,6 +166,104 @@ export class Presence {
}); });
} }
// ── Grouped lifecycle (RFC §7) — only active when `opts.group` is set ──────
/**
* Grouped ENTER: mount immediately, then WAIT to be released by the group.
* Only the owner triggers the group's coordinated enter; children mount and
* stay in `data-starting-style` until the group calls `release('enter')`.
*/
private handleOpenGrouped() {
this.cleanup();
this.shouldRender = true;
this.transitionStatus = 'starting';
this.pendingRunId = ++this.runId;
if (this.role === 'owner') this.opts.group?.requestEnter();
}
/**
* Grouped EXIT: cede to the group (RFC §8.1). Mark `ending` and WAIT — the
* group runs the tree's exit in `when` order and calls `unmount()` here only
* after the whole exit settles (DOM retention). Only the owner triggers it.
*/
private handleCloseGrouped() {
this.cleanup();
const enabled = this.opts.enabled ?? true;
if (!enabled) {
this.unmount();
return;
}
// Stay in the OPEN visual state (no `data-ending-style` yet) until the group
// RELEASES this surface. That is what makes `when: 'after'` exit the children
// before the owner — and the owner stay visibly intact while its children
// leave — instead of everyone's exit animation firing at once. `release`
// stamps `ending` when our turn comes (RFC §8.1).
this.pendingRunId = ++this.runId;
if (this.role === 'owner') this.opts.group?.requestExit();
}
/** This surface's role within its group (`PresenceMember`). */
get role(): PresenceRole {
return this.opts.groupRole ?? 'child';
}
/**
* Release this surface's motion for the phase — the group calls this in
* coordinated order (`PresenceMember`). Removes `data-starting-style` on enter,
* starts the JS motion, and resolves when this surface's own animation finishes
* (CSS via `getAnimations()` + any JS `finished`). The visual stagger is CSS
* (eidos); soma only awaits.
*/
release(phase: PresencePhase): Promise<void> {
const runId = this.pendingRunId;
return new Promise<void>((resolve) => {
this.requestFrame(() => {
if (runId !== this.runId) {
resolve();
return;
}
// Enter: drop `starting` → the CSS transition plays. Exit: stamp
// `ending` NOW (not at close) so the surface holds its open state until
// the group reaches it, giving `when: 'after'` its sequencing (§8.1).
if (phase === 'enter') this.transitionStatus = undefined;
else this.transitionStatus = 'ending';
const extra = this.startMotion(phase);
this.waitForAnimations(runId, extra, () => {
if (phase === 'enter') this.opts.onComplete?.(true);
resolve();
});
});
});
}
/**
* Tear down after the group's coordinated exit settles (`PresenceMember`, RFC
* §8.1). Mirrors the island unmount: drop the surface and notify. The group
* calls this once the whole tree's exit has aggregated — never before, so the
* owner's DOM (and any retained subtree) survives the children's exit.
*/
unmount(): void {
this.shouldRender = false;
this.transitionStatus = undefined;
this.opts.onComplete?.(false);
}
/**
* Cancel in-flight motion (interruption — RFC §8.3, M4). `cleanup()` bumps the
* runId so the in-flight async tail (frames, `waitForAnimations`) becomes a
* no-op, and `motion.cancel(node)` stops any JS-driven run so a reversal does
* not stack a second animation on the node (CSS presets are untracked → no-op;
* the reversed CSS transition continues from its current value). The group calls
* this on every member when a reversal supersedes the in-flight phase.
*/
cancel(): void {
this.cleanup();
const node = this.opts.ref.current;
if (node) this.opts.motion?.cancel(node);
}
// ── Animation waiting ──────────────────────────────────────────────────── // ── Animation waiting ────────────────────────────────────────────────────
/** Start the JS-driven motion for this phase, if a `motion` engine is wired. */ /** Start the JS-driven motion for this phase, if a `motion` engine is wired. */

@ -19,6 +19,7 @@
import { ActiveEidos } from '$uix/eidos' import { ActiveEidos } from '$uix/eidos'
import { rect, spring, waapi } from '$motion' import { rect, spring, waapi } from '$motion'
import { Presence } from '$soma/layers/presence.svelte' import { Presence } from '$soma/layers/presence.svelte'
import { PresenceGroup } from '$soma/layers/presence-group'
import { readableActive } from '$libs/reactive' import { readableActive } from '$libs/reactive'
import { Dialog } from '$uix/eidos/components/dialog' import { Dialog } from '$uix/eidos/components/dialog'
import { Popover } from '$uix/eidos/components/popover' import { Popover } from '$uix/eidos/components/popover'
@ -199,6 +200,39 @@
motion: eidos.motion motion: eidos.motion
}) })
// ── PresenceGroup — coordinated list with EXIT retention (RFC §13 prototype) ──
// The motion service end-to-end (M2 enter + M3 exit): a container (owner) and
// its items (children) coordinated by a `PresenceGroup` with `when: 'after'`.
// On CLOSE the items stagger OUT while the container RETAINS its DOM; only when
// their exit aggregates does the container leave, and only then does the tree
// unmount (§8.1). The visual stagger is pure CSS (transition / animation-delay
// × --i); soma only sequences + awaits `getAnimations()` — no JS motion here.
const PG_ITEMS = [0, 1, 2, 3]
let pgOpen = $state(false)
const pgOpenActive = readableActive(() => pgOpen)
const pgGroup = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: eidos.dom })
let pgContainerEl = $state<HTMLElement | null>(null)
const pgContainer = new Presence({
dom: eidos.dom,
open: pgOpenActive,
ref: readableActive(() => pgContainerEl),
group: pgGroup,
groupRole: 'owner'
})
const pgItemEls = $state<Record<number, HTMLElement | null>>({})
const pgItems = PG_ITEMS.map(
(i) =>
new Presence({
dom: eidos.dom,
open: pgOpenActive,
ref: readableActive(() => pgItemEls[i] ?? null),
group: pgGroup,
groupRole: 'child'
})
)
// FLIP — layout transition via the `rect` driver: measure first, mutate the // FLIP — layout transition via the `rect` driver: measure first, mutate the
// layout, then animate the inverse delta. The WAAPI animation is in // layout, then animate the inverse delta. The WAAPI animation is in
// getAnimations(), so it composes with the rest of the system for free. // getAnimations(), so it composes with the rest of the system for free.
@ -522,6 +556,54 @@ eidos.motion.enter(el, 'pop', { dom: eidos.dom })`}</pre>
// panel markup: <div data-animation-style="panel-spring" /> → motion.run runs the spring`}</pre> // panel markup: <div data-animation-style="panel-spring" /> → motion.run runs the spring`}</pre>
</section> </section>
<!-- PresenceGroup — coordinated list with EXIT retention (§13 prototype) -->
<section class="preset">
<div class="head">
<span class="eyebrow">soma PresenceGroup · when='after' · coordinación de presencia</span>
<h2>presence group <small>(coreografía exit-heavy · retención de DOM)</small></h2>
<p>
El <strong>servicio de motion end-to-end</strong> (M2 enter + M3 exit). Un contenedor
(<code>owner</code>) y sus ítems (<code>child</code>) coordinados por un
<code>PresenceGroup</code> con <code>when='after'</code>. Al <strong>cerrar</strong>, los
ítems salen <strong>en stagger</strong> mientras el contenedor <strong>retiene su DOM</strong>;
solo cuando su salida agrega, el contenedor se va y entonces se desmonta el árbol (§8.1). El
stagger visual es CSS puro (<code>animation-delay × --i</code>); soma solo secuencia y espera
<code>getAnimations()</code>. (En el preview en segundo plano el rAF puede congelarse; enfoca
el browser para ver la coreografía.)
</p>
</div>
<div class="demo">
<div class="actions">
<button class="btn" type="button" onclick={() => (pgOpen = !pgOpen)}>
{pgOpen ? 'Cerrar' : 'Abrir'} lista
</button>
<span class="tag">contenedor: {pgContainer.isPresent ? 'montado' : 'desmontado'}</span>
</div>
<div class="stage auto">
{#if pgContainer.isPresent}
<div class="pg-container" bind:this={pgContainerEl} {...pgContainer.transitionAttrs}>
{#each PG_ITEMS as i (i)}
{#if pgItems[i].isPresent}
<div
class="pg-item"
bind:this={pgItemEls[i]}
style="--i: {i}"
{...pgItems[i].transitionAttrs}
>
item {i + 1}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<pre class="code">{`const group = new PresenceGroup({ when: 'after', dom })
const container = new Presence({ open, ref, group, groupRole: 'owner' })
const items = [...].map(i => new Presence({ open, ref: itemRef(i), group, groupRole: 'child' }))
// close → items stagger out (container retained) → container exits → unmount`}</pre>
</section>
<!-- FLIP — layout transition via the rect driver --> <!-- FLIP — layout transition via the rect driver -->
<section class="preset"> <section class="preset">
<div class="head"> <div class="head">
@ -1089,4 +1171,53 @@ eidos.motion.enter(boxEl, 'genie') // ▶ play`}</pre>
font-weight: 500; font-weight: 500;
will-change: transform, opacity; will-change: transform, opacity;
} }
.pg-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
padding: var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border-subtle);
}
.pg-container[data-ending-style] {
animation: pg-container-out 0.3s var(--ease-in, ease) forwards;
}
@keyframes pg-container-out {
to {
opacity: 0;
transform: scale(0.97);
}
}
.pg-item {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
font-size: var(--font-size-sm);
font-weight: 600;
opacity: 1;
transform: translateY(0);
transition:
opacity 0.3s ease,
transform 0.3s ease;
transition-delay: calc(var(--i) * 0.06s);
will-change: transform, opacity;
}
.pg-item[data-starting-style] {
opacity: 0;
transform: translateY(10px);
}
.pg-item[data-ending-style] {
animation: pg-item-out 0.35s ease forwards;
animation-delay: calc(var(--i) * 0.08s);
}
@keyframes pg-item-out {
to {
opacity: 0;
transform: translateY(10px);
}
}
</style> </style>

@ -0,0 +1,244 @@
<script lang="ts">
/**
* PresenceGroup — práctica.
*
* The motion service's presence coordination (RFC: eidos/MOTION_SERVICE_RFC.md
* §7–§9), shown in isolation. Each card is a coordinated list (an owner
* container + its child items) wired through the real soma `PresenceGroup` +
* `Presence`, one per `when` mode. Discovery is by Svelte context (§7.1); the
* visual stagger is pure CSS; soma only sequences WHEN each surface enters /
* exits / unmounts — never the look. Inherits the `ActiveUix + Soma + Eidos`
* scope from `../+layout.svelte`.
*/
import CoordList from './coord-list.svelte';
type Phase = 'before' | 'after' | 'together';
type Mode = { id: string; enter: Phase; exit: Phase; badge: string; desc: string };
const MODES: Mode[] = [
{
id: 'bracket',
enter: 'before',
exit: 'after',
badge: "enter:'before' · exit:'after'",
desc: 'El contenedor BRACKETEA a sus hijos: entra antes que ellos y sale después (reteniendo su DOM hasta que terminan). El caso natural de un contenedor — justo lo que un when simétrico no podía expresar.'
},
{
id: 'exit-heavy',
enter: 'together',
exit: 'after',
badge: "enter:'together' · exit:'after'",
desc: 'Entran a la vez; al cerrar, los hijos salen en stagger y el contenedor aguanta hasta que su salida agrega. La lista exit-heavy de la RFC §13.'
},
{
id: 'together',
enter: 'together',
exit: 'together',
badge: "enter/exit:'together'",
desc: 'Todo en paralelo; el escalonado visual lo pone solo el CSS (animation-delay × índice). soma agrega el finished del árbol.'
}
];
const open = $state<Record<string, boolean>>({
bracket: false,
'exit-heavy': false,
together: false
});
const toggleAll = () => {
const next = !MODES.every((m) => open[m.id]);
for (const m of MODES) open[m.id] = next;
};
// Reversa demo (RFC §8.3): flip the card, then flip it BACK mid-animation (the
// transitions are ~300ms). The group's generation token must keep the surfaces
// mounted through the reversal — re-opening mid-exit must NOT unmount→remount.
const reversa = (id: string) => {
const back = open[id];
open[id] = !back;
setTimeout(() => (open[id] = back), 150);
};
</script>
<svelte:head>
<title>PresenceGroup · práctica</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>PresenceGroup <small>coordinación de presencia</small></h1>
<p class="lede">
La capa de coordinación del servicio de motion, en aislado. Cada tarjeta es una lista
coordinada —un contenedor <strong>owner</strong> y sus ítems <strong>child</strong>— cableada
por el <code>PresenceGroup</code> + <code>Presence</code> reales de soma, uno por modo
<code>when</code>. Los ítems descubren el grupo por <strong>context</strong> (§7.1); el
stagger es CSS puro; soma solo decide <strong>cuándo</strong> entra / sale / se desmonta cada
superficie. Fíjate en <code>after</code>: al cerrar, los ítems salen y el contenedor
<strong>aguanta</strong> hasta que terminan. Y <strong>↻ Reversa</strong> reabre a media
salida (§8.3): el token de generación del grupo no desmonta lo que vuelve —
<strong>sin parpadeo</strong>.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={toggleAll}>Abrir / cerrar todas</button>
<span class="hint"
>Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el
rAF se throttlea).</span
>
</div>
</header>
<div class="grid">
{#each MODES as m (m.id)}
<section class="card">
<div class="card-head">
<code class="badge">{m.badge}</code>
<div class="card-actions">
<button
class="btn ghost"
type="button"
aria-label="Reversa"
title="Reversa: flip + flip back mid-animation (§8.3)"
onclick={() => reversa(m.id)}
>
↻
</button>
<button class="btn" type="button" onclick={() => (open[m.id] = !open[m.id])}>
{open[m.id] ? 'Cerrar' : 'Abrir'}
</button>
</div>
</div>
<p class="desc">{m.desc}</p>
<div class="stage">
<CoordList enter={m.enter} exit={m.exit} open={open[m.id]} count={5} />
</div>
</section>
{/each}
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0;
max-width: 64ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.hint {
font-size: 0.78rem;
color: var(--color-content-muted, #888);
max-width: 42ch;
}
.grid {
max-width: 880px;
margin-inline: auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 16px;
background: var(--color-surface-raised, #f7f7f8);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.card-actions {
display: flex;
gap: 0.4rem;
}
.btn.ghost {
background: transparent;
color: var(--color-content-muted, #666);
font-size: 0.95rem;
line-height: 1;
padding: 0.4rem 0.55rem;
}
.badge {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: 0.78rem;
padding: 0.15em 0.5em;
border-radius: 6px;
background: color-mix(in srgb, var(--color-primary-solid, #4f46e5) 12%, transparent);
color: var(--color-primary-text, var(--color-primary-solid, #4f46e5));
}
.desc {
margin: 0;
font-size: 0.82rem;
line-height: 1.5;
color: var(--color-content-secondary, #555);
}
.stage {
min-height: 220px;
display: flex;
align-items: flex-start;
padding: 0.75rem;
border-radius: 12px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-default, #fff);
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.primary {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
border-color: transparent;
}
.btn:hover {
border-color: var(--color-primary-solid, #4f46e5);
}
</style>

@ -0,0 +1,62 @@
<script lang="ts">
/**
* One coordinated child surface. It DISCOVERS its group via Svelte context
* (RFC §7.1) — the parent `CoordList` published it; this item never receives
* the group explicitly. Pure CSS handles the visual (stagger via --i); the
* group only decides WHEN this surface is released / unmounted.
*/
import { Presence } from '$soma/layers/presence.svelte'
import { PresenceGroup } from '$soma/layers/presence-group'
import { readableActive } from '$libs/reactive'
import { ActiveEidos } from '$uix/eidos'
let { open, index }: { open: boolean; index: number } = $props()
const eidos = ActiveEidos.require()
const group = PresenceGroup.get()
let el = $state<HTMLElement | null>(null)
const presence = new Presence({
dom: eidos.dom,
open: readableActive(() => open),
ref: readableActive(() => el),
group,
groupRole: 'child'
})
</script>
{#if presence.isPresent}
<div class="coord-item" bind:this={el} style="--i: {index}" {...presence.transitionAttrs}>
ítem {index + 1}
</div>
{/if}
<style>
.coord-item {
padding: 0.5rem 0.85rem;
border-radius: 8px;
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
font-size: 0.85rem;
font-weight: 600;
opacity: 1;
transform: translateY(0);
transition:
opacity 0.4s ease,
transform 0.4s ease;
transition-delay: calc(var(--i) * 0.08s);
}
.coord-item[data-starting-style] {
opacity: 0;
transform: translateY(12px);
}
.coord-item[data-ending-style] {
animation: coord-item-out 0.4s ease forwards;
animation-delay: calc(var(--i) * 0.1s);
}
@keyframes coord-item-out {
to {
opacity: 0;
transform: translateY(12px);
}
}
</style>

@ -0,0 +1,75 @@
<script lang="ts">
/**
* The owner surface of a coordinated list. It creates the `PresenceGroup`
* (with the `when` that a real morfo would declare) and publishes it in
* context, then renders its child items — which discover the group via
* context (RFC §7.1) and register. The group sequences enter/exit per `when`
* and, on exit, RETAINS this container's DOM until the items have left (§8.1).
*/
import { untrack } from 'svelte';
import { Presence } from '$soma/layers/presence.svelte';
import { PresenceGroup, type PresenceWhen } from '$soma/layers/presence-group';
import { readableActive } from '$libs/reactive';
import { ActiveEidos } from '$uix/eidos';
import CoordItem from './coord-item.svelte';
let {
enter,
exit,
open,
count = 5
}: { enter: PresenceWhen; exit: PresenceWhen; open: boolean; count?: number } = $props();
const eidos = ActiveEidos.require();
// `enter`/`exit` are fixed per instance (a real morfo declares them once); read
// them untracked so creating the group doesn't pretend to react to a changing prop.
const group = untrack(() => PresenceGroup.create({ when: { enter, exit }, dom: eidos.dom }));
let containerEl = $state<HTMLElement | null>(null);
const container = new Presence({
dom: eidos.dom,
open: readableActive(() => open),
ref: readableActive(() => containerEl),
group,
groupRole: 'owner'
});
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#if container.isPresent}
<div class="coord-container" bind:this={containerEl} {...container.transitionAttrs}>
{#each items as i (i)}
<CoordItem {open} index={i} />
{/each}
</div>
{/if}
<style>
.coord-container {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
opacity: 1;
transform: scale(1);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.coord-container[data-starting-style] {
opacity: 0;
transform: scale(0.97);
}
.coord-container[data-ending-style] {
animation: coord-container-out 0.35s ease forwards;
}
@keyframes coord-container-out {
to {
opacity: 0;
transform: scale(0.96);
}
}
</style>

@ -0,0 +1,252 @@
<script lang="ts">
/**
* Rail — the SECOND consumer of the coordinated-motion helper
* (`soma/layers/coordination.ts`). Same coordination as `Reveal`, DIFFERENT shape:
* the root IS the owner surface, the relation is `together` (all parallel, CSS
* stagger), it is PARENT-CONTROLLED via `open` (no trigger), and has NO sema events.
* Proves the helper is reusable — building this was a handful of lines.
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import * as Rail from '$soma/components/rail';
const ITEMS = ['Inicio', 'Buscar', 'Mensajes', 'Perfil', 'Ajustes'];
const STYLES = [
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' }
];
let open = $state(false);
let animStyle = $state('cascade-slide');
let duration = $state(300); // ms — per-surface transition speed (exposed as a CSS var)
let stagger = $state(45); // ms — the rhythm between items (`--motion-stagger-each`)
</script>
<svelte:head>
<title>Rail · 2º consumidor coordinado</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>Rail <small>2º consumidor del helper coordinado</small></h1>
<p class="lede">
Misma coordinación que <a href="/temas/animations/reveal">Reveal</a>, otra forma: la raíz
<strong>ES</strong> la superficie owner, la relación es <code>together</code> (todo en
paralelo, el escalonado lo pone el CSS), es <strong>controlada por el padre</strong>
(<code>bind:open</code>, sin trigger) y <strong>no tiene eventos sema</strong>. Todo el wiring
—PresenceGroup + routing + auto-stagger— sale del helper compartido
<code>soma/layers/coordination.ts</code>; construir este componente fueron cuatro líneas.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={() => (open = !open)}>
{open ? 'Ocultar' : 'Mostrar'} rail
</button>
<div class="picker" role="group" aria-label="Estilo de animación">
{#each STYLES as s (s.value)}
<button
class="chip-btn"
class:on={animStyle === s.value}
type="button"
onclick={() => (animStyle = s.value)}
>
{s.label}
</button>
{/each}
</div>
</div>
<div class="sliders">
<label>
duración
<input type="range" min="80" max="700" step="20" bind:value={duration} />
<span class="val">{duration}ms</span>
</label>
<label>
stagger
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
<span class="val">{stagger}ms</span>
</label>
</div>
</header>
<div
class="stage"
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
>
<Rail.Provider bind:open animation={animStyle}>
{#each ITEMS as label (label)}
<Rail.Item class="chip">{label}</Rail.Item>
{/each}
</Rail.Provider>
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0;
max-width: 64ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede a {
color: var(--color-primary-solid, #4f46e5);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.primary {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
border-color: transparent;
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip-btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip-btn.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 1.5rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 140px;
display: flex;
align-items: center;
}
.sliders {
display: flex;
gap: 1.5rem;
margin-block-start: 1rem;
flex-wrap: wrap;
}
.sliders label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--color-content-secondary, #555);
}
.sliders input[type='range'] {
accent-color: var(--color-primary-solid, #4f46e5);
}
.sliders .val {
min-width: 3.5ch;
font-variant-numeric: tabular-nums;
color: var(--color-content-muted, #888);
}
/* The Rail surfaces — global (rendered by the soma component). HORIZONTAL row;
soma stamps data-starting/ending-style, the CSS only reacts. */
:global([data-rail]) {
display: flex;
flex-direction: row;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease;
}
:global([data-rail-item]) {
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: var(--color-surface-default, #fff);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
font-size: 0.85rem;
white-space: nowrap;
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease;
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
/* Close = open reversed (last chip leaves first). */
:global([data-rail-item][data-ending-style]) {
transition-delay: calc(
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
var(--motion-stagger-each, 0ms)
);
}
/* `cascade-*` (non-eidos-preset names) — react to the Presence attrs, not data-state. */
:global([data-animation-style='cascade-slide'][data-starting-style]),
:global([data-animation-style='cascade-slide'][data-ending-style]) {
opacity: 0;
transform: translateX(-12px);
}
:global([data-animation-style='cascade-fade'][data-starting-style]),
:global([data-animation-style='cascade-fade'][data-ending-style]) {
opacity: 0;
}
:global([data-animation-style='cascade-scale'][data-starting-style]),
:global([data-animation-style='cascade-scale'][data-ending-style]) {
opacity: 0;
transform: scale(0.85);
}
</style>

@ -0,0 +1,334 @@
<script lang="ts">
/**
* Reveal — the FIRST REAL consumer of the motion-coordination contract.
*
* Unlike the synthetic `/presence-group` demo (which hand-constructs `Presence`
* + `PresenceGroup`), this drives the REAL soma `<Reveal>` component: its morfo
* declares `panel.animation.children = { enter: 'before', exit: 'after' }`, and
* the provider reads that COMPILED contract to build the group (RFC §4). The
* panel enters, then the items cascade in; on close the items leave first while
* the panel RETAINS its DOM (exit-heavy §8.1), then the panel goes. The visual
* stagger is pure CSS here (transition-delay × index); soma only sequences WHEN.
*
* M5 (RFC §5): the `animation` prop is named ONCE on the root and routed to the
* declared surfaces (Panel + Items) as `data-animation-style` — "el sistema sabe
* dónde". The picker changes it live; the CSS below reacts to the attr.
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import * as Reveal from '$soma/components/reveal';
const ITEMS = ['Perfil', 'Ajustes', 'Notificaciones', 'Equipo', 'Facturación', 'Salir'];
// Coordinated-surface styles — deliberately NOT the eidos built-in preset names
// (`fade`, `slide-fade`, `scale-fade`…). Those presets generate
// `[data-animation-style][data-state]` CSS that fires on MOUNT (keyframe animation),
// which collides with and fights the coordinated cascade — the §5 finding. These
// `cascade-*` names route to NO eidos preset, so only this page's own
// data-starting-style-gated transitions apply (the correct gating for coordination).
const STYLES = [
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' },
{ value: 'cascade-drop', label: 'drop' }, // pure transform — 1 CSS rule, nothing else
{ value: 'cascade-blur', label: 'blur' } // adds `filter` → also extend the base transition
];
let open = $state(false);
let animStyle = $state('cascade-slide');
let duration = $state(300); // ms — the per-surface transition speed (exposed as a CSS var)
let stagger = $state(55); // ms — the rhythm between items (`--motion-stagger-each`)
// Reversa (RFC §8.3): re-open mid-exit — the group's generation token keeps the
// surfaces mounted (no unmount→remount flash). Flip, then flip back mid-animation.
const reversa = () => {
const back = open;
open = !back;
setTimeout(() => (open = back), 150);
};
</script>
<svelte:head>
<title>Reveal · primer consumidor real</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>Reveal <small>primer consumidor real</small></h1>
<p class="lede">
A diferencia del demo <a href="/temas/animations/presence-group">presence-group</a> —que
construye <code>Presence</code> + <code>PresenceGroup</code> a mano— esto usa el componente
<strong>real</strong> <code>&lt;Reveal&gt;</code>: su <strong>morfo</strong> declara
<code>panel.animation.children = &#123; enter: 'before', exit: 'after' &#125;</code>, y el
<strong>provider lee ese contrato compilado</strong> para construir el grupo (RFC §4). El
panel entra, luego los ítems <strong>entran en cascada</strong>; al cerrar los ítems salen
primero mientras el panel <strong>retiene su DOM</strong> (exit-heavy §8.1), y solo después se
va el panel. El escalonado es CSS puro; soma solo decide el <strong>cuándo</strong>. El
selector cambia el prop <code>animation</code>, que el componente
<strong>enruta a las superficies declaradas</strong>
(Panel + Items) como <code>data-animation-style</code> — se nombra una vez, el sistema sabe dónde
(RFC §5).
</p>
<div class="actions">
<div class="picker" role="group" aria-label="Estilo de animación">
{#each STYLES as s (s.value)}
<button
class="chip"
class:on={animStyle === s.value}
type="button"
onclick={() => (animStyle = s.value)}
>
{s.label}
</button>
{/each}
</div>
<button class="btn ghost" type="button" onclick={reversa}>↻ Reversa</button>
<span class="hint"
>Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el
rAF se throttlea).</span
>
</div>
<div class="sliders">
<label>
duración
<input type="range" min="80" max="700" step="20" bind:value={duration} />
<span class="val">{duration}ms</span>
</label>
<label>
stagger
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
<span class="val">{stagger}ms</span>
</label>
</div>
</header>
<!-- The slider-controlled vars live on the stage and INHERIT to the surfaces:
`--cascade-duration` (the transition speed) + `--motion-stagger-each` (rhythm)
+ `--motion-stagger-count` (for the reversed exit). Each item still auto-derives
its own `--motion-stagger-index` (M6). -->
<div
class="stage"
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
>
<Reveal.Provider bind:open animation={animStyle}>
<Reveal.Trigger>
{open ? '▾' : '▸'} Menú de cuenta
</Reveal.Trigger>
<Reveal.Panel class="panel">
{#each ITEMS as label (label)}
<Reveal.Item class="item">{label}</Reveal.Item>
{/each}
</Reveal.Panel>
</Reveal.Provider>
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0;
max-width: 64ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede a {
color: var(--color-primary-solid, #4f46e5);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.hint {
font-size: 0.78rem;
color: var(--color-content-muted, #888);
max-width: 42ch;
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.ghost {
background: transparent;
color: var(--color-content-muted, #666);
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.sliders {
display: flex;
gap: 1.5rem;
margin-block-start: 1rem;
flex-wrap: wrap;
}
.sliders label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--color-content-secondary, #555);
}
.sliders input[type='range'] {
accent-color: var(--color-primary-solid, #4f46e5);
}
.sliders .val {
min-width: 3.5ch;
font-variant-numeric: tabular-nums;
color: var(--color-content-muted, #888);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 1.5rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 360px;
}
/* The Reveal surfaces — global because the elements are rendered by the soma
component, not this page's own markup. soma stamps data-starting-style /
data-ending-style; the CSS only reacts (it never decides WHEN). */
:global([data-reveal-trigger]) {
appearance: none;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 0.5rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.18));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
margin-block-end: 0.6rem;
}
:global([data-reveal-panel]) {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: min(280px, 100%);
padding: 0.5rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease,
filter var(--cascade-duration, 0.3s) ease;
}
:global([data-reveal-item]) {
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--color-surface-default, #fff);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
font-size: 0.9rem;
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease,
filter var(--cascade-duration, 0.3s) ease;
/* Canonical eidos stagger tokens (M6): index (per item, auto-derived from the
group order) × each (the rhythm, inherited from the panel). Enter counts UP. */
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
/* The CLOSE is the OPEN played in reverse: the exit stagger counts DOWN
(count − 1 − index), so the last item leaves first and the choreography mirrors
the entrance — the inverse the user expects. */
:global([data-reveal-item][data-ending-style]) {
transition-delay: calc(
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
var(--motion-stagger-each, 0ms)
);
}
/* The DX `animation` prop → `data-animation-style` on the surfaces drives the
LOOK; the Presence's data-starting-style / data-ending-style gate WHEN (so the
coordinated cascade is preserved — unlike the built-in data-state presets,
which would fire on mount and break the sequencing). */
:global([data-animation-style='cascade-slide'][data-starting-style]),
:global([data-animation-style='cascade-slide'][data-ending-style]) {
opacity: 0;
transform: translateX(-16px);
}
:global([data-animation-style='cascade-fade'][data-starting-style]),
:global([data-animation-style='cascade-fade'][data-ending-style]) {
opacity: 0;
}
:global([data-animation-style='cascade-scale'][data-starting-style]),
:global([data-animation-style='cascade-scale'][data-ending-style]) {
opacity: 0;
transform: scale(0.9);
}
/* `drop` — vertical, distinct from slide's horizontal. Pure transform: nothing
else to wire (stagger + reverse-on-exit are already generic). */
:global([data-animation-style='cascade-drop'][data-starting-style]),
:global([data-animation-style='cascade-drop'][data-ending-style]) {
opacity: 0;
transform: translateY(-14px);
}
/* `blur` — uses `filter`, a property beyond opacity/transform, so it ALSO needs
`filter` in the base transition above (panel + item). */
:global([data-animation-style='cascade-blur'][data-starting-style]),
:global([data-animation-style='cascade-blur'][data-ending-style]) {
opacity: 0;
transform: scale(1.03);
filter: blur(6px);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.