From 9e4187bc3c55354eb953ae74e9edca10e986b894 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 21 Jun 2026 14:36:31 +0200 Subject: [PATCH] feat(motion): loops + state-domain + staggered-exit + debug-stagger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the content domain of the universal `motion` prop (RFC §D.12/§D.13): - State domain: a dedicated `data-motion-state` attribute lets a component with its OWN data-state machine (Card selected/idle) drive a preset on its transition without clobbering its semantic `data-state`. Enter-only (animate-on, snap-off). New emphasis preset `select-pop` (no opacity-from-0, for still-visible elements). - Loops (content domain, infinite): Tailwind's canonical `spin`/`pulse`/`ping`/ `bounce`, emitted as un-gated `[data-animation-style='X'] { animation: … infinite }` rules. Themeable per loop via `--motion-loop-{name}`; reduced motion stops them. - Staggered-exit bridge: the container-driven cascade now covers exit too — `[data-stagger][data-state='closed'] > …` cascades children OUT in reverse (`--motion-stagger-index-rev`, last leaves first). The container is retained for the stagger window then unmounts (pragmatic CSS bridge for bounded lists; the parent-waits-for-children version still needs the retired PresenceGroup). - `[data-debug-stagger]`: opt-in dev affordance — a CSS counter badge per child showing its `:nth-child - 1` index (the analog of UIX_DEBUG_MOTION). - Fix `` exit: a `{ duration }`-only Svelte transition never completes (the node sticks at the exit frame, a re-show reuses the dead node); add a `tick` no-op so it times the unmount. Generated base.css regenerated; motion.test 27/27; docs (eidos-motion + RFC) updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/MOTION_SERVICE_RFC.md | 80 ++++-- src/uix/eidos/components/motion/motion.svelte | 5 +- src/uix/eidos/eidos-motion.md | 24 +- src/uix/eidos/generated/base.css | 240 +++++++++++++++--- src/uix/eidos/lib/motion/presets/css.ts | 53 ++++ src/uix/eidos/lib/motion/registry.ts | 9 + src/uix/eidos/lib/render-css.ts | 94 ++++++- src/uix/eidos/motion.test.ts | 69 ++++- 8 files changed, 501 insertions(+), 73 deletions(-) diff --git a/src/uix/eidos/MOTION_SERVICE_RFC.md b/src/uix/eidos/MOTION_SERVICE_RFC.md index 7d8706902..6238213c5 100644 --- a/src/uix/eidos/MOTION_SERVICE_RFC.md +++ b/src/uix/eidos/MOTION_SERVICE_RFC.md @@ -1488,10 +1488,13 @@ Nadie más traza esta línea —evento-acoplado-a-percepción vs contenido-decor La universalidad de (a)+(b) NO diluye el acoplamiento metiendo los componentes-sin-evento en el mismo saco: los pone en el dominio **contenido**, ya previsto y legítimo. El prop es uno; los dominios, tres; la frontera, nuestra. -**El asterisco (no omitido).** Esto es superioridad **conceptual** (el encuadre). En el eje **motor** (físicas -`spring`, reversa interrumpible con handoff de velocidad), Framer demuestra más HOY porque el motor JS de UIX -existe pero está **dormido** (todos los presets shipped son `driver: 'css'`). El "por delante" también en ese -eje exige **estrenar el driver `spring`** con un primer preset JS — trabajo aparte de (a)+(b). +**El asterisco (parcialmente cerrado).** Esto es superioridad **conceptual** (el encuadre). En el eje **motor** +(físicas `spring`, reversa interrumpible con handoff de velocidad), el motor JS de UIX ya **no está dormido**: +el driver `spring` se **estrenó** con el primer built-in JS por nombre — `spring-pop` (`BUILTIN_JS_PRESETS`, +registrado directo en `ActiveEidos` porque sus funciones `MotionRun` no caben en el config serializable), +demostrado en vivo sobre `Popover.Content motion="spring-pop"` (commits `4878ccfb` + `b2b1f4ff`). Lo que SIGUE +pendiente en este eje: la **reversa interrumpible de `@keyframes`** CSS — el handoff de velocidad JS ya existe en +el motor (`MotionContext.handoff`), falta la política por-preset; sin consumidor que la fuerce hoy. **Plan de implementación (a)+(b) — acotado a eidos, NO el `PresenceGroup` pesado.** - **(a) Unificar el nombre — ✅ HECHO** (commit `bfac34dd`). `` `animation` → `motion` (prop + @@ -1510,13 +1513,35 @@ eje exige **estrenar el driver `spring`** con un primer preset JS — trabajo ap - **Paso 2 — ✅ HECHO.** Cableado el prop `motion` en un componente real (piloto ``, commit `0a6d112c`) + el wrapper `` (enter on-mount + exit on-unmount con retención `out:` estilo ``, + su morfo `scope:['eidos']`, commit `54268717`). Dos vías para el diseñador: el prop en - un componente, o el wrapper para contenido crudo (in/out). Loops (contenido infinito) = variante posterior. + un componente, o el wrapper para contenido crudo (in/out). + - **Loops (contenido infinito) — ✅ HECHO (2026-06-21).** Set canónico de Tailwind: `spin` · `pulse` · `ping` + · `bounce` (`BUILTIN_LOOP_PRESETS` en `presets/css.ts`). A diferencia de los state-presets enter/exit, el + generador (`render-css > renderMotionBlocks`) emite la regla **un-gated** `[data-animation-style='X'] { + animation: … infinite }` (sin `data-state`): la animación corre sin parar mientras el elemento esté presente. + Duración themeable por loop vía `--motion-loop-{name}`; reduced-motion las detiene (el frame estático queda). + Se aplican con `motion="X"` / `{...motionAttrs('X')}` sobre cualquier elemento. **Fix de exit encontrado al + construir el demo:** el `out:` de `` devolvía solo `{ duration }`, que en Svelte NO completa la + transición → el nodo se quedaba en el frame de salida (invisible) y un re-show reusaba el nodo muerto. + Arreglado con un `tick: () => {}` no-op (cronometra el desmontaje). Demo en `web/routes/demos/motion` + (loops + el ciclo enter→loop→exit sobre la paloma SVG de `/demos`). Gen-test en `motion.test.ts`. - **Hallazgo — el helper solo sirve en componentes SIN `data-state` propio.** `motionAttrs`/`` fuerzan `data-state="open"` → clobbearían la máquina de estado de un componente que tenga la suya (Card `selected`/`idle`, checkbox `checked`). La universalidad se parte: **contenido** (sin estado) = el helper; **estado** (máquina propia) = OTRO camino — su `motion` ataría a SUS transiciones, pero los presets keyean en - `open`/`closed` específicamente, así que un `selected`/`idle` ni dispararía el preset. El state-domain es - trabajo aparte (desbloquea la universalidad para componentes con estado). + `open`/`closed` específicamente, así que un `selected`/`idle` ni dispararía el preset. + - **RESUELTO (2026-06-21) — dominio estado vía atributo de fase dedicado.** Un componente con máquina propia + mantiene su `data-state` semántico y conduce el motion por un **`data-motion-state` dedicado**. El generador + (`renderCssPresetRules`) emite, junto al `[data-state='open']` y la cascada de stagger, un selector paralelo + `[data-animation-style='X'][data-motion-state='open']` SOLO para enter (**animate-on, snap-off**: el estado + inactivo no tiene regla → nunca anima al montar). El enter ya asienta al estilo natural (sin relleno que + fije), así que sirve sin tocar el fill. Como los presets de PRESENCIA (opacity 0→1) flashearían un elemento + aún visible, se añadió el preset de ÉNFASIS `select-pop` (scale 1→1.04→1, sin opacity). Piloto en ``: emite el atributo con un guard de montaje (`$effect` que salta la primera corrida) + para animar solo en la transición a selected; `card.css` excluye `card-emerge` cuando hay + `data-animation-style` (el sistema de motion posee el único `animation` → sin re-disparo al deseleccionar). + Verificado en navegador (data-state intacto · data-motion-state flipa · `select-pop-in` corre y asienta a + `transform:none` · deselect sin re-trigger). `select-pop` registrado en `EidosMotionPresets`; gen-test en + `motion.test.ts`. - **Hallazgo — colisión `data-motion`.** Ese attr es del sistema de **reduced-motion**: `arts/prefs/dom-projection.ts` proyecta la pref `motion` (intent `system|allow|reduce` → effective `allow|reduce`, vía `resolveMotion` + el OS) como `data-motion` en ``; `render-css.ts` @@ -1524,8 +1549,15 @@ eje exige **estrenar el driver `spring`** con un primer preset JS — trabajo ap `@media (prefers-reduced-motion: reduce)`. Por eso `` **NO emite marcador de part** (un `closest('[data-motion]')` del prefs lo matchearía) — solo los attrs funcionales. **Confirma:** el contenido es reduced-motion-safe GRATIS (las overrides keyean en `data-state`, que `motionAttrs` ya pone). -- **(c) El guard.** Tipos/lint: un componente con evento-que-posee-la-animación marca el prop como override; - los demás, primario. +- **(c) El guard — DOCTRINA, lint diferido (2026-06-21).** La regla: un componente con un evento-que-posee-la- + animación trata su `motion` como **override** (cambio consciente de la firma del evento, permitido); los demás + (contenido / estado) lo tratan como **primario**. Esto se queda como doctrina, NO como lint: «violación» = + «usar `motion` para algo que DEBERÍA declararse como evento» es un juicio subjetivo sin señal limpia que + encodear (el morfo no marca qué animación «pertenece» a un evento de forma que distinga override de violación). + Se difiere la materialización en tipos/lint hasta un caso concreto de mal uso que justifique el mecanismo — + coherente con «defer hasta el consumidor». Hoy los tres dominios quedan documentados (§D.12 tabla) y los dos + con consumidor (contenido vía `motionAttrs`/``/``, estado vía `data-motion-state`/``) + están implementados y verificados. - Cierra §6 (routing) en su parte vigente: el enrutado a superficie sigue siendo la idea (el dev nombra, el sistema sabe dónde), pero sin el eje coordinado separado que §6 contemplaba. @@ -1566,13 +1598,19 @@ inventa — se apoya en los que ya existen. `getAnimations().finished` antes de desmontar. El `opacity` del padre **agrupa el subárbol** → los hijos se desvanecen CON el panel. Salida correcta de casi todos los overlays; cero coordinación. (Verificado en vivo: `afterClose_retained: true`.) -- **Staggered-exit por-hijo (DIFERIDO):** un reverse-stagger donde el padre debe ESPERAR a los hijos es - inherentemente lifecycle (JS) — `Presence` espera su *propia* animación, no la del subárbol (sin - `{subtree:true}`). Es el `PresenceGroup` retirado (§8/§9). **Por eso la regla container-driven es - ENTER-ONLY:** no se envía un exit que se corte. -- **Puente pragmático (sin JS, interino):** dar al padre una salida con `duration ≥` la ventana de stagger de - los hijos → `Presence` retiene esa ventana → los hijos escalonan dentro. Hacky (depende del count) pero válido - para listas acotadas hasta que exista el `PresenceGroup`. +- **Staggered-exit por-hijo — el caso DIFÍCIL (la versión correcta, diferida):** un reverse-stagger donde el + padre debe ESPERAR a que los hijos terminen su salida es inherentemente lifecycle (JS) — `Presence` espera su + *propia* animación, no la del subárbol (sin `{subtree:true}`). Esa coordinación «el padre espera a los hijos» + es el `PresenceGroup` retirado (§8/§9); sigue diferida. +- **Puente pragmático CSS — ✅ HECHO (2026-06-21).** La regla container-driven ya NO es enter-only: el generador + emite también `[data-stagger][data-state='closed'] > [data-animation-style='X']`, que cascadea la SALIDA de los + hijos en **reverso** (`--motion-stagger-index-rev` — el último se va primero; `phaseDeclarations` elige el + índice por fase). El contrato: el contenedor debe estar RETENIDO en el DOM mientras tiene `data-state='closed'` + durante la ventana del stagger (dale una salida con `duration ≥` esa ventana, o retén+desmonta tras + `N × each + exit`). Sin esa retención el exit se corta — por eso la versión CORRECTA (padre-espera-a-hijos) + necesita el `PresenceGroup`. El puente cubre **listas acotadas**. Demo verificado en + `web/routes/demos/motion` (lista que entra en cascada y SALE en reverso, luego desmonta) + gen-test en + `motion.test.ts`. **D.13.3 — Afford de debug (`[data-debug-stagger]`).** @@ -1584,9 +1622,13 @@ El peaje del declarativo: sin error en consola ni breakpoint JS. Matices + mitig - **Todo es inspeccionable + tipado** (`@property`) en Computed: `index`, `each`, `animation-delay`, `animation-name`. Más que el closure de un motor JS; y un `@keyframes` no se breakpointea en ningún sitio (compositor). -- **Mitigación SHIPPED (el análogo CSS de `UIX_DEBUG_MOTION`, PENDIENTE):** un modo `[data-debug-stagger]` que - materialice por hijo su `index/delay/name` (un `::after` contador y/o un dump a consola), para convertir el - spelunking en Computed en una herramienta. +- **Mitigación — HECHO (2026-06-21):** el modo `[data-debug-stagger]` (el análogo CSS de `UIX_DEBUG_MOTION`) + ya se emite en la foundation (`render-css > renderMotionBlocks`). Un dev pone el attr en un contenedor de + stagger y cada hijo animado recibe un badge `::after` con su índice — un CSS `counter` que espeja + `:nth-child - 1` EXACTO (reset a -1, +1 por hijo, contando TODOS los hermanos, así un separador sin animación + no lleva badge pero su posición desplaza el índice del siguiente). Opt-in → coste cero sin el attr. El delay = + índice × `--motion-stagger-each`; `animation-delay`/`-name` siguen en Computed. Verificado en navegador + (badges 0·1·[sep]·3) + gen-test en `motion.test.ts`. --- diff --git a/src/uix/eidos/components/motion/motion.svelte b/src/uix/eidos/components/motion/motion.svelte index 2f823add7..e5eacbe7d 100644 --- a/src/uix/eidos/components/motion/motion.svelte +++ b/src/uix/eidos/components/motion/motion.svelte @@ -28,7 +28,10 @@ node.setAttribute('data-state', 'closed'); const cs = eidos.dom.getWindow(node).getComputedStyle(node); const ms = (parseFloat(cs.animationDelay) + parseFloat(cs.animationDuration)) * 1000 || 200; - return { duration: ms }; + // `tick` (a no-op) makes Svelte run the transition timeline for `duration` and + // THEN unmount. A `{ duration }`-only return never completes — the node sticks at + // the exit frame (invisible) and a re-show reuses the dead node. + return { duration: ms, tick: () => {} }; } diff --git a/src/uix/eidos/eidos-motion.md b/src/uix/eidos/eidos-motion.md index 8a2b981d2..a8f35a5d7 100644 --- a/src/uix/eidos/eidos-motion.md +++ b/src/uix/eidos/eidos-motion.md @@ -186,7 +186,8 @@ nombre ajeno, no animar sobre los dos ejes. > `Presence` (retiene + espera, el `opacity` del padre arrastra a los hijos) funciona hoy; el staggered-exit > por-hijo exige JS de lifecycle (`PresenceGroup`, diferido) → la regla container-driven es ENTER-ONLY; **nunca > `{#if}` crudo** en superficie animada. (3) **debug** — el índice es `inherits:false` (no lo pisa un ancestro); -> todo vive tipado en Computed; pendiente un modo `[data-debug-stagger]` que lo materialice. +> todo vive tipado en Computed; el modo opt-in `[data-debug-stagger]` lo materializa (un badge `::after` por +> hijo con su índice, vía un `counter` que espeja `:nth-child - 1`). --- @@ -507,8 +508,11 @@ Built-in: `fade-in/out`, `scale-in/out`, `slide-from/to-{side}[-full]`, | `shared-axis-x/y` · `fade-through` | Material 3 (F5) | Los **drivers JS** (`spring`/`waapi`/`rect`, `presets/js.ts`) se registran por -app/demo (aún no hay built-ins JS por nombre; el demo registra `panel-spring`, -`dialog-spring`, `flip`). +app/demo. El primer **built-in JS por nombre** es `spring-pop` (`driver:'spring'`, +`presets/js.ts` → `BUILTIN_JS_PRESETS`), registrado directo en `ActiveEidos` — no +vía el `EidosConfig.motion.presets` serializable, que no puede portar sus funciones +`MotionRun` (`structuredClone` falla). Demos adicionales registran `panel-spring`, +`dialog-spring`, `flip`. **`signatures` (momento `--event`)** — built-in, migradas de `events.css` (F2, §15): `present`/`dismiss`, `commit` (+ intents `fulfill`/`affirm`/`threat`), `press` @@ -665,9 +669,17 @@ usa `motion` ni dispara eventos nuevos. --- -**Última revisión**: 2026-06-04 — F1–F7 implementadas (dos momentos + firma -cross-modal + presence pulida + motor mecánico JS + coreografía + rigor de tokens -+ typegen extensible) + motor reubicado a `arts/motion` (servicio `uix.motion`). +**Última revisión**: 2026-06-21 — cierre del dominio del prop `motion` (§D.12): +**dominio estado** (atributo dedicado `data-motion-state` + preset énfasis +`select-pop`, piloto ``), **loops de contenido** (`spin`/`pulse`/`ping`/`bounce`, +reglas un-gated infinitas) + fix del exit de `` (`tick` no-op), afford de +debug `[data-debug-stagger]`, y primer **built-in JS por nombre** `spring-pop` +(driver `spring` estrenado, registrado en `ActiveEidos`; demostrado sobre +`Popover.Content motion="spring-pop"`). Demo de loops+lifecycle en +`web/routes/demos/motion`. Previo (2026-06-04): F1–F7 implementadas +(dos momentos + firma cross-modal + presence pulida + motor mecánico JS + +coreografía + rigor de tokens + typegen extensible) + motor reubicado a +`arts/motion` (servicio `uix.motion`). Si el código diverge, el código gana; abre un issue. Referencias: `GUIA_IMPLEMENTACION_SEMAUIX.md` (§4.3, §7, §11 — el modelo evento/estado/firma), `active_architecture.md` §5/§6 (cadena causal, attrs), `THEMING.md` (tokens), `soma/SOMA_ARCHITECTURE.md` diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 82a87c10e..ba5e2dd40 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -4728,6 +4728,32 @@ --motion-stagger-index-rev: 23; } +[data-debug-stagger] { + counter-reset: motion-stagger -1; +} + +[data-debug-stagger] > * { + counter-increment: motion-stagger; +} + +[data-debug-stagger] > [data-animation-style] { + position: relative; +} + +[data-debug-stagger] > [data-animation-style]::after { + content: counter(motion-stagger); + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + z-index: 2147483647; + padding: 0 4px; + font: 600 10px / 1.5 ui-monospace, SFMono-Regular, monospace; + color: #fff; + background: #d6409f; + border-end-end-radius: 4px; + pointer-events: none; +} + @keyframes fade-in { from { opacity: 0; @@ -4764,6 +4790,55 @@ } } +@keyframes select-pop-in { + 0% { + scale: 1; + } + 45% { + scale: 1.06; + } + 100% { + scale: 1; + } +} + +@keyframes loop-spin { + to { + rotate: 360deg; + } +} + +@keyframes loop-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@keyframes loop-ping { + 0% { + scale: 1; + opacity: 1; + } + 75%, 100% { + scale: 2; + opacity: 0; + } +} + +@keyframes loop-bounce { + 0%, 100% { + translate: 0 -22%; + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + 50% { + translate: 0 0; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} + @keyframes slide-from-top { from { translate: 0 calc(var(--motion-distance-md) * -1); @@ -5115,35 +5190,41 @@ } [data-animation-style='fade'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='fade'] { +[data-stagger][data-state='open'] > [data-animation-style='fade'], +[data-animation-style='fade'][data-motion-state='open'] { animation: fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='fade'][data-state='closed'] { +[data-animation-style='fade'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='fade'] { animation: fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } [data-animation-style='scale-fade'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='scale-fade'] { +[data-stagger][data-state='open'] > [data-animation-style='scale-fade'], +[data-animation-style='scale-fade'][data-motion-state='open'] { animation: scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); transform-origin: var(--floating-transform-origin); } -[data-animation-style='scale-fade'][data-state='closed'] { +[data-animation-style='scale-fade'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='scale-fade'] { animation: scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); transform-origin: var(--floating-transform-origin); } -[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='open'], +[data-motion='reduce'] [data-animation-style='scale-fade'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='scale-fade'][data-state='open'] { + [data-animation-style='scale-fade'][data-state='open'], + [data-animation-style='scale-fade'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } } @@ -5159,7 +5240,8 @@ } [data-animation-style='slide-fade'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='slide-fade'] { +[data-stagger][data-state='open'] > [data-animation-style='slide-fade'], +[data-animation-style='slide-fade'][data-motion-state='open'] { animation: slide-from-bottom var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5184,7 +5266,8 @@ animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='slide-fade'][data-state='closed'] { +[data-animation-style='slide-fade'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='slide-fade'] { animation: slide-to-bottom var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } @@ -5209,12 +5292,14 @@ animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-motion='reduce'] [data-animation-style='slide-fade'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='slide-fade'][data-state='open'], +[data-motion='reduce'] [data-animation-style='slide-fade'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='slide-fade'][data-state='open'] { + [data-animation-style='slide-fade'][data-state='open'], + [data-animation-style='slide-fade'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } } @@ -5230,7 +5315,8 @@ } [data-animation-style='slide-full'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='slide-full'] { +[data-stagger][data-state='open'] > [data-animation-style='slide-full'], +[data-animation-style='slide-full'][data-motion-state='open'] { animation: slide-from-right-full var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } @@ -5255,7 +5341,8 @@ animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='slide-full'][data-state='closed'] { +[data-animation-style='slide-full'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='slide-full'] { animation: slide-to-right-full var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } @@ -5280,12 +5367,14 @@ animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-motion='reduce'] [data-animation-style='slide-full'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='slide-full'][data-state='open'], +[data-motion='reduce'] [data-animation-style='slide-full'][data-motion-state='open'] { animation: none !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='slide-full'][data-state='open'] { + [data-animation-style='slide-full'][data-state='open'], + [data-animation-style='slide-full'][data-motion-state='open'] { animation: none !important; } } @@ -5301,22 +5390,26 @@ } [data-animation-style='collapse'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='collapse'] { +[data-stagger][data-state='open'] > [data-animation-style='collapse'], +[data-animation-style='collapse'][data-motion-state='open'] { animation: expand-height var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='collapse'][data-state='closed'] { +[data-animation-style='collapse'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='collapse'] { animation: collapse-height var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-motion='reduce'] [data-animation-style='collapse'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='collapse'][data-state='open'], +[data-motion='reduce'] [data-animation-style='collapse'][data-motion-state='open'] { animation: none !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='collapse'][data-state='open'] { + [data-animation-style='collapse'][data-state='open'], + [data-animation-style='collapse'][data-motion-state='open'] { animation: none !important; } } @@ -5332,22 +5425,26 @@ } [data-animation-style='shared-axis-x'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='shared-axis-x'] { +[data-stagger][data-state='open'] > [data-animation-style='shared-axis-x'], +[data-animation-style='shared-axis-x'][data-motion-state='open'] { animation: slide-axis-x-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='shared-axis-x'][data-state='closed'] { +[data-animation-style='shared-axis-x'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='shared-axis-x'] { animation: slide-axis-x-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-motion='reduce'] [data-animation-style='shared-axis-x'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='shared-axis-x'][data-state='open'], +[data-motion='reduce'] [data-animation-style='shared-axis-x'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='shared-axis-x'][data-state='open'] { + [data-animation-style='shared-axis-x'][data-state='open'], + [data-animation-style='shared-axis-x'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } } @@ -5363,22 +5460,26 @@ } [data-animation-style='shared-axis-y'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='shared-axis-y'] { +[data-stagger][data-state='open'] > [data-animation-style='shared-axis-y'], +[data-animation-style='shared-axis-y'][data-motion-state='open'] { animation: slide-axis-y-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='shared-axis-y'][data-state='closed'] { +[data-animation-style='shared-axis-y'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='shared-axis-y'] { animation: slide-axis-y-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-motion='reduce'] [data-animation-style='shared-axis-y'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='shared-axis-y'][data-state='open'], +[data-motion='reduce'] [data-animation-style='shared-axis-y'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='shared-axis-y'][data-state='open'] { + [data-animation-style='shared-axis-y'][data-state='open'], + [data-animation-style='shared-axis-y'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } } @@ -5394,22 +5495,26 @@ } [data-animation-style='fade-through'][data-state='open'], -[data-stagger][data-state='open'] > [data-animation-style='fade-through'] { +[data-stagger][data-state='open'] > [data-animation-style='fade-through'], +[data-animation-style='fade-through'][data-motion-state='open'] { animation: scale-through var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); } -[data-animation-style='fade-through'][data-state='closed'] { +[data-animation-style='fade-through'][data-state='closed'], +[data-stagger][data-state='closed'] > [data-animation-style='fade-through'] { animation: fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } -[data-motion='reduce'] [data-animation-style='fade-through'][data-state='open'] { +[data-motion='reduce'] [data-animation-style='fade-through'][data-state='open'], +[data-motion='reduce'] [data-animation-style='fade-through'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } @media (prefers-reduced-motion: reduce) { - [data-animation-style='fade-through'][data-state='open'] { + [data-animation-style='fade-through'][data-state='open'], + [data-animation-style='fade-through'][data-motion-state='open'] { animation: fade-in var(--duration-fast) var(--ease-out) backwards !important; } } @@ -5424,6 +5529,81 @@ } } +[data-animation-style='select-pop'][data-state='open'], +[data-stagger][data-state='open'] > [data-animation-style='select-pop'], +[data-animation-style='select-pop'][data-motion-state='open'] { + animation: select-pop-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards; + animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms)); +} + +[data-motion='reduce'] [data-animation-style='select-pop'][data-state='open'], +[data-motion='reduce'] [data-animation-style='select-pop'][data-motion-state='open'] { + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + [data-animation-style='select-pop'][data-state='open'], + [data-animation-style='select-pop'][data-motion-state='open'] { + animation: none !important; + } +} + +[data-animation-style='spin'] { + animation: loop-spin var(--motion-loop-spin, 1100ms) linear infinite; +} + +[data-motion='reduce'] [data-animation-style='spin'] { + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + [data-animation-style='spin'] { + animation: none !important; + } +} + +[data-animation-style='pulse'] { + animation: loop-pulse var(--motion-loop-pulse, 2s) cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +[data-motion='reduce'] [data-animation-style='pulse'] { + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + [data-animation-style='pulse'] { + animation: none !important; + } +} + +[data-animation-style='ping'] { + animation: loop-ping var(--motion-loop-ping, 1100ms) cubic-bezier(0, 0, 0.2, 1) infinite; +} + +[data-motion='reduce'] [data-animation-style='ping'] { + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + [data-animation-style='ping'] { + animation: none !important; + } +} + +[data-animation-style='bounce'] { + animation: loop-bounce var(--motion-loop-bounce, 1s) linear infinite; +} + +[data-motion='reduce'] [data-animation-style='bounce'] { + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + [data-animation-style='bounce'] { + animation: none !important; + } +} + @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 7facaa564..a855e16d3 100644 --- a/src/uix/eidos/lib/motion/presets/css.ts +++ b/src/uix/eidos/lib/motion/presets/css.ts @@ -30,6 +30,27 @@ export const BUILTIN_KEYFRAMES: Readonly> = { 'scale-in': { from: { scale: 'var(--motion-scale-enter)' }, to: { scale: '1' } }, 'scale-out': { from: { scale: '1' }, to: { scale: 'var(--motion-scale-enter)' } }, + // State-domain EMPHASIS (momento --state) — NO opacity, so the element stays + // visible. A both-visible state machine (Card selected/idle, Switch on/off) pops + // once when it ENTERS its active state and settles back to its steady style — it + // does NOT appear/disappear, so it must NOT fade from 0 like the presence presets. + // Peak is a design literal (cf. press-squeeze's 0.96). + 'select-pop-in': { '0%': { scale: '1' }, '45%': { scale: '1.06' }, '100%': { scale: '1' } }, + + // ── Loops (momento --content, infinite) — Tailwind's canonical set ──────────── + // Pure content: the `motion` prop is the PRIMARY trigger (no event/state to + // compose with), and the animation runs forever while the element is present. + // Individual transform properties so they compose; `bounce` carries per-keyframe + // timing (the squash/rebound asymmetry). Run via the un-gated loop rules + // (`render-css > renderMotionBlocks`), NOT the enter/exit state path. + 'loop-spin': { to: { rotate: '360deg' } }, + 'loop-pulse': { '0%, 100%': { opacity: '1' }, '50%': { opacity: '0.45' } }, + 'loop-ping': { '0%': { scale: '1', opacity: '1' }, '75%, 100%': { scale: '2', opacity: '0' } }, + 'loop-bounce': { + '0%, 100%': { translate: '0 -22%', 'animation-timing-function': 'cubic-bezier(0.8, 0, 1, 1)' }, + '50%': { translate: '0 0', 'animation-timing-function': 'cubic-bezier(0, 0, 0.2, 1)' } + }, + // Partial slides (~8px nudge) — popover / tooltip / menu / toast. 'slide-from-top': { from: { translate: `0 ${NEG_D}` }, to: { translate: '0 0' } }, 'slide-from-bottom': { from: { translate: `0 ${D}` }, to: { translate: '0 0' } }, @@ -374,6 +395,38 @@ export const BUILTIN_CSS_PRESETS: Readonly> = { enter: { keyframes: ['scale-through', 'fade-in'], duration: 'moderate', ease: 'out' }, exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' }, reduce: 'opacity-only' + }, + + // ── State-domain emphasis (momento --state — stateful components) ───────────── + // For a component with its OWN data-state machine (Card selected/idle, Switch + // on/off): `motion="select-pop"` pops the element when it ENTERS its active state + // and settles back. The generator emits this under the dedicated + // `[data-motion-state='open']` selector (NOT data-state, which the component uses + // for its semantics) and ENTER-ONLY (no `exit`) — animate-on, snap-off: the + // inactive state has no rule, so an idle element never animates on mount. Always + // pair the state domain with an EMPHASIS preset (this), never a presence one + // (scale-fade et al. would flash the still-visible element from invisible). + 'select-pop': { + driver: 'css', + enter: { keyframes: 'select-pop-in', duration: 'moderate', ease: 'out' }, + reduce: 'instant' } } +/** + * Built-in LOOP presets (content domain, infinite) — Tailwind's canonical set. + * Unlike the enter/exit state-presets, a loop runs continuously while the element + * is present: the generator emits `[data-animation-style='X'] { animation: … infinite }` + * with NO `data-state` gate. The duration is themeable per loop via + * `--motion-loop-{name}` (default below). Reduced motion stops them — the element + * rests in its static frame. + */ +export const BUILTIN_LOOP_PRESETS: Readonly< + Record +> = { + spin: { keyframes: 'loop-spin', duration: '1100ms', timing: 'linear' }, + pulse: { keyframes: 'loop-pulse', duration: '2s', timing: 'cubic-bezier(0.4, 0, 0.6, 1)' }, + ping: { keyframes: 'loop-ping', duration: '1100ms', timing: 'cubic-bezier(0, 0, 0.2, 1)' }, + bounce: { keyframes: 'loop-bounce', duration: '1s', timing: 'linear' } +} + diff --git a/src/uix/eidos/lib/motion/registry.ts b/src/uix/eidos/lib/motion/registry.ts index a90990cdf..4b3baa078 100644 --- a/src/uix/eidos/lib/motion/registry.ts +++ b/src/uix/eidos/lib/motion/registry.ts @@ -29,6 +29,15 @@ export interface EidosMotionPresets { 'shared-axis-x': true 'shared-axis-y': true 'fade-through': true + // State-domain emphasis — for components with their OWN data-state machine; keys + // on the dedicated `data-motion-state` (see render-css `renderCssPresetRules`). + 'select-pop': true + // Loops (content domain, infinite) — Tailwind's canonical set; run continuously + // while present (un-gated `[data-animation-style]` rules). + spin: true + pulse: true + ping: true + bounce: true // JS-driven (the `spring` engine tier) — runs via uix.motion, not CSS. 'spring-pop': true } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 712f5b622..1db0a2b63 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -47,6 +47,7 @@ import { } from './config-types' import { createEidosCssContract } from './contract' import { toKebab } from './utils' +import { BUILTIN_LOOP_PRESETS } from './motion/presets/css' import { STATIC_SCALING } from './primitives/static' import type { CssStatePreset, @@ -739,6 +740,33 @@ function renderMotionBlocks(motion: MotionConfig): string { blocks.push(`[data-stagger] > *:nth-last-child(${n}) {\n\t--motion-stagger-index-rev: ${n - 1};\n}`) } + // Debug affordance — a dev sets `[data-debug-stagger]` on a stagger container to + // materialize each child's index as a corner badge (the CSS analog of + // `UIX_DEBUG_MOTION`, RFC §D.13.3). Opt-in: nothing renders without the attr, so + // it ships at zero cost. The counter mirrors `:nth-child - 1` EXACTLY (reset to + // -1, +1 per child, counting ALL siblings) = the child's `--motion-stagger-index`. + // Multiply by `--motion-stagger-each` for the delay; `animation-delay`/`-name` + // stay inspectable in Computed for the rest. Colors are debug literals (not + // themed — this is a tool, never production chrome). + blocks.push('[data-debug-stagger] {\n\tcounter-reset: motion-stagger -1;\n}') + blocks.push('[data-debug-stagger] > * {\n\tcounter-increment: motion-stagger;\n}') + blocks.push('[data-debug-stagger] > [data-animation-style] {\n\tposition: relative;\n}') + blocks.push( + '[data-debug-stagger] > [data-animation-style]::after {\n' + + '\tcontent: counter(motion-stagger);\n' + + '\tposition: absolute;\n' + + '\tinset-block-start: 0;\n' + + '\tinset-inline-start: 0;\n' + + '\tz-index: 2147483647;\n' + + '\tpadding: 0 4px;\n' + + '\tfont: 600 10px / 1.5 ui-monospace, SFMono-Regular, monospace;\n' + + '\tcolor: #fff;\n' + + '\tbackground: #d6409f;\n' + + '\tborder-end-end-radius: 4px;\n' + + '\tpointer-events: none;\n' + + '}' + ) + for (const [name, stops] of Object.entries(motion.keyframes ?? {})) { blocks.push(renderKeyframes(name, stops)) } @@ -755,6 +783,25 @@ function renderMotionBlocks(motion: MotionConfig): string { blocks.push(...renderReducedMotionRules(name, preset)) } + // Momento --content: LOOPS (infinite). Un-gated `[data-animation-style]` rules so + // the element animates continuously while present (no data-state). Themeable per + // loop via `--motion-loop-{name}`; reduced motion stops them — the element rests + // in its static frame. + for (const [name, loop] of Object.entries(BUILTIN_LOOP_PRESETS)) { + blocks.push( + renderBlock(`[data-animation-style='${name}']`, [ + `animation: ${loop.keyframes} var(--motion-loop-${name}, ${loop.duration}) ${loop.timing} infinite;` + ]) + ) + const off = renderBlock(`[data-animation-style='${name}']`, ['animation: none !important;']) + blocks.push( + renderBlock(`[data-motion='reduce'] [data-animation-style='${name}']`, [ + 'animation: none !important;' + ]) + ) + blocks.push(`@media (prefers-reduced-motion: reduce) {\n${indentBlock(off)}\n}`) + } + return blocks.join('\n\n') } @@ -778,19 +825,31 @@ function renderCssPresetRules(name: string, preset: CssStatePreset): string[] { // Two selector forms share the SAME declarations: // · self-driven — a lone surface carries its own `data-state` (Content // panel, dialog, a ``). - // · container-driven (ENTER only) — the EVENT-DRIVEN cascade: direct - // children of an opening `[data-stagger]` container that carry this - // preset enter together, indexed FROM STRUCTURE (`:nth-child` → - // `--motion-stagger-index`). No per-item `data-state` (a `menuitem` - // isn't open/closed); the container's state — which soma already writes - // via the morfo `commits` — drives the children. - // ENTER only: children mount together so enter needs no coordination; the - // coordinated EXIT (retain the container until its children finish) is a - // soma lifecycle concern (PresenceGroup), still deferred — so a closing - // container must NOT drive a child exit that would be cut off on unmount. + // · container-driven — the EVENT-DRIVEN cascade: direct children of a + // `[data-stagger]` container inherit the container's `data-state`, indexed + // FROM STRUCTURE (`:nth-child`). No per-item `data-state` (a `menuitem` isn't + // open/closed); the container's state — which soma writes via the morfo + // `commits` — drives the children. ENTER cascades in with + // `--motion-stagger-index` (first item first); EXIT cascades OUT in reverse + // with `--motion-stagger-index-rev` (last leaves first) — `phaseDeclarations` + // picks the right index per phase. + // + // EXIT is the pragmatic CSS bridge (§D.13.2): the container must be RETAINED + // long enough for the children to finish (give it an exit `duration ≥` the + // stagger window). Without retention the exit is cut off on unmount — the + // correct "parent waits for children" needs JS lifecycle (the retired + // `PresenceGroup`, still deferred); this bridge covers BOUNDED lists. const selectors = [`[data-animation-style='${name}'][data-state='${state}']`] + selectors.push(`[data-stagger][data-state='${state}'] > [data-animation-style='${name}']`) if (phase === 'enter') { - selectors.push(`[data-stagger][data-state='${state}'] > [data-animation-style='${name}']`) + // · state-domain — a component with its OWN data-state machine (Card + // selected/idle, Switch on/off) keeps its semantic `data-state` and drives + // motion via the dedicated `data-motion-state='open'` when it ENTERS its + // active state. ENTER-ONLY (this branch): the enter animation settles to the + // natural style (no holding fill), and the inactive state has no rule — so an + // idle element never animates on mount. Pair with an EMPHASIS preset + // (`select-pop`); a presence preset would flash the still-visible element. + selectors.push(`[data-animation-style='${name}'][data-motion-state='${state}']`) } rules.push( renderBlock( @@ -855,10 +914,17 @@ function renderReducedMotionRules(name: string, preset: CssStatePreset): string[ : phase === 'enter' ? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;'] : ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;'] - const selector = `[data-animation-style='${name}'][data-state='${state}']` - rules.push(renderBlock(`[data-motion='reduce'] ${selector}`, inner)) + const selectors = [`[data-animation-style='${name}'][data-state='${state}']`] + // State-domain (enter-only): the dedicated `data-motion-state` path honors reduce too. + if (phase === 'enter') { + selectors.push(`[data-animation-style='${name}'][data-motion-state='${state}']`) + } + // `[data-motion='reduce']` must prefix EACH selector (in a comma-list it would bind + // only to the first), so project the list explicitly. + const projected = selectors.map((s) => `[data-motion='reduce'] ${s}`).join(',\n') + rules.push(renderBlock(projected, inner)) rules.push( - `@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selector, inner))}\n}` + `@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selectors.join(',\n'), inner))}\n}` ) } return rules diff --git a/src/uix/eidos/motion.test.ts b/src/uix/eidos/motion.test.ts index 2b8134387..5212f4624 100644 --- a/src/uix/eidos/motion.test.ts +++ b/src/uix/eidos/motion.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { validateEidosConfig } from './lib/config'; import { createEngineMotion } from '$motion'; -import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES } from './lib/motion/presets/css'; +import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES, BUILTIN_LOOP_PRESETS } from './lib/motion/presets/css'; import { BUILTIN_JS_PRESETS } from './lib/motion/presets/js'; import { renderStaticCss } from './lib/render-css'; import { createThemeBaseEidosConfig } from './lib/themes/base'; @@ -47,6 +47,43 @@ describe('eidos motion — CSS generation', () => { ); }); + it('emits the opt-in stagger debug affordance ([data-debug-stagger] badges)', () => { + // A dev sets `[data-debug-stagger]` on a container; each animated child gets a + // corner badge with its index. The counter mirrors `:nth-child - 1` exactly. + expect(css).toContain('[data-debug-stagger] {'); + expect(css).toContain('counter-reset: motion-stagger -1;'); + expect(css).toContain('counter-increment: motion-stagger;'); + expect(css).toContain(`[data-debug-stagger] > [data-animation-style]::after`); + expect(css).toContain('content: counter(motion-stagger);'); + }); + + it('emits the loop presets (content domain, infinite, un-gated)', () => { + // Loops run continuously while present: an un-gated `[data-animation-style]` + // rule (no data-state), a themeable duration, and stopped under reduced motion. + expect(css).toContain('@keyframes loop-spin'); + expect(css).toContain('@keyframes loop-bounce'); + for (const name of ['spin', 'pulse', 'ping', 'bounce']) { + expect(css).toContain(`[data-animation-style='${name}'] {`); + expect(css).toContain(`var(--motion-loop-${name},`); + } + expect(css).toContain('infinite;'); + // reduced motion stops them + expect(css).toContain(`[data-motion='reduce'] [data-animation-style='spin']`); + }); + + it('emits the container-driven cascade for enter AND exit (staggered-exit bridge)', () => { + // Direct children of a `[data-stagger]` container inherit its `data-state`: enter + // cascades in (forward index), exit cascades OUT in reverse (last leaves first). + expect(css).toContain(`[data-stagger][data-state='open'] > [data-animation-style='scale-fade']`); + expect(css).toContain( + `[data-stagger][data-state='closed'] > [data-animation-style='scale-fade']` + ); + // exit declarations use the REVERSE index (`-rev`) so the last item leaves first + expect(css).toContain( + 'animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms))' + ); + }); + 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']`); @@ -81,12 +118,29 @@ describe('eidos motion — CSS generation', () => { `[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;/ + /\[data-animation-style='collapse'\]\[data-state='open'\][^{]*\{\s*animation: none !important;/ ); // 'none' presets (fade) get NO per-preset reduced override expect(css).not.toContain(`[data-motion='reduce'] [data-animation-style='fade'][data-state`); }); + it('emits the state-domain enter selector (dedicated data-motion-state, enter-only)', () => { + // A stateful component (Card selected/idle, Switch on/off) drives motion via + // `data-motion-state`, keeping its semantic `data-state`. The mechanism is + // universal (every preset gets the parallel selector); `select-pop` is the + // emphasis preset MEANT for it (no opacity-from-0 that would flash a visible + // element). + expect(css).toContain(`[data-animation-style='select-pop'][data-motion-state='open']`); + expect(css).toContain(`[data-animation-style='scale-fade'][data-motion-state='open']`); + expect(css).toContain('@keyframes select-pop-in'); + // ENTER-ONLY (animate-on, snap-off): no preset emits a `closed` motion-state rule. + expect(css).not.toContain(`data-motion-state='closed'`); + // reduced motion covers the state-domain path too. + expect(css).toContain( + `[data-motion='reduce'] [data-animation-style='select-pop'][data-motion-state='open']` + ); + }); + it('emits event-moment signatures (data-event-*) from motion.signatures', () => { const withSig = renderStaticCss({ ...createThemeBaseEidosConfig(), @@ -265,10 +319,19 @@ describe('eidos motion — F7 typegen registry', () => { 'shared-axis-x', 'shared-axis-y', 'fade-through', + 'select-pop', + 'spin', + 'pulse', + 'ping', + 'bounce', 'spring-pop' ] as const satisfies readonly (keyof EidosMotionPresets)[]; expect( - [...Object.keys(BUILTIN_CSS_PRESETS), ...Object.keys(BUILTIN_JS_PRESETS)].sort() + [ + ...Object.keys(BUILTIN_CSS_PRESETS), + ...Object.keys(BUILTIN_LOOP_PRESETS), + ...Object.keys(BUILTIN_JS_PRESETS) + ].sort() ).toEqual([...registered].sort()); }); });