diff --git a/src/arts/motion/drivers.ts b/src/arts/motion/drivers.ts index 5de9df154..5172d3680 100644 --- a/src/arts/motion/drivers.ts +++ b/src/arts/motion/drivers.ts @@ -14,7 +14,7 @@ * - `rect` is FLIP: measures first/last rects and animates the inverse delta. */ -import type { MotionHandle, MotionRun } from './types' +import type { MotionHandle, MotionRun, MotionState } from './types' export interface SpringPhysics { /** Higher = snappier pull toward the target. @default 170 */ @@ -53,13 +53,19 @@ export function spring(config: SpringConfig): MotionRun { const restSpeed = config.restSpeed ?? 0.01 return (ctx): MotionHandle => { - const tracks = Object.entries(config.values).map(([prop, [from, to]]) => ({ - prop, - to, - x: from, - v: 0, - unit: config.unit?.[prop] ?? '' - })) + // A reversal hands off the prior run's physics state (§8.3): start each + // property from `ctx.handoff[prop]` (current position + velocity) instead of + // the declared `from`, so the reversed spring continues from where it was. + const tracks = Object.entries(config.values).map(([prop, [from, to]]) => { + const h = ctx.handoff?.[prop] + return { + prop, + to, + x: h?.x ?? from, + v: h?.v ?? 0, + unit: config.unit?.[prop] ?? '' + } + }) let resolve!: () => void const finished = new Promise((r) => (resolve = r)) @@ -105,11 +111,16 @@ export function spring(config: SpringConfig): MotionRun { frame = ctx.dom.requestFrame(step, ctx.el) } - tracks.forEach(write) // initial paint at `from` + tracks.forEach(write) // initial paint at `from` (or the handoff position) ctx.signal.addEventListener('abort', settle, { once: true }) frame = ctx.dom.requestFrame(step, ctx.el) - return { finished, cancel: settle } + // `peek` snapshots the live physics so the engine can hand it to a reversal + // run (§8.3) — the per-property current position + velocity. + const peek = (): MotionState => + Object.fromEntries(tracks.map((t) => [t.prop, { x: t.x, v: t.v }])) + + return { finished, cancel: settle, peek } } } diff --git a/src/arts/motion/engine-motion.test.ts b/src/arts/motion/engine-motion.test.ts index 6e3bb9c8b..15042c02f 100644 --- a/src/arts/motion/engine-motion.test.ts +++ b/src/arts/motion/engine-motion.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { createEngineMotion } from './engine-motion'; -import type { MotionDom, MotionHandle, StatePreset } from './types'; +import { spring } from './drivers'; +import type { MotionContext, MotionDom, MotionHandle, MotionState, 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` @@ -77,3 +78,115 @@ describe('EngineMotion — JS handle tracking + cancel semantics', () => { expect(cancelled).toBe(true); }); }); + +describe('EngineMotion — velocity-preserving handoff on reversal (§8.3)', () => { + it('captures a cancelled run physics and injects it into the next run as ctx.handoff', () => { + let captured: MotionState | undefined; + const preset: StatePreset = { + driver: 'spring', + enter: (): MotionHandle => ({ + finished: new Promise(() => {}), + cancel() {}, + peek: () => ({ '--ty': { x: 5, v: -120 } }) + }), + exit: (ctx): MotionHandle => { + captured = ctx.handoff; + return { finished: Promise.resolve(), cancel() {} }; + } + }; + const motion = createEngineMotion({ dom, presets: { p: preset } }); + const node = el('p'); + + motion.run(node, 'enter'); // enter in flight (peek = {x:5, v:-120}) + motion.cancel(node); // captures the peek → handoff + motion.run(node, 'exit'); // consumes it as ctx.handoff + expect(captured).toEqual({ '--ty': { x: 5, v: -120 } }); + }); + + it('records no handoff when the cancelled driver exposes no peek (WAAPI/rect)', () => { + let captured: MotionState | undefined = { sentinel: { x: 1, v: 1 } }; + const preset: StatePreset = { + driver: 'waapi', + enter: (): MotionHandle => ({ finished: new Promise(() => {}), cancel() {} }), + exit: (ctx): MotionHandle => { + captured = ctx.handoff; + return { finished: Promise.resolve(), cancel() {} }; + } + }; + const motion = createEngineMotion({ dom, presets: { p: preset } }); + const node = el('p'); + motion.run(node, 'enter'); + motion.cancel(node); + motion.run(node, 'exit'); + expect(captured).toBeUndefined(); + }); + + it('hands off from an in-flight run even without an explicit cancel (island path)', () => { + let captured: MotionState | undefined; + let enterCancelled = false; + const preset: StatePreset = { + driver: 'spring', + enter: (): MotionHandle => ({ + finished: new Promise(() => {}), + cancel() { + enterCancelled = true; + }, + peek: () => ({ '--ty': { x: 9, v: 200 } }) + }), + exit: (ctx): MotionHandle => { + captured = ctx.handoff; + return { finished: Promise.resolve(), cancel() {} }; + } + }; + const motion = createEngineMotion({ dom, presets: { p: preset } }); + const node = el('p'); + motion.run(node, 'enter'); // enter in flight, NO explicit cancel + motion.run(node, 'exit'); // run must cancel the enter AND hand off its state + expect(enterCancelled).toBe(true); + expect(captured).toEqual({ '--ty': { x: 9, v: 200 } }); + }); +}); + +describe('spring driver — handoff + peek (§8.3)', () => { + const makeDom = () => { + const frames: FrameRequestCallback[] = []; + const fdom: MotionDom = { + requestFrame: (cb) => (frames.push(cb), frames.length), + cancelFrame: () => {}, + prefersReducedMotion: { matches: false } + }; + return { fdom, tick: () => frames.shift()?.(0) }; + }; + const makeCtx = (fdom: MotionDom, handoff?: MotionState): MotionContext => + ({ + el: { style: { setProperty() {} } }, + dom: fdom, + signal: new AbortController().signal, + handoff + }) as unknown as MotionContext; + + it('starts from the handoff position/velocity instead of the declared from', () => { + const { fdom } = makeDom(); + const handle = spring({ values: { '--ty': [0, 100] }, unit: { '--ty': 'px' } })( + makeCtx(fdom, { '--ty': { x: 7, v: 50 } }) + ) as MotionHandle; + expect(handle.peek?.()).toEqual({ '--ty': { x: 7, v: 50 } }); + }); + + it('without a handoff, starts from the declared from with zero velocity', () => { + const { fdom } = makeDom(); + const handle = spring({ values: { '--ty': [3, 100] } })(makeCtx(fdom)) as MotionHandle; + expect(handle.peek?.()).toEqual({ '--ty': { x: 3, v: 0 } }); + }); + + it('peek reflects the live state as the spring steps toward the target', () => { + const { fdom, tick } = makeDom(); + const handle = spring({ values: { '--ty': [0, 100] } })( + makeCtx(fdom, { '--ty': { x: 0, v: 0 } }) + ) as MotionHandle; + tick(); // one integration step + const s = handle.peek?.()?.['--ty']; + expect(s?.x).toBeGreaterThan(0); // moved toward 100 + expect(s?.v).toBeGreaterThan(0); // gained velocity + }); +}); diff --git a/src/arts/motion/engine-motion.ts b/src/arts/motion/engine-motion.ts index ce5ee6055..d35c7b80e 100644 --- a/src/arts/motion/engine-motion.ts +++ b/src/arts/motion/engine-motion.ts @@ -20,6 +20,7 @@ import type { MotionDom, MotionHandle, MotionSide, + MotionState, StatePreset } from './types' import { isCssStatePreset } from './types' @@ -84,6 +85,11 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion // Active JS-driven handles, per element. A plain Map (not WeakMap) so dispose // can iterate; emptied sets are deleted so it doesn't leak detached elements. const active = new Map>() + // Physics state captured when a tracked run is cancelled, keyed by element, for a + // velocity-preserving handoff on reversal (§8.3): `cancel(el)` snapshots it, the + // NEXT `run(el, …)` consumes it as `ctx.handoff`. Only set when a cancel finds an + // in-flight handle that exposes `peek()` (i.e. a real interruption). + const handoffState = new Map() let disposed = false function track(el: HTMLElement, handle: MotionHandle): void { @@ -98,6 +104,22 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion }) } + // Take the physics state for a reversal handoff (§8.3): merge any state a preceding + // explicit `cancel(el)` recorded (the grouped/reversa path) with the live state of a + // run STILL in flight on this element (the island path — e.g. Dialog spring — which + // does not pre-cancel). Cancelling that in-flight run here also stops a second + // animation from stacking on the node. + function takeHandoff(el: HTMLElement): MotionState | undefined { + const pending = handoffState.get(el) + handoffState.delete(el) + const set = active.get(el) + if (!set || set.size === 0) return pending + const captured = captureHandoff(set) + for (const handle of [...set]) handle.cancel() + active.delete(el) + return captured ? { ...pending, ...captured } : pending + } + function runJs( el: HTMLElement, preset: JsStatePreset, @@ -116,6 +138,11 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion if (!motionRun || !dom) return SETTLED_HANDLE const controller = new AbortController() + // Velocity-preserving handoff (§8.3): take any physics state to continue from — + // from a preceding explicit cancel() (the grouped/reversa path) AND/OR from a run + // still in flight on this element (the island path, which does not pre-cancel). + // Cancelling the in-flight run here also prevents a second animation stacking. + const handoff = takeHandoff(el) const ctx: MotionContext = { moment: 'state', phase, @@ -127,6 +154,7 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion side: opts?.side, sourceRect: opts?.sourceRect, targetRect: opts?.targetRect, + handoff, signal: controller.signal } const handle = toHandle(motionRun(ctx), controller) @@ -173,6 +201,11 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion cancel(el) { const set = active.get(el) if (!set) return + // Snapshot the in-flight physics BEFORE cancelling, so the next run on this + // element can continue from the current position/velocity (§8.3). Only + // physics drivers expose `peek()`; if none do, no handoff is recorded. + const captured = captureHandoff(set) + if (captured) handoffState.set(el, captured) for (const handle of [...set]) handle.cancel() active.delete(el) }, @@ -188,11 +221,29 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion for (const handle of [...set]) handle.cancel() } active.clear() + handoffState.clear() presets.clear() } } } +/** + * Merge the live physics (`peek()`) of every handle that exposes it, for a + * velocity-preserving handoff on reversal (§8.3). Returns `undefined` when no + * tracked handle is a physics driver (WAAPI / rect expose no state) — the engine + * then lets the next run restart cleanly from the preset's declared `from`. + */ +function captureHandoff(set: Set): MotionState | undefined { + let merged: Record | undefined + for (const handle of set) { + const state = handle.peek?.() + if (!state) continue + merged ??= {} + Object.assign(merged, state) + } + return merged +} + /** * Normalise a `MotionRun` result to a single `MotionHandle`. An `Animation` * (or `Animation[]`) and a `MotionHandle` both expose `finished` + `cancel`; @@ -222,7 +273,11 @@ function toHandle( } } } - const single = result as { finished: Promise; cancel: () => void } + const single = result as { + finished: Promise + cancel: () => void + peek?: () => MotionState | undefined + } return { finished: Promise.resolve(single.finished).then( () => {}, @@ -231,7 +286,10 @@ function toHandle( cancel() { controller.abort() safeCancel(single) - } + }, + // Forward the driver's `peek` (only `spring` has one) so the engine can capture + // its state on cancel for a reversal handoff (§8.3). + peek: single.peek ? () => single.peek!() : undefined } } diff --git a/src/arts/motion/index.ts b/src/arts/motion/index.ts index ee5f4119a..df78bcc76 100644 --- a/src/arts/motion/index.ts +++ b/src/arts/motion/index.ts @@ -10,6 +10,7 @@ export type { SpringConfig, SpringPhysics } from './drivers' export { isCssStatePreset } from './types' export type { CoordinatedPreset, + CoordinatedPresetName, CssPhase, CssStatePreset, EventSignature, @@ -18,12 +19,14 @@ export type { KeyframeName, KeyframeStops, MotionConfig, + MotionCoordinatedPresets, MotionContext, MotionDom, MotionHandle, MotionPresetName, MotionRun, MotionSide, + MotionState, ReducePolicy, StatePreset } from './types' diff --git a/src/arts/motion/types.ts b/src/arts/motion/types.ts index a415848fd..a459bc3f9 100644 --- a/src/arts/motion/types.ts +++ b/src/arts/motion/types.ts @@ -97,6 +97,14 @@ export interface CssStatePreset { readonly reduce?: ReducePolicy } +/** + * Per-property physics state captured from a running driver, for a velocity- + * preserving handoff on reversal (RFC: eidos/MOTION_SERVICE_RFC.md §8.3). `x` is + * the current interpolated position, `v` the current velocity. Only physics + * drivers (`spring`) produce it; WAAPI / rect do not (they expose no velocity). + */ +export type MotionState = Readonly> + export interface MotionContext { readonly moment: 'event' | 'state' readonly phase: 'enter' | 'exit' @@ -108,12 +116,27 @@ export interface MotionContext { readonly side?: MotionSide readonly sourceRect?: DOMRect readonly targetRect?: DOMRect + /** + * Physics state handed off from a just-cancelled run on the SAME element (a + * reversal — §8.3). A driver that supports it starts each property from + * `handoff[prop]` (position + velocity) instead of its declared `from`, so the + * reversal continues from the current position/velocity rather than snapping. + * The engine captures it (via the cancelled handle's `peek()`) and injects it + * into the next run on that element. + */ + readonly handoff?: MotionState readonly signal: AbortSignal } export interface MotionHandle { readonly finished: Promise cancel(): void + /** + * Snapshot the driver's current physics state for a velocity-preserving handoff + * (§8.3). Optional: only `spring` implements it; WAAPI / rect omit it (the engine + * then falls back to a clean restart from the preset's declared `from`). + */ + peek?(): MotionState | undefined } export type MotionRun = (ctx: MotionContext) => Animation | readonly Animation[] | MotionHandle @@ -151,6 +174,35 @@ export interface CoordinatedPreset { readonly ease?: string } +/** + * Type-safe, eidos/app-extensible registry of COORDINATED preset names — the value + * of a component's `animation` prop (RFC: eidos/MOTION_SERVICE_RFC.md §5, "separadas + * por rol"). It is the counterpart of eidos's `EidosMotionPresets` (which types the + * `motion` prop), but it lives HERE in `$motion` — the shared layer — so that SOMA + * components (Rail / Reveal) can type their `animation` prop WITHOUT importing eidos + * (a lower layer cannot depend on a higher one). Eidos POPULATES it with its + * built-ins (`cascade-*`) via declaration merging: + * + * declare module '$motion' { + * interface MotionCoordinatedPresets { + * 'cascade-slide': true + * } + * } + * + * The interface starts EMPTY: the names are eidos's data, not the engine's. Same + * boundary as `EidosMotionPresets` — the engine stays open at runtime; this is a + * COMPILE-TIME ergonomic for the `animation` prop only. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface MotionCoordinatedPresets {} + +/** + * The `animation` prop type — the COORDINATED counterpart of `MotionPresetName`. + * Known coordinated presets (eidos/app-merged) autocomplete; an arbitrary string is + * still accepted. Omit the prop (`undefined`) for no preset. + */ +export type CoordinatedPresetName = keyof MotionCoordinatedPresets | (string & {}) + /** * The motion config: keyframes + the two animation surfaces. Eidos generates CSS * from `keyframes` + `signatures` + the css `presets`; the engine resolves + diff --git a/src/uix/eidos/MOTION_SERVICE_RFC.md b/src/uix/eidos/MOTION_SERVICE_RFC.md index 42f29d9cd..5ef4ffd6a 100644 --- a/src/uix/eidos/MOTION_SERVICE_RFC.md +++ b/src/uix/eidos/MOTION_SERVICE_RFC.md @@ -47,6 +47,7 @@ - [14. Impacto sobre el código actual + migración](#14-impacto-sobre-el-código-actual--migración) - [15. Roadmap de implementación por fases](#15-roadmap-de-implementación-por-fases) - [Apéndice A — decisiones resueltas vs abiertas](#apéndice-a--decisiones-resueltas-vs-abiertas) +- [Apéndice B — Convivencia con la firma sema (los tres sistemas visuales)](#apéndice-b--convivencia-con-la-firma-sema-los-tres-sistemas-visuales) --- @@ -211,19 +212,26 @@ Esto convierte un variant en **separable y reutilizable**: `scale-fade` no perte 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. +**Decisión M5 (TOMADA): `animation` y `motion` quedan SEPARADAS por rol.** +La versión original de este RFC recomendaba aquí _unificar_ (`animation` como cara DX y `motion` +como alias deprecable), asumiendo que ambas nombraban el MISMO eje (el momento-estado elevado a +transversal). **El hallazgo M6 lo invalidó** (el «hallazgo gemelo» de más abajo + Apéndice B): +`motion` y `animation` gobiernan sistemas visuales **distintos** — un state-preset (`data-state`, +dispara al MONTAR) metido en un coordinado ROMPE la cascada. Es **distinción, no duplicación**; y +lo "caro" era la duplicación, no la distinción. Se mantienen separadas porque el **nombre de la +prop comunica el sistema** a nivel de propiedad, sin leer docs: + +| Prop | Sistema | Reacciona a | Capa dueña | Catálogo type-safe | +| --------------- | ----------------- | ------------------------------ | ---------- | ---------------------------------------- | +| **`motion`** | momento `--state` | `[data-state]` | eidos | `EidosMotionPresets` (`scale-fade`…) | +| **`animation`** | coordinado | `[data-starting/ending-style]` | soma | `MotionCoordinatedPresets` (`cascade-*`) | + +Ambas emiten `data-animation-style` (el contrato de bajo nivel del motor, que **se conserva**); lo +que difiere es A QUÉ reacciona el CSS y QUIÉN la tipa. El **type-safety de `animation`** vive en un +registry augmentable `MotionCoordinatedPresets` en **`$motion`** (la capa compartida, para que soma +lo tipe sin importar eidos — una capa inferior no depende de una superior); **eidos lo puebla** con +sus `cascade-*` por declaration-merging — espejo exacto de `EidosMotionPresets`. Esto **cierra** la +línea abierta del Apéndice A ("destino de `motion`": se queda, con rol propio). > **Estado — incremento M5 (hecho, sobre `Reveal`).** El enrutado existe: `` se nombra una vez en la raíz y el provider lo enruta a las parts `surface: true` @@ -401,7 +409,7 @@ con saltos, §7.1, el DOM no sirve para contar): > 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) +### 8.3. Interrupción y reversa (máquina de estados) — IMPLEMENTADO (M4 + M6 handoff JS) Un usuario abre y cierra un Dialog rápido. El sistema debe transicionar de un `enter` incompleto a un `exit` (o de vuelta) sin saltos ni huérfanos. @@ -425,24 +433,32 @@ incompleto a un `exit` (o de vuelta) sin saltos ni huérfanos. 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. +**Reversa fluida — el estado por sustrato:** + +- **CSS transiciones (los coordinados `cascade-*`):** fluida **sin esfuerzo**. Una `transition` + interpola desde el valor computado actual al invertir el target → el prototipo + `/temas/animations/presence-group` y los presets coordinados (que usan `transition`, NO + `@keyframes`) revierten desde la posición actual gratis (el "caso (1)" de abajo, implícito). Es el + sustrato del servicio de coordinación → **su reversa ya es fluida**. +- **JS (`spring`) — HECHO (M6):** el motor ahora **expone el estado del driver** + (`MotionHandle.peek()` → posición + velocidad por propiedad, sólo física) y **lo reinyecta** en la + fase inversa vía `MotionContext.handoff`: el `spring` arranca cada propiedad desde el + `handoff[prop]` (x, v) en vez del `from` declarado, así la reversa **continúa desde la + posición/velocidad actual** sin saltar ni reiniciar el overshoot. El _handoff_ vive en el engine + (`cancel` captura el `peek`; `run` lo consume vía `takeHandoff`, que **además** cancela un run en + vuelo si el caller no pre-canceló — cubre el camino _island_ de un overlay-spring y elimina el + apilamiento). `waapi`/`rect` no exponen velocidad → reinicio limpio (sin `peek`). Tests en + `engine-motion.test.ts`. +- **CSS `@keyframes` — pendiente (decisión por preset):** un `@keyframes` de salida **salta** a su + `from` al invertir. "Sin saltos" exige una de tres políticas: (1) keyframes interrumpibles + (`from` ≈ reposo); (2) leer el computed value y reinyectarlo como `from` vía WAAPI; (3) aceptar el + salto donde sea imperceptible (fades). Aplica a los state-presets que usan `@keyframes` + (`scale-fade`…), NO a los coordinados (transiciones). Sin consumidor que lo fuerce hoy. + +Rigor sostenido: M4 garantiza la **corrección** (sin unmount espurio, sin animaciones apiladas, sin +rejection filtrado); M6 añade la **continuidad fina del path JS** (handoff con velocidad). El único +cabo es la política de `@keyframes`, acotada a los state-presets y diferida hasta que un preset la +necesite. --- @@ -597,14 +613,16 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni 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` → +- ✅ **M5 — DX `animation` enrutado + naming (hecho).** Prop transversal `animation` → enrutado a las superficies `surface: true` del morfo compilado (`routeAnimation`), emitido como `data-animation-style` por los wrappers de superficie. **Generalizado**: el wiring (PresenceGroup + routing + auto-stagger) se extrajo al helper `soma/layers/coordination.ts` (`Coordination` + `CoordinatedSurface`), validado por DOS consumidores — `Reveal` (raíz virtual + Panel owner, bracket) y `Rail` (raíz=owner, `together`, sin eventos). Un componente coordinado nuevo es ahora un - puñado de líneas. Pendiente: el alias de `motion`/estratificación formal (§5). El gating - data-state↔data-starting-style va a M6. + puñado de líneas. **Naming resuelto (§5): `animation` y `motion` SEPARADAS por rol** — no se + unifican (tras M6 son sistemas distintos); `animation` gana type-safety vía el registry + augmentable `MotionCoordinatedPresets` en `$motion` (eidos lo puebla con `cascade-*`), espejo de + `EidosMotionPresets`. El gating data-state↔data-starting-style se resolvió en M6. - ◐ **M6 — Stagger expresivo + presets coordinados (hechos) + reversa interrumpible (pendiente).** (1) Stagger: tokens canónicos `--motion-stagger-index` (por ítem, **auto-derivado del orden de registro del grupo** vía `PresenceGroup.childIndex`) × `--motion-stagger-each` (ritmo, heredado). @@ -612,8 +630,13 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni 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). + en `eidos/motion.test.ts`. La demo `/temas/animations/compuesto` consume ya los presets de la librería + en un compuesto real (un `Rail` de ` + +``` + +- **entrada del item** → `cascade-slide`, eje `[data-starting-style]`, tokens `--motion-cascade-*` _(coordinado)_ +- **press del button** → `press-squeeze`, eje `[data-event-*]`, tokens `--duration-fast`/`--ease-default` _(firma)_ + +Son **dos nodos** (el `Rail.Item` que se anima vs el ` +
+ {#each STYLES as s (s.value)} + + {/each} +
+ +
+ + +
+ + Para la cascada fluida, abre esta página enfocada en el browser (en preview de fondo el rAF se + throttlea). La animación NO está aquí — sale de la foundation generada. + + + + +
+ + {#each ITEMS as item (item.label)} + + + + {/each} + +
+ +
{`   
+  {#each items as item}
+    
+      
+    
+  {/each}
+`}
+ + +