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: 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. 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 **El asterisco (parcialmente cerrado).** Esto es superioridad **conceptual** (el encuadre). En el eje **motor**
`spring`, reversa interrumpible con handoff de velocidad), Framer demuestra más HOY porque el motor JS de UIX (físicas `spring`, reversa interrumpible con handoff de velocidad), el motor JS de UIX ya **no está dormido**:
existe pero está **dormido** (todos los presets shipped son `driver: 'css'`). El "por delante" también en ese el driver `spring` se **estrenó** con el primer built-in JS por nombre — `spring-pop` (`BUILTIN_JS_PRESETS`,
eje exige **estrenar el driver `spring`** con un primer preset JS — trabajo aparte de (a)+(b). 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.** **Plan de implementación (a)+(b) — acotado a eidos, NO el `PresenceGroup` pesado.**
- **(a) Unificar el nombre — ✅ HECHO** (commit `bfac34dd`). `<Cascade>` `animation` → `motion` (prop + - **(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">`, - **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 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 `<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 - **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 `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; `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 **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 `open`/`closed` específicamente, así que un `selected`/`idle` ni dispararía el preset.
trabajo aparte (desbloquea la universalidad para componentes con estado). - **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**: - **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 `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` `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 `@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 `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). 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; - **(c) El guard — DOCTRINA, lint diferido (2026-06-21).** La regla: un componente con un evento-que-posee-la-
los demás, primario. 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 - 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. 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 `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: desvanecen CON el panel. Salida correcta de casi todos los overlays; cero coordinación. (Verificado en vivo:
`afterClose_retained: true`.) `afterClose_retained: true`.)
- **Staggered-exit por-hijo (DIFERIDO):** un reverse-stagger donde el padre debe ESPERAR a los hijos es - **Staggered-exit por-hijo — el caso DIFÍCIL (la versión correcta, diferida):** un reverse-stagger donde el
inherentemente lifecycle (JS) — `Presence` espera su *propia* animación, no la del subárbol (sin padre debe ESPERAR a que los hijos terminen su salida es inherentemente lifecycle (JS) — `Presence` espera su
`{subtree:true}`). Es el `PresenceGroup` retirado (§8/§9). **Por eso la regla container-driven es *propia* animación, no la del subárbol (sin `{subtree:true}`). Esa coordinación «el padre espera a los hijos»
ENTER-ONLY:** no se envía un exit que se corte. es el `PresenceGroup` retirado (§8/§9); sigue diferida.
- **Puente pragmático (sin JS, interino):** dar al padre una salida con `duration ≥` la ventana de stagger de - **Puente pragmático CSS — ✅ HECHO (2026-06-21).** La regla container-driven ya NO es enter-only: el generador
los hijos → `Presence` retiene esa ventana → los hijos escalonan dentro. Hacky (depende del count) pero válido emite también `[data-stagger][data-state='closed'] > [data-animation-style='X']`, que cascadea la SALIDA de los
para listas acotadas hasta que exista el `PresenceGroup`. 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]`).** **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`, - **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 `animation-name`. Más que el closure de un motor JS; y un `@keyframes` no se breakpointea en ningún sitio
(compositor). (compositor).
- **Mitigación SHIPPED (el análogo CSS de `UIX_DEBUG_MOTION`, PENDIENTE):** un modo `[data-debug-stagger]` que - **Mitigación — HECHO (2026-06-21):** el modo `[data-debug-stagger]` (el análogo CSS de `UIX_DEBUG_MOTION`)
materialice por hijo su `index/delay/name` (un `::after` contador y/o un dump a consola), para convertir el ya se emite en la foundation (`render-css > renderMotionBlocks`). Un dev pone el attr en un contenedor de
spelunking en Computed en una herramienta. 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'); node.setAttribute('data-state', 'closed');
const cs = eidos.dom.getWindow(node).getComputedStyle(node); const cs = eidos.dom.getWindow(node).getComputedStyle(node);
const ms = (parseFloat(cs.animationDelay) + parseFloat(cs.animationDuration)) * 1000 || 200; 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> </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 > `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 > 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); > `{#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) | | `shared-axis-x/y` · `fade-through` | Material 3 (F5) |
Los **drivers JS** (`spring`/`waapi`/`rect`, `presets/js.ts`) se registran por 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`, app/demo. El primer **built-in JS por nombre** es `spring-pop` (`driver:'spring'`,
`dialog-spring`, `flip`). `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): **`signatures` (momento `--event`)** — built-in, migradas de `events.css` (F2, §15):
`present`/`dismiss`, `commit` (+ intents `fulfill`/`affirm`/`threat`), `press` `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 **Última revisión**: 2026-06-21 — cierre del dominio del prop `motion` (§D.12):
cross-modal + presence pulida + motor mecánico JS + coreografía + rigor de tokens **dominio estado** (atributo dedicado `data-motion-state` + preset énfasis
+ typegen extensible) + motor reubicado a `arts/motion` (servicio `uix.motion`). `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` 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 (§4.3, §7, §11 — el modelo evento/estado/firma), `active_architecture.md` §5/§6
(cadena causal, attrs), `THEMING.md` (tokens), `soma/SOMA_ARCHITECTURE.md` (cadena causal, attrs), `THEMING.md` (tokens), `soma/SOMA_ARCHITECTURE.md`

@ -4728,6 +4728,32 @@
--motion-stagger-index-rev: 23; --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 { @keyframes fade-in {
from { from {
opacity: 0; 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 { @keyframes slide-from-top {
from { from {
translate: 0 calc(var(--motion-distance-md) * -1); translate: 0 calc(var(--motion-distance-md) * -1);
@ -5115,35 +5190,41 @@
} }
[data-animation-style='fade'][data-state='open'], [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: 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)); 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: 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)); animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms));
} }
[data-animation-style='scale-fade'][data-state='open'], [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: 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)); animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
transform-origin: var(--floating-transform-origin); 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: 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)); animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms));
transform-origin: var(--floating-transform-origin); 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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
@media (prefers-reduced-motion: reduce) { @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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
} }
@ -5159,7 +5240,8 @@
} }
[data-animation-style='slide-fade'][data-state='open'], [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: 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)); 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)); 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: 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)); 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)); 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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
@media (prefers-reduced-motion: reduce) { @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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
} }
@ -5230,7 +5315,8 @@
} }
[data-animation-style='slide-full'][data-state='open'], [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: 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)); 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)); 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: 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)); 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)); 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; animation: none !important;
} }
@media (prefers-reduced-motion: reduce) { @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; animation: none !important;
} }
} }
@ -5301,22 +5390,26 @@
} }
[data-animation-style='collapse'][data-state='open'], [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: 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)); 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: 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)); 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; animation: none !important;
} }
@media (prefers-reduced-motion: reduce) { @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; animation: none !important;
} }
} }
@ -5332,22 +5425,26 @@
} }
[data-animation-style='shared-axis-x'][data-state='open'], [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: 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)); 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: 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)); 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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
@media (prefers-reduced-motion: reduce) { @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; 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-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: 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)); 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: 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)); 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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
@media (prefers-reduced-motion: reduce) { @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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
} }
@ -5394,22 +5495,26 @@
} }
[data-animation-style='fade-through'][data-state='open'], [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: 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)); 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: 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)); 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; animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;
} }
@media (prefers-reduced-motion: reduce) { @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; 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) { @media (forced-colors: active) {
:focus-visible { :focus-visible {
outline: 2px solid Highlight; 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-in': { from: { scale: 'var(--motion-scale-enter)' }, to: { scale: '1' } },
'scale-out': { from: { scale: '1' }, to: { scale: 'var(--motion-scale-enter)' } }, '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. // Partial slides (~8px nudge) — popover / tooltip / menu / toast.
'slide-from-top': { from: { translate: `0 ${NEG_D}` }, to: { translate: '0 0' } }, 'slide-from-top': { from: { translate: `0 ${NEG_D}` }, to: { translate: '0 0' } },
'slide-from-bottom': { from: { translate: `0 ${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' }, enter: { keyframes: ['scale-through', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' }, exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' },
reduce: 'opacity-only' 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-x': true
'shared-axis-y': true 'shared-axis-y': true
'fade-through': 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. // JS-driven (the `spring` engine tier) — runs via uix.motion, not CSS.
'spring-pop': true 'spring-pop': true
} }

@ -47,6 +47,7 @@ import {
} from './config-types' } from './config-types'
import { createEidosCssContract } from './contract' import { createEidosCssContract } from './contract'
import { toKebab } from './utils' import { toKebab } from './utils'
import { BUILTIN_LOOP_PRESETS } from './motion/presets/css'
import { STATIC_SCALING } from './primitives/static' import { STATIC_SCALING } from './primitives/static'
import type { import type {
CssStatePreset, 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}`) 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 ?? {})) { for (const [name, stops] of Object.entries(motion.keyframes ?? {})) {
blocks.push(renderKeyframes(name, stops)) blocks.push(renderKeyframes(name, stops))
} }
@ -755,6 +783,25 @@ function renderMotionBlocks(motion: MotionConfig): string {
blocks.push(...renderReducedMotionRules(name, preset)) 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') return blocks.join('\n\n')
} }
@ -778,19 +825,31 @@ function renderCssPresetRules(name: string, preset: CssStatePreset): string[] {
// Two selector forms share the SAME declarations: // Two selector forms share the SAME declarations:
// · self-driven — a lone surface carries its own `data-state` (Content // · self-driven — a lone surface carries its own `data-state` (Content
// panel, dialog, a `<Cascade.Item>`). // panel, dialog, a `<Cascade.Item>`).
// · container-driven (ENTER only) — the EVENT-DRIVEN cascade: direct // · container-driven — the EVENT-DRIVEN cascade: direct children of a
// children of an opening `[data-stagger]` container that carry this // `[data-stagger]` container inherit the container's `data-state`, indexed
// preset enter together, indexed FROM STRUCTURE (`:nth-child` → // FROM STRUCTURE (`:nth-child`). No per-item `data-state` (a `menuitem` isn't
// `--motion-stagger-index`). No per-item `data-state` (a `menuitem` // open/closed); the container's state — which soma writes via the morfo
// isn't open/closed); the container's state — which soma already writes // `commits` — drives the children. ENTER cascades in with
// via the morfo `commits` — drives the children. // `--motion-stagger-index` (first item first); EXIT cascades OUT in reverse
// ENTER only: children mount together so enter needs no coordination; the // with `--motion-stagger-index-rev` (last leaves first) — `phaseDeclarations`
// coordinated EXIT (retain the container until its children finish) is a // picks the right index per phase.
// soma lifecycle concern (PresenceGroup), still deferred — so a closing //
// container must NOT drive a child exit that would be cut off on unmount. // 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}']`] const selectors = [`[data-animation-style='${name}'][data-state='${state}']`]
selectors.push(`[data-stagger][data-state='${state}'] > [data-animation-style='${name}']`)
if (phase === 'enter') { 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( rules.push(
renderBlock( renderBlock(
@ -855,10 +914,17 @@ function renderReducedMotionRules(name: string, preset: CssStatePreset): string[
: phase === 'enter' : phase === 'enter'
? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;'] ? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;']
: ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;'] : ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;']
const selector = `[data-animation-style='${name}'][data-state='${state}']` const selectors = [`[data-animation-style='${name}'][data-state='${state}']`]
rules.push(renderBlock(`[data-motion='reduce'] ${selector}`, inner)) // 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( 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 return rules

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { validateEidosConfig } from './lib/config'; import { validateEidosConfig } from './lib/config';
import { createEngineMotion } from '$motion'; 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 { BUILTIN_JS_PRESETS } from './lib/motion/presets/js';
import { renderStaticCss } from './lib/render-css'; import { renderStaticCss } from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base'; 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)', () => { it('emits the Material transition presets (shared-axis + fade-through)', () => {
expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`); expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`);
expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`); expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`);
@ -81,12 +118,29 @@ describe('eidos motion — CSS generation', () => {
`[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']` `[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']`
); );
expect(css).toMatch( expect(css).toMatch(
/\[data-motion='reduce'\] \[data-animation-style='collapse'\]\[data-state='open'\] \{\s*animation: none !important;/ /\[data-animation-style='collapse'\]\[data-state='open'\][^{]*\{\s*animation: none !important;/
); );
// 'none' presets (fade) get NO per-preset reduced override // 'none' presets (fade) get NO per-preset reduced override
expect(css).not.toContain(`[data-motion='reduce'] [data-animation-style='fade'][data-state`); 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', () => { it('emits event-moment signatures (data-event-*) from motion.signatures', () => {
const withSig = renderStaticCss({ const withSig = renderStaticCss({
...createThemeBaseEidosConfig(), ...createThemeBaseEidosConfig(),
@ -265,10 +319,19 @@ describe('eidos motion — F7 typegen registry', () => {
'shared-axis-x', 'shared-axis-x',
'shared-axis-y', 'shared-axis-y',
'fade-through', 'fade-through',
'select-pop',
'spin',
'pulse',
'ping',
'bounce',
'spring-pop' 'spring-pop'
] as const satisfies readonly (keyof EidosMotionPresets)[]; ] as const satisfies readonly (keyof EidosMotionPresets)[];
expect( 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()); ).toEqual([...registered].sort());
}); });
}); });

Loading…
Cancel
Save

Powered by TurnKey Linux.