docs(motion): RFC §D.12 (universal `motion` prop, 3 domains) + §D.13 (structural constraints)

Captures the design framing agreed this session, BEFORE implementation:

§D.12 — One universal `motion` prop on any component; the discriminant ("does the
animation realize a perceptual event?") decides the domain: event (firma, morfo+sema,
the prop is override/violation) / state (data-state preset) / content (prop is
primary). Supersedes §5's M5/M6 "motion vs animation separate" — its justification
(the coordinated `cascade-*` system) was retired in Plan A. Honest positioning:
content/state = parity with Framer/SwiftUI; event = above (motion coupled with
sound/haptic from one evaluated signal). Caveat: the JS engine (spring/handoff) is
built but dormant — the "above" on the engine axis needs a first spring preset.

§D.13 — The contracts the CSS model demands + failure modes, separating what the
mechanics already guard from what's deferred:
- structure: `:nth-child` ignores comments/`{#if}` (guarded); intermediate ELEMENT
  wrapper breaks the count (the contract); a cascading wrapper becomes its own scope
  (`inherits:false` isolates the index); `display:contents` = known dead-zone.
- exit: animated surface exits via Presence/`out:`, NEVER raw `{#if}`; unit-exit works
  today (parent opacity carries the children); per-child staggered exit needs JS
  lifecycle (PresenceGroup, deferred) → the container-driven rule is enter-only.
- debug: the index is `inherits:false` (no ancestor can step on it); a
  `[data-debug-stagger]` mode to surface index/delay/name is pending.

Pointer added in eidos-motion.md. No code changed — design record only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 2a378360e6
commit efb20a9c5f

@ -246,6 +246,11 @@ parts con `surface: true` y que las partes referenciadas existan.
## 5. Variants separables + naming (`animation` ↔ `data-animation-style`)
> **SUPERSEDED por §D.12.** La decisión M5/M6 de abajo («`motion` y `animation` separadas por rol»)
> asumía el sistema **coordinado** (`MotionCoordinatedPresets`/`cascade-*`), **retirado en el Plan A**
> (§D.8). Sin él queda UN solo sistema → **un solo prop `motion`** (§D.12). El objetivo de DX y de
> separabilidad de abajo SIGUE vigente; lo obsoleto es la dualidad de nombres.
**El objetivo de DX:** el dev escribe `animation="scale-fade"` en **cualquier** componente
animable y el sistema lo **enruta** a la superficie declarada (la part con `surface: true`),
sin que el dev sepa qué part es. "El sistema ya sabe dónde."
@ -1435,6 +1440,130 @@ que ya existían (+ le faltaba reduced-motion + un `getComputedStyle` crudo viol
`<Cascade>` quedó como orquestador fino del dominio explícito (consume el sistema, no lo duplica). Verificado en
navegador + `npm run check`/`motion.test`/eidos-lint limpios.
#### D.12 — El prop `motion` UNIVERSAL: un selector, tres dominios
> **Encuadre de diseño (acordado 2026-06-20, aún SIN implementar).** Supersede el §5 del cuerpo
> (decisión M5/M6 «`motion` y `animation` separadas por rol») — que asumía el sistema **coordinado**
> (`MotionCoordinatedPresets`/`cascade-*`/`data-starting-ending-style`) **retirado en el Plan A** (§D.8).
> Sin el coordinado ya no hay dos sistemas visuales que justifiquen dos nombres: queda UNO (state-presets
> sobre `data-animation-style`). Esta sección fija qué se construye.
**La tesis — un solo prop `motion: MotionPresetName`, en CUALQUIER componente.** Hoy el selector existe
pero (1) tiene dos nombres (`motion` en overlays, `animation` en `<Cascade>`) — vestigio del coordinado
muerto — y (2) solo lo consumen los componentes cableados (overlays + cascade + menús). La meta: **un
nombre (`motion`)** + **universalidad** (un Button, un Card, un Badge pueden recibir `motion="X"` y
materializarlo). El registro (`EidosMotionPresets` + `EidosConfig.motion.presets`) ya es universal y
type-safe (autorar→registrar→autocompletar en todo prop); falta que el **prop y la consumición** lo sean.
**Pero `motion` NO significa lo mismo en todo componente — el DISCRIMINANTE decide el dominio.**
La pregunta única (la misma de los 3 dominios, [`eidos-motion.md`] / memoria del rediseño):
> ¿La animación realiza un EVENTO perceptivo —algo **aparece** / se **fija** / **reclama atención** / **sigue en curso**?
| Dominio | Quién la dispara | Quién la posee | El prop `motion` es… |
| -------------- | ----------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------- |
| **evento** | `data-event-*` (durante el hold de sema) | morfo (la declara) + sema (firma: +sonido/háptico) | **override explícito** o **violación** (si esquiva la capa semántica) |
| **estado** | `data-state` (open/closed, checked…) | el state-preset (recipe) | **selecciona** qué preset reacciona al estado |
| **contenido** | nada — corre sola (loop / on-mount) | el prop, directo | **vía PRIMARIA** — no hay evento ni estado con qué componerse |
Canon del ejemplo: un **spinner** es evento (`sustain` — "proceso vivo"); un **logo que late** es contenido
(no comunica nada operativo). Mismo SVG, dominios distintos; la pregunta los separa.
**El guard (override vs violación).** Cuando un componente DECLARA un evento que debe portar la animación,
`motion="X"` es **override** si el diseñador cambia conscientemente la firma de ese evento (permitido,
explícito), o **violación** si usa el prop para animar algo que DEBERÍA declararse como evento (lo cazan
tipos/lint — el componente declara qué animaciones pertenecen a su evento). **Sin evento no hay nada que
violar: el prop es primario.** Es la regla "prop = override/violación cuando hay evento que la porte" del
rediseño, materializada.
**Por qué esto pone a UIX conceptualmente por delante (honesto, no hype).** En el eje "animación nombrada,
reutilizable, sobre cualquier superficie" hay **paridad** con Framer (`variants`) y SwiftUI (`.transition()`).
Lo distintivo NO es el selector — es que **el mismo prop uniforme sabe en qué dominio está**:
- contenido / estado → paridad limpia (prop directo + registro type-safe);
- **evento → por encima**: la animación sale coordinada con sonido + háptico de la misma señal evaluada
(lo que SwiftUI deja DESCONECTADO en `.sensoryFeedback`; lo que Framer no tiene capa para hacer);
- y un **discriminante principista** decide cuál, en vez de dejarlo al criterio del call-site.
Nadie más traza esta línea —evento-acoplado-a-percepción vs contenido-decorativo— **bajo un selector único**.
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).
**Plan de implementación (a)+(b) — acotado a eidos, NO el `PresenceGroup` pesado.**
- **(a) Unificar el nombre.** `<Cascade>` `animation` → `motion`; confirmar que ningún otro wrapper use
`animation`. El catálogo ya es `MotionPresetName`.
- **(b) Universalizar.** Un mecanismo uniforme para que cualquier componente acepte `motion` y emita
`data-animation-style` (un helper/acción que los wrappers compongan, o un wrapper-primitivo `<Motion>`).
Decidir cómo dispara el dominio **contenido** (sin `data-state`): un attr/clase que corra el keyframe
on-mount o en loop, porque hoy los presets reaccionan SOLO a `data-state` (montaje).
- **(c) El guard.** Tipos/lint: un componente con evento-que-posee-la-animación marca el prop como override;
los demás, primario.
- 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.
#### D.13 — Restricciones estructurales y modos de fallo (los contratos que el modelo CSS exige)
> **Encuadre de diseño (acordado 2026-06-20; D.13.1 ya vive en código, D.13.2/.3 con piezas DIFERIDAS).**
> El modelo declarativo (§D.11/§D.12) mueve a HTML/CSS lo que el motor paralelo retirado resolvía por
> fuerza bruta en JS. Eso exige **contratos estrictos**. Esta sección los fija y nombra los modos de fallo,
> separando lo que la mecánica **ya blinda** de lo **diferido** — para que las grietas se descubran en el
> contrato, no en producción.
**D.13.1 — Contrato de estructura (`:nth-child` ↔ hijos directos).**
- **Blindado por spec:** `:nth-child` cuenta SOLO elementos — ignora texto y comentarios. Los anclas `{#if}`
de Svelte (nodos-comentario) y el whitespace **no desincronizan** el índice. El renderizado condicional de
un *elemento* reindexa a los hermanos siguientes, lo cual es **correcto** (el escalonado refleja el set
realmente renderizado).
- **El contrato:** la cascada vincula un contenedor `[data-stagger]` con sus **hijos DIRECTOS** que llevan
`data-animation-style`. El componente **posee** esa estructura (como `<select>` posee sus `<option>`). Un
wrapper-elemento intermedio inyectado entre contenedor e ítems rompe el conteo — es **romper el contrato**,
no DOM arbitrario.
- **Wrapper-que-cascadea = su propio scope:** un agrupador que SÍ debe escalonar se marca a sí mismo
`[data-stagger]` → índice fresco de SU `:nth-child` (`--motion-stagger-index` es `@property … inherits: false`,
el índice del ancestro **no se filtra**) + su propio `--motion-stagger-each`. La regla container-driven es
`>` solo-hijo-directo → nunca alcanza nietos. El wrapper deja de ser bug y pasa a coreografía anidada.
- **Dead-zone conocida:** un wrapper `display:contents` que NO se re-scopea (ej. el `<Group>` del menú) — sus
ítems no son `:nth-child` del panel ni de un stagger propio → aparecen instantáneos. Documentado y aceptado
hasta `sibling-index()`.
**D.13.2 — Política de exit (la guillotina del desmontaje).**
Verdad-base del CSS: **no animas lo desmontado.** TODA salida exige un primitivo de retención; el modelo no lo
inventa — se apoya en los que ya existen.
- **Regla dura:** una superficie animada sale por **`Presence` (overlays) o `out:` (Svelte)**, **NUNCA por
`{#if}` crudo.** Un `{#if}` que desmonta directo = guillotina (cae incluso el unit-exit).
- **Unit-exit (barato, HOY):** `Presence` retiene el nodo, pone `data-state="closed"` y espera
`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`.
**D.13.3 — Afford de debug (`[data-debug-stagger]`).**
El peaje del declarativo: sin error en consola ni breakpoint JS. Matices + mitigación:
- **El miedo «una property heredada pisa el índice» está blindado:** `--motion-stagger-index` es
`inherits: false` → el índice de un ancestro **no puede** filtrarse. La única que hereda (a propósito, para
fijarse una vez en el contenedor) es el ritmo `--motion-stagger-each`.
- **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.
---
> **Fuentes.** Canon semántico: [`docs/CANON.md`](../../../docs/CANON.md). Arquitectura de

@ -169,6 +169,25 @@ nombre ajeno, no animar sobre los dos ejes.
> ortogonales (sema emite · motion es el motor · eidos materializa) + el lifecycle completo:
> [§D.11 del RFC](./MOTION_SERVICE_RFC.md).
> **Prop `motion` UNIVERSAL — un selector, tres dominios (encuadre de diseño, [RFC §D.12]).** Tras
> retirar el coordinado, queda UN sistema → **un solo prop `motion`** (hoy `<Cascade>` aún usa
> `animation`; se unifica). La meta: **cualquier** componente (no solo overlays) puede recibir
> `motion="X"` de un catálogo registrado type-safe. Qué significa el prop lo decide el
> **discriminante** «¿la animación realiza un evento perceptivo?»: **evento** → la firma manda, el
> prop es override/violación (tipos/lint); **estado** → selecciona el state-preset (`data-state`);
> **contenido** (sin evento) → el prop es la **vía primaria**. El acoplamiento con sonido/háptico
> solo aplica al dominio evento — los de contenido quedan en paridad limpia. Detalle + plan (a)+(b):
> [§D.12 del RFC](./MOTION_SERVICE_RFC.md).
> **Restricciones estructurales del modelo CSS — [RFC §D.13].** Los contratos que el declarativo exige:
> (1) **estructura** — `[data-stagger]` ↔ hijos DIRECTOS; `:nth-child` ignora comentarios/`{#if}` (blindado),
> pero un wrapper-elemento intermedio rompe el conteo → el agrupador que cascadea se vuelve su propio scope
> (`inherits:false` aísla el índice); `display:contents` sin re-scope = dead-zone. (2) **exit** — unit-exit por
> `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.
---
## 3. Motion en las 4 capas

Loading…
Cancel
Save

Powered by TurnKey Linux.