From 48b183672a2568853d21bd5720aa45286421c0a3 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 15 Jun 2026 10:46:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(motion):=20servicio=20de=20coordinaci?= =?UTF-8?q?=C3=B3n=20de=20presencia=20(M1=E2=80=93M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/arts/motion/engine-motion.test.ts | 79 +++ src/arts/motion/engine-motion.ts | 17 +- src/arts/motion/index.ts | 1 + src/arts/motion/types.ts | 25 +- src/uix/eidos/MOTION_SERVICE_RFC.md | 666 ++++++++++++++++++ src/uix/eidos/generated/base.css | 50 ++ src/uix/eidos/lib/motion/presets/css.ts | 17 +- src/uix/eidos/lib/render-css.ts | 33 + src/uix/eidos/lib/themes/base.ts | 10 +- src/uix/eidos/motion.test.ts | 37 +- src/uix/morfo/compile.test.ts | 88 +++ src/uix/morfo/compile.ts | 49 +- src/uix/morfo/components/rail.ts | 55 ++ src/uix/morfo/components/reveal.ts | 133 ++++ src/uix/morfo/index.ts | 3 + src/uix/morfo/schema.test.ts | 34 + src/uix/morfo/schema.ts | 30 + src/uix/morfo/types.ts | 65 ++ .../rail/components/rail-item.svelte | 31 + .../components/rail/components/rail.svelte | 57 ++ src/uix/soma/components/rail/exports.ts | 4 + src/uix/soma/components/rail/index.ts | 1 + .../rail/rail-provider.svelte.test.ts | 96 +++ .../components/rail/rail-provider.svelte.ts | 121 ++++ src/uix/soma/components/rail/types.ts | 23 + .../reveal/components/reveal-item.svelte | 32 + .../reveal/components/reveal-panel.svelte | 35 + .../reveal/components/reveal-trigger.svelte | 33 + .../reveal/components/reveal.svelte | 31 + src/uix/soma/components/reveal/exports.ts | 11 + src/uix/soma/components/reveal/index.ts | 1 + .../reveal/reveal-provider.svelte.test.ts | 178 +++++ .../reveal/reveal-provider.svelte.ts | 220 ++++++ src/uix/soma/components/reveal/types.ts | 39 + src/uix/soma/layers/coordination.ts | 137 ++++ src/uix/soma/layers/index.ts | 10 + src/uix/soma/layers/presence-group.test.ts | 326 +++++++++ src/uix/soma/layers/presence-group.ts | 294 ++++++++ src/uix/soma/layers/presence.svelte.test.ts | 24 + src/uix/soma/layers/presence.svelte.ts | 141 +++- web/routes/temas/animations/+page.svelte | 131 ++++ .../animations/presence-group/+page.svelte | 244 +++++++ .../presence-group/coord-item.svelte | 62 ++ .../presence-group/coord-list.svelte | 75 ++ web/routes/temas/animations/rail/+page.svelte | 252 +++++++ .../temas/animations/reveal/+page.svelte | 334 +++++++++ 46 files changed, 4324 insertions(+), 11 deletions(-) create mode 100644 src/arts/motion/engine-motion.test.ts create mode 100644 src/uix/eidos/MOTION_SERVICE_RFC.md create mode 100644 src/uix/morfo/components/rail.ts create mode 100644 src/uix/morfo/components/reveal.ts create mode 100644 src/uix/soma/components/rail/components/rail-item.svelte create mode 100644 src/uix/soma/components/rail/components/rail.svelte create mode 100644 src/uix/soma/components/rail/exports.ts create mode 100644 src/uix/soma/components/rail/index.ts create mode 100644 src/uix/soma/components/rail/rail-provider.svelte.test.ts create mode 100644 src/uix/soma/components/rail/rail-provider.svelte.ts create mode 100644 src/uix/soma/components/rail/types.ts create mode 100644 src/uix/soma/components/reveal/components/reveal-item.svelte create mode 100644 src/uix/soma/components/reveal/components/reveal-panel.svelte create mode 100644 src/uix/soma/components/reveal/components/reveal-trigger.svelte create mode 100644 src/uix/soma/components/reveal/components/reveal.svelte create mode 100644 src/uix/soma/components/reveal/exports.ts create mode 100644 src/uix/soma/components/reveal/index.ts create mode 100644 src/uix/soma/components/reveal/reveal-provider.svelte.test.ts create mode 100644 src/uix/soma/components/reveal/reveal-provider.svelte.ts create mode 100644 src/uix/soma/components/reveal/types.ts create mode 100644 src/uix/soma/layers/coordination.ts create mode 100644 src/uix/soma/layers/presence-group.test.ts create mode 100644 src/uix/soma/layers/presence-group.ts create mode 100644 web/routes/temas/animations/presence-group/+page.svelte create mode 100644 web/routes/temas/animations/presence-group/coord-item.svelte create mode 100644 web/routes/temas/animations/presence-group/coord-list.svelte create mode 100644 web/routes/temas/animations/rail/+page.svelte create mode 100644 web/routes/temas/animations/reveal/+page.svelte diff --git a/src/arts/motion/engine-motion.test.ts b/src/arts/motion/engine-motion.test.ts new file mode 100644 index 000000000..6e3bb9c8b --- /dev/null +++ b/src/arts/motion/engine-motion.test.ts @@ -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((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(() => {}), + 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); + }); +}); diff --git a/src/arts/motion/engine-motion.ts b/src/arts/motion/engine-motion.ts index 1fa7c4d89..ce5ee6055 100644 --- a/src/arts/motion/engine-motion.ts +++ b/src/arts/motion/engine-motion.ts @@ -197,6 +197,13 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion * Normalise a `MotionRun` result to a single `MotionHandle`. An `Animation` * (or `Animation[]`) and a `MotionHandle` both expose `finished` + `cancel`; * 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( result: Animation | readonly Animation[] | MotionHandle, @@ -205,7 +212,10 @@ function toHandle( if (Array.isArray(result)) { const anims = result as readonly Animation[] return { - finished: Promise.all(anims.map((a) => a.finished)).then(() => {}), + finished: Promise.all(anims.map((a) => a.finished)).then( + () => {}, + () => {} + ), cancel() { controller.abort() for (const a of anims) safeCancel(a) @@ -214,7 +224,10 @@ function toHandle( } const single = result as { finished: Promise; cancel: () => void } return { - finished: Promise.resolve(single.finished).then(() => {}), + finished: Promise.resolve(single.finished).then( + () => {}, + () => {} + ), cancel() { controller.abort() safeCancel(single) diff --git a/src/arts/motion/index.ts b/src/arts/motion/index.ts index 1b948f31e..ee5f4119a 100644 --- a/src/arts/motion/index.ts +++ b/src/arts/motion/index.ts @@ -9,6 +9,7 @@ export type { SpringConfig, SpringPhysics } from './drivers' export { isCssStatePreset } from './types' export type { + CoordinatedPreset, CssPhase, CssStatePreset, EventSignature, diff --git a/src/arts/motion/types.ts b/src/arts/motion/types.ts index 17ab168e3..a415848fd 100644 --- a/src/arts/motion/types.ts +++ b/src/arts/motion/types.ts @@ -130,15 +130,38 @@ export interface 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> + /** 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 * 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 { readonly keyframes?: Readonly> readonly signatures?: Readonly> readonly presets?: Readonly> + readonly coordinated?: Readonly> } export function isCssStatePreset(preset: StatePreset): preset is CssStatePreset { diff --git a/src/uix/eidos/MOTION_SERVICE_RFC.md b/src/uix/eidos/MOTION_SERVICE_RFC.md new file mode 100644 index 000000000..42f29d9cd --- /dev/null +++ b/src/uix/eidos/MOTION_SERVICE_RFC.md @@ -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: +`` por nodo + ``) o escribir timelines imperativas (GSAP). UIX +ya tiene un contrato declarativo cross-layer (**morfo**) y un lifecycle de presencia headless +(**`Presence`**, en soma). La apuesta de este servicio es **proyectar la coreografía sobre +ese contrato**: el componente declara _qué partes son superficies animables y cómo se +coordinan_, soma generaliza `Presence` de una superficie a un **árbol de presencia**, y el +desarrollador solo **nombra** la animación. El sistema sabe **dónde** aplicarla (la superficie +está declarada) y **cuándo** (la coordinación está declarada). + +Tres propiedades que ningún referente reúne, y que NO incluyen "coreografía declarativa" a +secas (Framer ya la tiene): + +- **(a)** la coreografía vive en el **contrato (morfo)**, una vez por componente, no en el + JSX de cada uso; +- **(b)** **enrutado automático a la superficie** — Framer no lo tiene porque allí envuelves + manualmente; +- **(c)** **composición con la firma semántica** (sema): la misma `commit + fulfill` que + dispara sound/haptic/visual coordina también la entrada de los hijos. + +--- + +## 1. El estudio — referentes y dónde topan + +| Framework | Modelo | Coreografía padre↔hijo | Límite estructural | +| ---------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Framer Motion** | `variants` declarativos + `` para exit | ✅ `when: 'beforeChildren'/'afterChildren'`, `staggerChildren`, `delayChildren` | la coreografía vive en el **JSX de la app** (cada call-site la cablea); el target se **envuelve a mano** (``); atado a React | +| **GSAP** | timelines imperativas (`tl.to(...).to(..., '<')`) | ✅ manual, control total | **imperativo**: el guion se escribe a mano; ninguna relación con el contrato del componente | +| **Svelte transitions** | directivas `in:`/`out:`/`transition:`, `animate:flip` | ⚠️ parcial (`flip` por lista; sin `when`/stagger jerárquico) | por-elemento; sin coordinación de árbol con dependencias | +| **AutoAnimate** | `autoAnimate(el)` zero-config sobre cambios de children | ❌ (anima cambios, no coreografía dirigida) | una sola caja; sin secuencia/paralelo declarado | +| **Chakra (Ark)** | `data-state` + `Presence` (un eje) | ❌ | colapsa la animación de presence en un solo eje; sin firma ni árbol | + +**El límite común** —incluso Framer, el más fino— es que la coreografía es **algo que el +consumidor escribe en cada uso** y el target es **algo que el consumidor envuelve**. Nadie la +trata como **propiedad declarada del componente** ni la **compone con la semántica del +evento**. Ahí está el hueco (a)(b)(c) de §0. + +> **Honestidad obligatoria (§1 no sobreafirma):** Framer Motion **ya hace** coreografía +> declarativa padre↔hijo. Nuestro `children: { enter, exit }` (§4) es ~1:1 con su `when`. Reclamar +> "coreografía declarativa" como novedad sería falso y haría perder credibilidad al resto. +> Lo nuevo es **dónde vive** (el contrato, no el call-site), el **enrutado** y la +> **composición con sema**. + +--- + +## 2. No-goals explícitos + +Declarar el alcance evita que un revisor lo pida y que el servicio se desenfoque. + +1. **Layout animations / shared layout / reordenación automática** — **no-goal a propósito.** + El "shared layout" de Framer (un elemento que parece viajar entre dos posiciones de + layout, `layoutId`) NO entra. Matiz técnico: el driver `rect` (FLIP) de `arts/motion` + mide **un solo nodo** (`el.getBoundingClientRect()` antes/después) — sirve para que **una** + superficie absorba su propio salto de layout, **no** para transiciones de elemento + compartido entre dos árboles. El RFC corta esa expectativa de raíz. +2. **Fondos / efectos WebGL** (`web/routes/demos/animations/background/*`: aurora, galaxy, + particles, liquid-image, …) — **fuera del servicio.** No son superficies enter/exit; son + efectos ambientales full-canvas. Pertenecen a otro eje (un art / componentes "ambient" + tipo ``), que se diseñará por separado. +3. **Reinventar GSAP** — el servicio **no** construye timelines imperativas de propósito + general. El JS coordina **presencia** (timing de mount/unmount/await), no compone + tweens arbitrarios. + +Lo que **sí** es goal: los **text-effects** WIP (blur/count/scrambled/…) entran como +candidatos a _variants de contenido_ (§11), porque sí son animación de contenido al entrar. + +--- + +## 3. Dónde está UIX hoy (estado verificado + gap) + +Verificado leyendo el código, no por memoria: + +- **El motor es por-nodo.** `EngineMotion.run(el, phase)` lee el `data-animation-style` de + _ese_ nodo, resuelve el preset y lo corre; `pending(el)` / `cancel(el)` se indexan por + elemento (`Map>`). No conoce árbol, padres ni hijos. + → `src/arts/motion/engine-motion.ts`. +- **`Presence` es una isla por superficie.** Coordina una sola superficie (`opts.ref.current`) + y espera `node.getAnimations()` **sin `{ subtree: true }`**. Un Dialog o un ContextMenu + crean **varios `new Presence` independientes** (overlay, content) que coinciden en el tiempo + por casualidad, no por coordinación. → `src/uix/soma/layers/presence.svelte.ts`. +- **La coreografía se delega a la app.** El propio código de los presets Material shared-axis + lo dice: `// Per-element here; an app triggers both sides together for the "shared" effect.` + → `src/uix/eidos/lib/motion/presets/css.ts`. +- **La selección del variant vive en eidos.** El momento-estado se aplica vía prop + `motion="scale-fade"` → atributo `data-animation-style`. → `eidos-motion.md` (TL;DR). +- **morfo no declara nada de animación.** `MorfoPart` declara `data` / `aria` / `keyboard` / + `states` / `archetype` / `parts`, pero **ningún concepto de "superficie animable"** ni de + coordinación. Lo más cercano es `events[].semantic.sequence` (`pre|coincident|post`), que + ordena el momento-evento vs el momento-estado **del mismo componente**, no entre superficies. + → `src/uix/morfo/types.ts`. + +**El gap, en una frase:** UIX anima superficies sueltas; no hay un árbol de presencia que las +coordine. Este RFC añade ese árbol **en soma** (donde ya vive el lifecycle de presencia), +declarado **en morfo** y vestido **en eidos**. + +--- + +## 4. Superficie animable declarada en morfo (solo estructura) + +Una `MorfoPart` gana un campo opcional. **Solo lleva estructura** — nada visual ni expresivo: + +```ts +// src/uix/morfo/types.ts — añadido a MorfoPart +animation?: { + /** Esta part es una superficie animable: tiene lifecycle de presencia coordinable. */ + surface?: boolean + /** Dependencia de coordinación con las superficies hijas (opt-in: declararlo activa la coreografía). */ + children?: { + /** + * Relación temporal del padre con sus hijos, INDEPENDIENTE por fase. Cada valor: + * - 'before' → el padre actúa ANTES (enter: entra y luego suelta a los hijos; + * exit: se va antes que ellos) + * - 'after' → el padre actúa DESPUÉS (exit: RETIENE su DOM hasta que los hijos + * terminen de salir — §8.1) + * - 'together' → padre e hijos en paralelo (default de la fase ausente) + */ + enter?: 'before' | 'after' | 'together' + exit?: 'before' | 'after' | 'together' + } +} +``` + +**Por qué `enter`/`exit` por separado y no un `when` simétrico.** Un único `when` ata las dos +fases a la misma relación, y el caso más común —un **contenedor que bracketea a sus hijos**— +**no es simétrico**: entra ANTES que ellos (`enter: 'before'`) y sale DESPUÉS (`exit: 'after'`, +reteniendo su DOM hasta que terminen). El prototipo (§13) lo destapó: con un `when` simétrico el +bracket era inexpresable. Separar las fases lo hace declarable sin coste — la fase ausente cae +a `'together'`. + +**Por qué van en morfo y el stagger-ms NO.** `enter`/`exit` tienen **consecuencia de +lifecycle**: `exit: 'after'` obliga a soma a **retener el DOM del padre** hasta que los hijos +terminen su salida (§8.1). Eso es state, lo necesita soma → es estructura → morfo. En cambio el +**valor** del stagger (80 ms), el easing, los keyframes y el orden visual son **expresivos** → +viven en eidos (tokens/recipe), nunca en morfo. La regla 2-de-3 lo confirma: el campo lo +consumen **soma** (lifecycle) y **eidos** (lo viste) — dos capas → justificado en el contrato. + +> **Nota sobre `order`.** El _orden_ del escalonado (forward/reverse) es expresivo y vive en +> eidos. Soma no lo necesita: el escalonado lo pinta el CSS (§7). Lo que soma observa es el +> **orden de registración** (§7.1), que el RFC fija = document order. + +El compilador (`compile.ts`) traduce este campo a un _plan de coordinación_ que el +`PresenceGroup` consume; el validador (`schema.ts`) comprueba que `children` solo aparezca en +parts con `surface: true` y que las partes referenciadas existan. + +--- + +## 5. Variants separables + naming (`animation` ↔ `data-animation-style`) + +**El objetivo de DX:** el dev escribe `animation="scale-fade"` en **cualquier** componente +animable y el sistema lo **enruta** a la superficie declarada (la part con `surface: true`), +sin que el dev sepa qué part es. "El sistema ya sabe dónde." + +Esto convierte un variant en **separable y reutilizable**: `scale-fade` no pertenece al +Dialog; pertenece al catálogo y se aplica a cualquier superficie. Es el momento-estado de +`eidos-motion.md`, elevado de "prop de un wrapper concreto" a "prop transversal enrutado". + +**Decisión-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: `` se nombra una vez en la raíz y el provider lo enruta a las parts `surface: true` +> del morfo COMPILADO (`routeAnimation(kebab)` consulta el set de superficies derivado de +> `compileMorfo`; una part no-superficie —el Trigger— recibe `undefined`, nunca se mis-targetea). +> Los wrappers Panel/Item emiten `data-animation-style` con el valor enrutado. Unit-tested. +> +> **Hallazgo — el enrutado da el QUÉ, no el CUÁNDO.** Los presets BUILT-IN de eidos (`scale-fade`, +> `slide-fade`…) reaccionan a `data-state` (disparan al MONTAR) → NO componen con superficies +> COORDINADAS, donde el cuándo lo gobierna el `data-starting-style`/release del `PresenceGroup`: un +> preset data-state dispararía todo al montar y rompería la cascada. Por eso la demo reacciona a +> `[data-animation-style='X'][data-starting-style]` (el attr da el QUÉ; los attrs del Presence dan el +> CUÁNDO). Reconciliar la LIBRERÍA de presets con superficies coordinadas (un preset/variante que +> reaccione a `data-starting/ending-style`) está **HECHO (M6)**: la librería de eidos gana +> `MotionConfig.coordinated` — presets `cascade-slide`/`-fade`/`-scale` que eidos genera como +> transición (off-state + stagger reversible) sobre los attrs del Presence, no `data-state`. El motor +> nunca los corre (CSS puro). Un coordinado hace `animation="cascade-slide"` sin CSS propio. + +> **Hallazgo gemelo — la FIRMA sema genérica también choca.** El mismo patrón con el otro sistema +> visual de eidos: al disparar `runtime.trigger('open'/'close')`, sema estampa `data-event-*` y eidos +> corre la firma genérica `emerge` (`present-rise`/`dismiss-fade … forwards`, `generated/base.css`) +> ENCIMA de la cascada coordinada. En `Reveal` se vio como "otra animación" al cerrar con el botón +> (que va por `runtime.trigger`) ausente al usar el reversa (que pone `open` directo). **Doctrina: un +> componente de movimiento COORDINADO silencia la señal sema genérica** — sus eventos declaran +> `semantic.channels: []` (el engine corta antes de estampar, `sema/engine.ts`) y `expression: 'none'`: +> su percepción ES la coordinación, no la firma. Generaliza el hueco §5: la cascada coordinada +> (data-starting/ending-style) es un tercer sistema visual que NO compone con los otros dos de eidos +> (presets data-state · firmas data-event) — un coordinado debe optar fuera de ambos. + +--- + +## 6. Routing automático vs choreography opt-in + +Lo que parecía una decisión binaria ("¿coordinación automática u opt-in?") se disuelve +separando **dos cosas distintas**: + +- **Routing a superficie = automático / zero-config.** El dev nunca cablea superficies: + `animation="..."` se enruta solo a la part `surface: true`. Es el corazón del "sin nada más". +- **Coreografía padre↔hijo = opt-in por construcción.** Solo ocurre si la part **declara** + `children` en morfo (§4). Un componente que no lo declara nunca arrastra a sus hijos. + +El beneficio combinado: coherencia con "sin nada más" **sin** el riesgo de que un padre +empiece a **esperar misteriosamente** a una superficie anidada profunda que el autor no quería +coordinar. La coordinación es explícita en el contrato; el routing es implícito en el uso. + +--- + +## 7. Coordinación de presencia — el `PresenceGroup` + +`Presence` hoy gestiona el lifecycle de **una** superficie: monta, marca +`data-starting-style`/`data-ending-style`, espera las animaciones, desmonta. El servicio lo +**generaliza a un árbol** con una clase hermana — **`PresenceGroup`** (el nombre es +deliberado: coordina _presencia_, state; no _motion_, visual): + +```ts +// src/uix/soma/layers/presence-group.svelte.ts (nuevo) +// Presence generalizado de 1 superficie a un árbol. Pure lifecycle/state — NO visual. +class PresenceGroup { + static create(structure: AnimationStructure): PresenceGroup; // root → set in context + static get(): PresenceGroup | undefined; // children discover it + /** A surface (or a nested group) cedes its "when" to this group. */ + register(member: PresenceMember): () => void; // returns deregister + /** Coordinate the tree for a phase: ordered mount/unmount/await + aggregate `finished`. */ + play(phase: 'enter' | 'exit'): Promise; + /** Propagate cancellation to every registered member (interrupt/reverse). */ + cancel(): void; +} +``` + +`Presence` gana una opción `group?` (descubierta por context). Si hay grupo, la superficie +**cede el timing**: en vez de arrancar su `motion.run` por su cuenta en `handleOpen`, se +**registra** y espera la señal del grupo (`release(phase)`). Si no hay grupo, `Presence` +**degrada al comportamiento de hoy** (isla autónoma) — backward-compat total. + +**Qué coordina el grupo y qué NO.** El grupo decide _cuándo_ existe / monta / espera / cancela +cada superficie (state). El **cómo-se-ve** sigue siendo de eidos: el **stagger visual lo pinta +el CSS** (`animation-delay` escalonado por índice, como hoy). Soma **solo monta y espera el +`finished` agregado** — **nunca calcula un milisegundo**. Esta es la línea state/visual hecha +mecanismo: el grupo no conoce el valor del stagger; lo pone el delay CSS, y el grupo solo +agrega los `finished` (que ya incluyen ese delay). + +**Las dos direcciones** (consecuencia de `children.when`): + +``` +play('enter'): + when 'before' → corre la superficie del padre; al (o durante) su animación, libera a los hijos + when 'together' → libera a todos a la vez (el stagger CSS los escalona visualmente) + when 'after' → libera a los hijos; cuando su finished agrega, corre la del padre + +play('exit'): + when 'after' → emite exit a los hijos, RETIENE el DOM del padre, agrega su finished, + y solo entonces corre la salida del padre y libera la retención (§8.1) +``` + +**Modelo de coordinación — DECISIÓN (recomendación: híbrido declarativo).** Tres opciones: + +| Modelo | Cómo | Veredicto | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **Híbrido declarativo** _(recomendado)_ | morfo declara la estructura; soma coordina el timing y agrega `finished`; **el stagger/escalonado lo pinta CSS** (eidos); el motor ejecuta por-nodo. El JS solo coordina presencia. | respeta la línea state/visual; no reinventa GSAP; reusa `Presence` | +| **Variants propagados (Framer)** | el padre propaga su fase a los hijos por context con `staggerChildren`/`when` en el árbol de componentes | mete lógica de coordinación en el árbol de componentes; más cerca de soma que de morfo; el timing visual se filtra a JS | +| **Timeline / grafo explícito** | un orquestador construye una timeline con offsets/dependencias y la ejecuta vía WAAPI groups / scheduler | máximo control; pero maquinaria JS pesada y riesgo de reinventar GSAP / alejarse de lo declarativo | + +El híbrido es el único que mantiene a soma fuera de los valores visuales. + +### 7.1. El árbol de presencia es lógico (context), NO DOM + +La pregunta clave: _¿cómo se gestiona el árbol si entre un padre animable y sus hijos +animables hay componentes sin animación? Los padres tienen "saltos" en el árbol._ + +**Respuesta: el árbol que se coordina es el de superficies animables proyectado sobre el +`context` de Svelte, no el árbol DOM.** Y el context **no depende de adyacencia DOM**, así que +los saltos se resuelven solos: + +- Un ancestro animable pone un `PresenceGroup` en context. +- Los componentes **sin animación** (wrappers de layout, `{#if}`, una `
    ` tonta) son + **transparentes**: no crean grupo ni se registran. El context del grupo **fluye a través de + ellos** sin que lo toquen. +- El siguiente descendiente animable —a la profundidad DOM que sea— hace `PresenceGroup.get()`, + encuentra el grupo del **ancestro animable más cercano** y se **registra**. El padre conoce + a sus hijos animables **por registración, no por proximidad DOM**. +- Un intermedio que **sí** es animable crea su propio grupo y **corta el descenso** (caja + negra, §9) — única frontera del árbol lógico. + +Implicaciones que el resto del RFC hereda: + +1. **El count no sale del DOM** (los descendientes cuelgan a profundidad arbitraria) → §8.2. +2. **El orden del stagger = orden de registración** (= montaje = document order), modulado por + el `order` visual de eidos; **nunca la profundidad DOM**. +3. **La retención en exit aguanta el salto**: retener el **nodo del ancestro** retiene + transitivamente su subárbol entero (wrappers incluidos) → §8.1 no se rompe. +4. **Des-registración abrupta** (caso de borde): un wrapper intermedio con su propio `{#if}` + que se desmonta deja a sus hijos animables sin exit coordinado → el grupo debe tolerar un + **set dinámico** de registros (aparecen/desaparecen) y **no colgar** su promesa por un + miembro que se fue (liga con el timeout anti-deadlock de §8.2). + +--- + +## 8. Salida, interrupción y reversa (el caso difícil) + +La entrada en cascada (`enter` + stagger) es trivial; todo framework la borda. El estrés real +—donde los sistemas mueren— está en la **salida coordinada** (`after`) y en la **interrupción** +(un `enter` a medias que pasa a `exit`). Esta sección combina el lifecycle de soma con el +contrato de morfo para resolverlo. + +### 8.1. Retención del DOM en exit coordinado + +Cuando un padre declara `exit: 'after'`, el nodo padre **no puede desmontarse** hasta que +todos los hijos orquestados terminen su animación de salida. + +**Mecanismo:** el `PresenceGroup` del padre **retiene el bloque en el DOM** (vía el +`shouldRender` de `Presence` / Svelte). Al dispararse `play('exit')`, emite la señal de salida +a sus hijos registrados, **recolecta sus `finished` agregados**, y **solo cuando todas +resuelven** ejecuta su propia animación de salida; al terminar, **libera la retención** y el +nodo se desmonta. El **escalonado visual** de la salida lo pone el CSS (eidos, +`animation-delay`); soma solo **espera** el agregado. Coherente con la línea state/visual: el +grupo nunca decide cuánto se escalona, solo cuándo desmontar. + +### 8.2. Cierre del conjunto de registración (el problema async) + +En render condicional o diferido, el padre **no puede** esperar solo "las superficies que se +hayan registrado en este tick": podría asumir que no hay hijos antes de que terminen de +montar. La **bala de plata es el contrato** — pero la **fuente del count se bifurca** (porque +con saltos, §7.1, el DOM no sirve para contar): + +- **Cardinalidad fija** (parts nombradas: `overlay` + `content`) → el count exacto lo da + **morfo** (sabe cuántas superficies-hijas declaró). El padre **bloquea** hasta que + `registros == count`, con un **timeout de seguridad anti-deadlock** (si un hijo se excluye + lógicamente del render, no se cuelga para siempre). +- **Colecciones** (part `item` repetida N veces — el caso del prototipo §13) → morfo declara + el **tipo** de superficie hija, **no el número** de instancias (el count es runtime). El + count lo aporta el **provider de la colección** (conoce su `items.length`) o un _commit_ + explícito cuando la colección termina de montar. + +> Esta bifurcación es directa consecuencia de §7.1: si el árbol es lógico y hay saltos, +> contar por estructura DOM es imposible; el count tiene que venir de una fuente que lo +> conozca declarativamente (morfo) o de runtime (provider de la colección). + +### 8.3. Interrupción y reversa (máquina de estados) — IMPLEMENTADO (M4) + +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`. diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 79e005991..7834b3ee5 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1731,6 +1731,15 @@ --calendar-transition-duration: var(--duration-fast); --calendar-transition-ease: var(--ease-default); --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-sm: var(--space-1); --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) { :focus-visible { outline: 2px solid Highlight; diff --git a/src/uix/eidos/lib/motion/presets/css.ts b/src/uix/eidos/lib/motion/presets/css.ts index 4bd9c6546..2b7c49d8a 100644 --- a/src/uix/eidos/lib/motion/presets/css.ts +++ b/src/uix/eidos/lib/motion/presets/css.ts @@ -16,7 +16,7 @@ * 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 NEG_D = `calc(${D} * -1)` @@ -376,3 +376,18 @@ export const BUILTIN_CSS_PRESETS: Readonly> = { 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> = { + 'cascade-slide': { off: { opacity: '0', transform: 'translateX(-16px)' } }, + 'cascade-fade': { off: { opacity: '0' } }, + 'cascade-scale': { off: { opacity: '0', transform: 'scale(0.9)' } } +} diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index d43684875..ded174cc7 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -49,6 +49,7 @@ import { toKebab } from './utils' import { STATIC_SCALING } from './primitives/static' import type { CssStatePreset, + CoordinatedPreset, CssPhase, EventSignature, KeyframeName, @@ -669,6 +670,12 @@ function renderMotionBlocks(motion: MotionConfig): string { 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') } @@ -736,6 +743,32 @@ function phaseDeclarations( 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=""` + 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' // kills the animation (snap to the steady `[data-state]` style); 'opacity-only' // keeps a brief fade and drops transforms. Emitted both under the projected diff --git a/src/uix/eidos/lib/themes/base.ts b/src/uix/eidos/lib/themes/base.ts index 6435577d2..d3c027302 100644 --- a/src/uix/eidos/lib/themes/base.ts +++ b/src/uix/eidos/lib/themes/base.ts @@ -13,7 +13,12 @@ import type { } from '../config-types' import { THEME_BASE_RECIPE_TOKENS } from '../recipes/base' 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 = { primary: 'purple', @@ -481,7 +486,8 @@ export const THEME_BASE_OPTIONS: EidosConfig = defineEidosConfig({ motion: { keyframes: BUILTIN_KEYFRAMES, signatures: BUILTIN_SIGNATURES, - presets: BUILTIN_CSS_PRESETS + presets: BUILTIN_CSS_PRESETS, + coordinated: BUILTIN_COORDINATED_PRESETS }, themes: { 'base-light': { diff --git a/src/uix/eidos/motion.test.ts b/src/uix/eidos/motion.test.ts index aa4213836..f12cbe619 100644 --- a/src/uix/eidos/motion.test.ts +++ b/src/uix/eidos/motion.test.ts @@ -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)', () => { expect(css).toContain(`[data-animation-style='shared-axis-x'][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)', () => { - 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( `[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)'); // 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( /\[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.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe(true); + expect(report.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe( + true + ); }); }); diff --git a/src/uix/morfo/compile.test.ts b/src/uix/morfo/compile.test.ts index bb2738701..f5daa5913 100644 --- a/src/uix/morfo/compile.test.ts +++ b/src/uix/morfo/compile.test.ts @@ -436,3 +436,91 @@ describe('compileMorfo — invariants', () => { 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' } + }) + }) +}) diff --git a/src/uix/morfo/compile.ts b/src/uix/morfo/compile.ts index 04d341571..140a49756 100644 --- a/src/uix/morfo/compile.ts +++ b/src/uix/morfo/compile.ts @@ -29,6 +29,7 @@ import type { Morfo, MorfoA11ySemantic, + MorfoAnimation, MorfoAriaEntry, MorfoArchetype, MorfoCondition, @@ -139,6 +140,24 @@ export interface SourceDeps { 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. * @@ -170,6 +189,11 @@ export interface CompiledPart { readonly parentKebab: string | undefined /** Direct child kebabs (one level deep). Empty for leaves. */ 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 ──────────────────────────────────────────────────────────── @@ -472,7 +496,8 @@ function walkParts( needsTranslations }), 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) @@ -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 ────────────────────────────────────────────────── interface DepSink { diff --git a/src/uix/morfo/components/rail.ts b/src/uix/morfo/components/rail.ts new file mode 100644 index 000000000..25885a1b9 --- /dev/null +++ b/src/uix/morfo/components/rail.ts @@ -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; diff --git a/src/uix/morfo/components/reveal.ts b/src/uix/morfo/components/reveal.ts new file mode 100644 index 000000000..da35882e7 --- /dev/null +++ b/src/uix/morfo/components/reveal.ts @@ -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; diff --git a/src/uix/morfo/index.ts b/src/uix/morfo/index.ts index 2d0bf2446..5a0b0f85d 100644 --- a/src/uix/morfo/index.ts +++ b/src/uix/morfo/index.ts @@ -25,6 +25,8 @@ export type { MorfoEventSemantic, MorfoA11ySemantic, MorfoEvent, + MorfoAnimationChildren, + MorfoAnimation, MorfoPart, Morfo } from './types'; @@ -59,6 +61,7 @@ export { type ActionPlan, type CompiledMorfo, type CompiledPart, + type CompiledPartAnimation, type CssSelectorContract, type DataAttrContract, type KeyboardPlan, diff --git a/src/uix/morfo/schema.test.ts b/src/uix/morfo/schema.test.ts index 143ef33a2..c27ab472e 100644 --- a/src/uix/morfo/schema.test.ts +++ b/src/uix/morfo/schema.test.ts @@ -84,3 +84,37 @@ describe('validateMorfo — 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(); + }); +}); diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index d8f9fb573..ce3397727 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -77,6 +77,7 @@ const elementSchema = union( literal('footer'), literal('img'), literal('svg'), + literal('path'), literal('label'), literal('form'), literal('fieldset'), @@ -315,6 +316,21 @@ const focusSchema = object({ // Sium lacks `lazy()`, so we validate a single part without its `parts?` // 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({ name: string(), kebab: string(), @@ -328,6 +344,7 @@ const partShallowSchema = object({ data: array(dataSchema), aria: array(ariaEntrySchema), keyboard: optional(array(keyboardSchema)), + animation: optional(animationSchema), parts: optional(array(object({}, { unknownKeys: 'passthrough' }))) // ^ 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) { const f = morfo.focus; if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) { diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index d15276a43..f4a77f666 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -684,6 +684,64 @@ export const ARCHETYPE_VOCABULARY = [ // ── 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. * @@ -751,6 +809,13 @@ export interface MorfoPart { keyboard?: readonly MorfoKeyboard[]; /** Nested parts (e.g. `Accordion.Item` contains `Header`, `Trigger`, `Content`). */ 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 ───────────────────────────────────────────────── diff --git a/src/uix/soma/components/rail/components/rail-item.svelte b/src/uix/soma/components/rail/components/rail-item.svelte new file mode 100644 index 000000000..cd982e374 --- /dev/null +++ b/src/uix/soma/components/rail/components/rail-item.svelte @@ -0,0 +1,31 @@ + + +{#if state.isPresent} +
    + {@render children?.()} +
    +{/if} diff --git a/src/uix/soma/components/rail/components/rail.svelte b/src/uix/soma/components/rail/components/rail.svelte new file mode 100644 index 000000000..a5962ecea --- /dev/null +++ b/src/uix/soma/components/rail/components/rail.svelte @@ -0,0 +1,57 @@ + + +{#if state.isPresent} + {#if child} + {@render child({ + props: { + ...mergedProps, + ...state.transitionAttrs, + 'data-animation-style': state.animationStyle + } + })} + {:else} +
    + {@render children?.()} +
    + {/if} +{/if} diff --git a/src/uix/soma/components/rail/exports.ts b/src/uix/soma/components/rail/exports.ts new file mode 100644 index 000000000..4345d1605 --- /dev/null +++ b/src/uix/soma/components/rail/exports.ts @@ -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'; diff --git a/src/uix/soma/components/rail/index.ts b/src/uix/soma/components/rail/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/rail/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/rail/rail-provider.svelte.test.ts b/src/uix/soma/components/rail/rail-provider.svelte.test.ts new file mode 100644 index 000000000..e47138124 --- /dev/null +++ b/src/uix/soma/components/rail/rail-provider.svelte.test.ts @@ -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(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) => + 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(railEl), + open: state(true), + animation: state('cascade-slide') + }); + vi.spyOn(RailProvider, 'require').mockReturnValue(provider); + const items = itemEls.map((el) => + RailItemProvider.create({ ref: state(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(); + }); +}); diff --git a/src/uix/soma/components/rail/rail-provider.svelte.ts b/src/uix/soma/components/rail/rail-provider.svelte.ts new file mode 100644 index 000000000..6e1489dbb --- /dev/null +++ b/src/uix/soma/components/rail/rail-provider.svelte.ts @@ -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; + ref: State; + /** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */ + animation: Active; +} + +/** + * 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('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; +} + +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; + } +} diff --git a/src/uix/soma/components/rail/types.ts b/src/uix/soma/components/rail/types.ts new file mode 100644 index 000000000..ef3d56aa6 --- /dev/null +++ b/src/uix/soma/components/rail/types.ts @@ -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; + /** Motion preset name, routed to the declared surfaces (RFC §5). */ + animation?: string; +}> & + Without; + +export type RailItemProps = Without> & { + /** 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 | null; + children?: Snippet; +}; diff --git a/src/uix/soma/components/reveal/components/reveal-item.svelte b/src/uix/soma/components/reveal/components/reveal-item.svelte new file mode 100644 index 000000000..510d3281e --- /dev/null +++ b/src/uix/soma/components/reveal/components/reveal-item.svelte @@ -0,0 +1,32 @@ + + +{#if state.isPresent} +
    + {@render children?.()} +
    +{/if} diff --git a/src/uix/soma/components/reveal/components/reveal-panel.svelte b/src/uix/soma/components/reveal/components/reveal-panel.svelte new file mode 100644 index 000000000..c88a55aea --- /dev/null +++ b/src/uix/soma/components/reveal/components/reveal-panel.svelte @@ -0,0 +1,35 @@ + + +{#if state.isPresent} +
    + {@render children?.()} +
    +{/if} diff --git a/src/uix/soma/components/reveal/components/reveal-trigger.svelte b/src/uix/soma/components/reveal/components/reveal-trigger.svelte new file mode 100644 index 000000000..4b1464a23 --- /dev/null +++ b/src/uix/soma/components/reveal/components/reveal-trigger.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/reveal/components/reveal.svelte b/src/uix/soma/components/reveal/components/reveal.svelte new file mode 100644 index 000000000..bde8bc35c --- /dev/null +++ b/src/uix/soma/components/reveal/components/reveal.svelte @@ -0,0 +1,31 @@ + + +{@render children?.()} diff --git a/src/uix/soma/components/reveal/exports.ts b/src/uix/soma/components/reveal/exports.ts new file mode 100644 index 000000000..d1dfabb60 --- /dev/null +++ b/src/uix/soma/components/reveal/exports.ts @@ -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'; diff --git a/src/uix/soma/components/reveal/index.ts b/src/uix/soma/components/reveal/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/reveal/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/reveal/reveal-provider.svelte.test.ts b/src/uix/soma/components/reveal/reveal-provider.svelte.test.ts new file mode 100644 index 000000000..919c504a9 --- /dev/null +++ b/src/uix/soma/components/reveal/reveal-provider.svelte.test.ts @@ -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(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) => + 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(undefined) + }); + vi.spyOn(RevealProvider, 'require').mockReturnValue(provider); + const panel = RevealPanelProvider.create({ + id: state('reveal-panel'), + ref: state(panelEl) + }); + const items = itemEls.map((el) => + RevealItemProvider.create({ ref: state(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('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(undefined) + }); + vi.spyOn(RevealProvider, 'require').mockReturnValue(provider); + RevealPanelProvider.create({ + id: state('reveal-panel'), + ref: state(panelEl) + }); + const items = itemEls.map((el) => + RevealItemProvider.create({ ref: state(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(undefined) + }); + return { provider }; + }); + + result.provider.toggle(); + await flushRuntimeTrigger(); + expect(open.current).toBe(true); + + result.provider.toggle(); + await flushRuntimeTrigger(); + expect(open.current).toBe(false); + + cleanup(); + }); +}); diff --git a/src/uix/soma/components/reveal/reveal-provider.svelte.ts b/src/uix/soma/components/reveal/reveal-provider.svelte.ts new file mode 100644 index 000000000..ee4ee0daa --- /dev/null +++ b/src/uix/soma/components/reveal/reveal-provider.svelte.ts @@ -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; + /** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */ + animation: Active; +} + +/** + * 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('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; + ref: State; +} + +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; + ref: State; +} + +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; +} + +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; + } +} diff --git a/src/uix/soma/components/reveal/types.ts b/src/uix/soma/components/reveal/types.ts new file mode 100644 index 000000000..2785cbed9 --- /dev/null +++ b/src/uix/soma/components/reveal/types.ts @@ -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; + /** + * 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>; + +export type RevealPanelProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without>; + +export type RevealItemProps = Without> & { + /** 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 | null; + children?: Snippet; +}; diff --git a/src/uix/soma/layers/coordination.ts b/src/uix/soma/layers/coordination.ts new file mode 100644 index 000000000..6a5cf819e --- /dev/null +++ b/src/uix/soma/layers/coordination.ts @@ -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; +} + +// Cached by morfo identity (compileMorfo is itself cached) — the structure is a +// pure function of the contract. +const STRUCTURE_CACHE = new WeakMap(); + +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(); + 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; + /** DX preset name routed to the declared surfaces. Omit for none. */ + readonly animation?: Active; +} + +export class Coordination { + readonly group: PresenceGroup; + private readonly surfaces: ReadonlySet; + private readonly opts: CoordinationOptions; + private readonly animation: Active; + + 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, 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); + } +} diff --git a/src/uix/soma/layers/index.ts b/src/uix/soma/layers/index.ts index f470466cc..c6fdaace4 100644 --- a/src/uix/soma/layers/index.ts +++ b/src/uix/soma/layers/index.ts @@ -1,5 +1,15 @@ // ── Behavior layers (classes — consumed by Providers) ──────────────────────── 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 { Dismissal, type DismissalOpts, type DismissalBehavior } from './dismissal.svelte'; export { TextSelection, type TextSelectionOpts } from './text-selection.svelte'; diff --git a/src/uix/soma/layers/presence-group.test.ts b/src/uix/soma/layers/presence-group.test.ts new file mode 100644 index 000000000..9ca658769 --- /dev/null +++ b/src/uix/soma/layers/presence-group.test.ts @@ -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((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 } +): 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'); + }); +}); diff --git a/src/uix/soma/layers/presence-group.ts b/src/uix/soma/layers/presence-group.ts new file mode 100644 index 000000000..f53338871 --- /dev/null +++ b/src/uix/soma/layers/presence-group.ts @@ -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; + /** + * 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'); + + /** 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(); + + /** + * 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 { + 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 { + 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 { + if (members.length === 0) return Promise.resolve(); + return Promise.all(members.map((m) => m.release(phase))).then(() => undefined); +} diff --git a/src/uix/soma/layers/presence.svelte.test.ts b/src/uix/soma/layers/presence.svelte.test.ts index d9f9439e1..200487a51 100644 --- a/src/uix/soma/layers/presence.svelte.test.ts +++ b/src/uix/soma/layers/presence.svelte.test.ts @@ -107,4 +107,28 @@ describe('soma Presence — motion.run (JS-driver gating)', () => { expect(presence.isPresent).toBe(false); 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(() => {}), 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(); + }); }); diff --git a/src/uix/soma/layers/presence.svelte.ts b/src/uix/soma/layers/presence.svelte.ts index bfff549db..764a80952 100644 --- a/src/uix/soma/layers/presence.svelte.ts +++ b/src/uix/soma/layers/presence.svelte.ts @@ -2,6 +2,7 @@ import { type Active, type ActiveProps } from '$libs/reactive'; import { watch } from 'runed'; import type { ActiveDom } from '$adom'; import type { EngineMotion } from '$motion'; +import type { PresenceGroup, PresenceMember, PresencePhase, PresenceRole } from './presence-group'; // ── 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`. */ 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; @@ -41,7 +56,7 @@ export type TransitionStatus = 'starting' | 'ending' | undefined; * → getAnimations().finished * → shouldRender=false + transitionStatus=undefined → onComplete(false) */ -export class Presence { +export class Presence implements PresenceMember { readonly opts: PresenceOptions; shouldRender = $state(false); @@ -49,11 +64,23 @@ export class Presence { private runId = 0; private frameIds = new Set(); + /** 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) { this.opts = opts; 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( () => opts.open.current, (isOpen) => { @@ -79,6 +106,13 @@ export class Presence { // ── Open ───────────────────────────────────────────────────────────────── 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.shouldRender = true; this.transitionStatus = 'starting'; @@ -101,6 +135,13 @@ export class Presence { // ── Close ──────────────────────────────────────────────────────────────── 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(); 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 { + const runId = this.pendingRunId; + return new Promise((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 ──────────────────────────────────────────────────── /** Start the JS-driven motion for this phase, if a `motion` engine is wired. */ diff --git a/web/routes/temas/animations/+page.svelte b/web/routes/temas/animations/+page.svelte index 171ce0330..d003191c6 100644 --- a/web/routes/temas/animations/+page.svelte +++ b/web/routes/temas/animations/+page.svelte @@ -19,6 +19,7 @@ import { ActiveEidos } from '$uix/eidos' import { rect, spring, waapi } from '$motion' import { Presence } from '$soma/layers/presence.svelte' + import { PresenceGroup } from '$soma/layers/presence-group' import { readableActive } from '$libs/reactive' import { Dialog } from '$uix/eidos/components/dialog' import { Popover } from '$uix/eidos/components/popover' @@ -199,6 +200,39 @@ 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(null) + const pgContainer = new Presence({ + dom: eidos.dom, + open: pgOpenActive, + ref: readableActive(() => pgContainerEl), + group: pgGroup, + groupRole: 'owner' + }) + + const pgItemEls = $state>({}) + 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 // layout, then animate the inverse delta. The WAAPI animation is in // getAnimations(), so it composes with the rest of the system for free. @@ -522,6 +556,54 @@ eidos.motion.enter(el, 'pop', { dom: eidos.dom })`} // panel markup:
    → motion.run runs the spring`} + +
    +
    + soma PresenceGroup · when='after' · coordinación de presencia +

    presence group (coreografía exit-heavy · retención de DOM)

    +

    + El servicio de motion end-to-end (M2 enter + M3 exit). Un contenedor + (owner) y sus ítems (child) coordinados por un + PresenceGroup con when='after'. Al cerrar, los + ítems salen en stagger mientras el contenedor retiene su DOM; + solo cuando su salida agrega, el contenedor se va y entonces se desmonta el árbol (§8.1). El + stagger visual es CSS puro (animation-delay × --i); soma solo secuencia y espera + getAnimations(). (En el preview en segundo plano el rAF puede congelarse; enfoca + el browser para ver la coreografía.) +

    +
    +
    +
    + + contenedor: {pgContainer.isPresent ? 'montado' : 'desmontado'} +
    +
    + {#if pgContainer.isPresent} +
    + {#each PG_ITEMS as i (i)} + {#if pgItems[i].isPresent} +
    + item {i + 1} +
    + {/if} + {/each} +
    + {/if} +
    +
    +
    {`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`}
    +
    +
    @@ -1089,4 +1171,53 @@ eidos.motion.enter(boxEl, 'genie') // ▶ play`} font-weight: 500; 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); + } + } diff --git a/web/routes/temas/animations/presence-group/+page.svelte b/web/routes/temas/animations/presence-group/+page.svelte new file mode 100644 index 000000000..e5149c7f4 --- /dev/null +++ b/web/routes/temas/animations/presence-group/+page.svelte @@ -0,0 +1,244 @@ + + + + PresenceGroup · práctica + + +
    +
    + ← Motion +

    PresenceGroup coordinación de presencia

    +

    + La capa de coordinación del servicio de motion, en aislado. Cada tarjeta es una lista + coordinada —un contenedor owner y sus ítems child— cableada + por el PresenceGroup + Presence reales de soma, uno por modo + when. Los ítems descubren el grupo por context (§7.1); el + stagger es CSS puro; soma solo decide cuándo entra / sale / se desmonta cada + superficie. Fíjate en after: al cerrar, los ítems salen y el contenedor + aguanta hasta que terminan. Y ↻ Reversa reabre a media + salida (§8.3): el token de generación del grupo no desmonta lo que vuelve — + sin parpadeo. +

    +
    + + Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el + rAF se throttlea). +
    +
    + +
    + {#each MODES as m (m.id)} +
    +
    + {m.badge} +
    + + +
    +
    +

    {m.desc}

    +
    + +
    +
    + {/each} +
    +
    + + diff --git a/web/routes/temas/animations/presence-group/coord-item.svelte b/web/routes/temas/animations/presence-group/coord-item.svelte new file mode 100644 index 000000000..c86ab2d8c --- /dev/null +++ b/web/routes/temas/animations/presence-group/coord-item.svelte @@ -0,0 +1,62 @@ + + +{#if presence.isPresent} +
    + ítem {index + 1} +
    +{/if} + + diff --git a/web/routes/temas/animations/presence-group/coord-list.svelte b/web/routes/temas/animations/presence-group/coord-list.svelte new file mode 100644 index 000000000..3d029009c --- /dev/null +++ b/web/routes/temas/animations/presence-group/coord-list.svelte @@ -0,0 +1,75 @@ + + +{#if container.isPresent} +
    + {#each items as i (i)} + + {/each} +
    +{/if} + + diff --git a/web/routes/temas/animations/rail/+page.svelte b/web/routes/temas/animations/rail/+page.svelte new file mode 100644 index 000000000..106cf4650 --- /dev/null +++ b/web/routes/temas/animations/rail/+page.svelte @@ -0,0 +1,252 @@ + + + + Rail · 2º consumidor coordinado + + +
    +
    + ← Motion +

    Rail 2º consumidor del helper coordinado

    +

    + Misma coordinación que Reveal, otra forma: la raíz + ES la superficie owner, la relación es together (todo en + paralelo, el escalonado lo pone el CSS), es controlada por el padre + (bind:open, sin trigger) y no tiene eventos sema. Todo el wiring + —PresenceGroup + routing + auto-stagger— sale del helper compartido + soma/layers/coordination.ts; construir este componente fueron cuatro líneas. +

    +
    + +
    + {#each STYLES as s (s.value)} + + {/each} +
    +
    +
    + + +
    +
    + +
    + + {#each ITEMS as label (label)} + {label} + {/each} + +
    +
    + + diff --git a/web/routes/temas/animations/reveal/+page.svelte b/web/routes/temas/animations/reveal/+page.svelte new file mode 100644 index 000000000..cd09af698 --- /dev/null +++ b/web/routes/temas/animations/reveal/+page.svelte @@ -0,0 +1,334 @@ + + + + Reveal · primer consumidor real + + +
    +
    + ← Motion +

    Reveal primer consumidor real

    +

    + A diferencia del demo presence-group —que + construye Presence + PresenceGroup a mano— esto usa el componente + real <Reveal>: su morfo declara + panel.animation.children = { enter: 'before', exit: 'after' }, y el + provider lee ese contrato compilado para construir el grupo (RFC §4). El + panel entra, luego los ítems entran en cascada; al cerrar los ítems salen + primero mientras el panel retiene su DOM (exit-heavy §8.1), y solo después se + va el panel. El escalonado es CSS puro; soma solo decide el cuándo. El + selector cambia el prop animation, que el componente + enruta a las superficies declaradas + (Panel + Items) como data-animation-style — se nombra una vez, el sistema sabe dónde + (RFC §5). +

    +
    +
    + {#each STYLES as s (s.value)} + + {/each} +
    + + Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el + rAF se throttlea). +
    +
    + + +
    +
    + + +
    + + + {open ? '▾' : '▸'} Menú de cuenta + + + {#each ITEMS as label (label)} + {label} + {/each} + + +
    +
    + +