feat(motion): loops + state-domain + staggered-exit + debug-stagger

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 `<Motion>` 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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent b2b1f4ffcf
commit 9e4187bc3c

@ -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`). `<Cascade>` `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 `<Badge motion="X">`,
commit `0a6d112c`) + el wrapper `<Motion>` (enter on-mount + exit on-unmount con retención `out:` estilo
`<Cascade.Item>`, + 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 `<Motion>` 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`/`<Motion>` 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 `<Card
motion="select-pop">`: 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 `<html>`; `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 `<Motion>` **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`/`<Motion>`/`<Badge>`, estado vía `data-motion-state`/`<Card>`)
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`.
---

@ -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: () => {} };
}
</script>

@ -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 `<Card>`), **loops de contenido** (`spin`/`pulse`/`ping`/`bounce`,
reglas un-gated infinitas) + fix del exit de `<Motion>` (`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`

@ -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;

@ -30,6 +30,27 @@ export const BUILTIN_KEYFRAMES: Readonly<Record<string, KeyframeStops>> = {
'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<Record<string, CssStatePreset>> = {
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<string, { keyframes: string; duration: string; timing: string }>
> = {
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' }
}

@ -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
}

@ -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 `<Cascade.Item>`).
// · 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

@ -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());
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.