docs(motion): RFC Apéndice C — M9 children-DOM mode + dropdown-menu README + eidos-motion cross-link

Documents the second coordination mode (DomCascade) wired in M9 F1/F1c/F2: the design, the contract (staggerChildren), pending() exit-heavy, the dropdown-menu cascade, and the four coexistence fixes (transition collision, disabled opacity, dismiss-fade panel signature, trigger/dismissal toggle). Updates the status note, TOC and §15 roadmap; adds the children-DOM cross-link to eidos-motion.md and an opt-in 'animation' Motion section to the soma README.

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

@ -21,11 +21,13 @@
> - **eidos** posee el _QUÉ visual_ y los _valores expresivos_ (keyframes, easing, stagger-ms);
> - **arts/motion** ejecuta primitivas **por-nodo** (no orquesta, no gana jerarquía).
>
> **Estado: M1–M4 implementados + incrementos M5/M6** (contrato morfo · `PresenceGroup` · exit con
> retención de DOM · interrupción/reversa §8.3 · enrutado de la prop `animation` a las superficies
> declaradas · stagger auto-derivado del orden de coordinación). Primer consumidor real: `Reveal`
> (§15). El resto de M5/M6–M9 pendiente — el roadmap (§15) define las fases. Todo es **aditivo y
> opt-in**: un componente sin la nueva declaración funciona exactamente como hoy.
> **Estado: M1–M4 + M5/M6 + M9 implementados** (contrato morfo · `PresenceGroup` · exit con
> retención de DOM · interrupción/reversa §8.3 · enrutado de la prop `animation` · stagger
> auto-derivado · **segunda coordinación: el modo children-DOM, `DomCascade`, cableado en el
> `dropdown-menu` real — Apéndice C**). Consumidores reales: `Reveal`/`Rail` (modo registered-
> `Presence`, §15) y `dropdown-menu` (modo children-DOM, Apéndice C). Pendiente: M7 (reduced-motion
> + SSR hardening) · M8 (text-effects) · F3 (submenús). Todo es **aditivo y opt-in**: un componente
> sin la nueva declaración funciona exactamente como hoy.
## Tabla de contenidos
@ -48,6 +50,7 @@
- [15. Roadmap de implementación por fases](#15-roadmap-de-implementación-por-fases)
- [Apéndice A — decisiones resueltas vs abiertas](#apéndice-a--decisiones-resueltas-vs-abiertas)
- [Apéndice B — Convivencia con la firma sema (los tres sistemas visuales)](#apéndice-b--convivencia-con-la-firma-sema-los-tres-sistemas-visuales)
- [Apéndice C — M9: el modo children-DOM (la segunda coordinación)](#apéndice-c--m9-el-modo-children-dom-la-segunda-coordinación)
---
@ -639,7 +642,11 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni
(acotada, diferida).**
- **M7 — Reduced-motion + SSR/hydration hardening** (§10).
- **M8 — text-effects como variants de contenido** (§11).
- **M9 — Migración de los pilotos** (Dialog + lista/menú) y documentación de patrón.
- **M9 — Segunda coordinación: el modo children-DOM** — HECHO (F1/F1b/F1c/F2, commit `d797a0ce`).
El `DomCascade` propaga el lifecycle del owner sobre ítems DOM descubiertos por selector;
cableado en el `dropdown-menu` real (cascade de filas, exit-heavy con `pending`, + 4 fixes de
convivencia). Detalle completo en el **Apéndice C**. Pendiente F3: submenús (`sub-content` como
segundo owner). El otro piloto (Dialog) usa el modo registered-`Presence` y queda para después.
> **Validación end-to-end — primer consumidor real (`Reveal`).** Antes de M5/M6 se cableó un
> componente REAL como primer consumidor del contrato: `Reveal` (disclosure list — morfo
@ -830,6 +837,142 @@ Son **dos nodos** (el `Rail.Item` que se anima vs el `<Button>` dentro) y **dos
---
## Apéndice C — M9: el modo children-DOM (la segunda coordinación)
> **Estado: IMPLEMENTADO y verificado en navegador real (commit `d797a0ce`).** F1 (esqueleto +
> contrato + `DomCascade`), F1b (demo aislado), F1c (exit-heavy con `pending`), F2 (el
> `dropdown-menu` real). Cubre el segundo de los dos modos de coordinación del servicio. F3
> (submenús) queda fuera.
### C.1 — Por qué un segundo modo
El `PresenceGroup` (§7) coordina **superficies-`Presence` registradas**: cada hijo animable es un
`Presence` que se registra en el grupo por context (Reveal/Rail). Pero un **menú o lista real** no
tiene esa forma: tiene **UNA** superficie `Presence` (el _content_/overlay que monta como unidad) y
sus **ítems son DOM plano** que el runtime no envuelve — se descubren por selector (`getItems`/
`querySelectorAll`), markup arbitrario del consumidor. No hay nada que registrar en un grupo, ni un
sitio de binding Svelte donde poner `--motion-stagger-index`.
**3 grietas que el menú destapó** (una por capa):
1. **registro** — no hay un `Presence` por ítem que registrar en un grupo.
2. **index per-ítem** — el preset de eidos hace `calc(var(--motion-stagger-index) * …)`; en
Reveal/Rail lo pone un binding Svelte (`style:--motion-stagger-index`), inaplicable a ítems DOM
arbitrarios del consumidor.
3. **escritura de CSS-var por elemento** — `ActiveDom` no tenía un método para escribir una
custom property en UN elemento (`apply` = attrs/data-\*; `writeStyle` = `<style>` global; la
doctrina prohíbe `el.style` directo). Se añadió `writeProperty`/`removeProperty` a `$adom`
(el usuario aprobó extender adom para esto — sema NO se toca, adom SÍ).
### C.2 — El diseño: el owner propaga su lifecycle a los ítems DOM
En vez de registrar ítems, el **owner `Presence` PROPAGA su lifecycle** sobre cada ítem DOM,
**reutilizando el preset coordinado de eidos SIN cambios**. Mientras el owner está
`data-starting/ending-style`, el propagador escribe en cada ítem los mismos attrs que un child-
`Presence` habría producido — `data-animation-style` (el preset ruteado) + el `data-starting/
ending-style` ESPEJADO del owner + `--motion-stagger-index`/`-count` por orden DOM — de modo que
`[data-animation-style='cascade-X'][data-starting-style]` matchea y la cascada corre. El owner
`Presence` ya posee el TIMING (starting → next-frame → transición); el propagador solo espeja.
**`DomCascade`** (`src/uix/soma/layers/dom-cascade.svelte.ts`) es ese propagador:
```ts
new DomCascade({
dom, // ActiveDom
animationStyle, // Active<string|undefined> — el preset ruteado; undefined ⇒ inerte
ownerTransitionAttrs, // Active<…> = Presence.transitionAttrs del owner
items // () => readonly HTMLElement[] — los nodos a cascadear, en orden DOM
})
```
- `sync/clear` son PUROS sobre `opts.dom` (testeables sin componente, server-test con un mock).
- `watch()` corre el `$effect` reactivo (de ahí el `.svelte.ts`): 3 ramas por prioridad —
`items===0` (`clear`) · `ending` (espeja el exit) · `appeared && style` (`beginEnter`). El
`$effect` de lógica **NO tiene cleanup** (un `clear()` por re-run cortaba el enter a media
transición); el teardown vive en un 2º `$effect` sin-deps.
- **`beginEnter`** conduce el enter de filas recién montadas: estas montan en el ON-state, así que
estampar el off-state con la transición ACTIVA haría que el opacity **interpole** hacia 0 (y,
soltado un frame después, apenas se mueva). En su lugar estampa el off-state con `transition:
none` (SALTO instantáneo) → fuerza un reflow → restaura la transición y suelta
`data-starting-style` → las filas hacen _ease in_ con el stagger. (El `Presence` no sufre esto:
su `data-starting-style` está en el render de montaje, que nunca transiciona.)
- **`pending()`** (F1c) — el cabo del §9: el owner `getAnimations()` NO ve las transiciones de los
ítems (son descendientes DOM, no el nodo owner; el motor sigue sin `{subtree:true}`). `pending()`
agrega los `finished` de los ítems en UNA promesa (diferida 2 frames para que las transiciones de
salida ya hayan registrado), que el owner `Presence` espera vía `PresenceOptions.pending` →
retiene el subárbol hasta que la cascada de salida settlea. Usa
`Promise.all(finishers.map(f => f.then(noop, noop)))` (settle, no `Promise.all` crudo) para que
una transición cancelada aislada no colapse la espera.
**El contrato (morfo).** La parte owner declara
`animation: { surface: true, staggerChildren: '<item-kebab>' }` (`MorfoPart.animation.staggerChildren`,
validado en `schema.ts`: requiere `surface:true`, mutuamente excluyente con `children`). Es el
PRIMER consumidor real de `staggerChildren`. El valor nombra la item-part líder; **el provider
resuelve los nodos DOM** (puede ampliar a todas las filas visibles — ver C.3).
### C.3 — F2: el `dropdown-menu` real + los fixes de convivencia
El owner es el `content` (ya era un `Presence` isla); los ítems son sus filas, descubiertas por un
`getCascadeRows` (TODAS las filas visibles — item/checkbox/radio/sub-trigger + separators +
group-headings, incl. disabled, scoped al content excluyendo submenús). Prop pública opt-in
`animation?: CoordinatedPresetName` ruteada al `DomCascade`; `undefined` ⇒ el menú se comporta como
siempre. El `pending` se cablea reenviándolo por `createFloatingShellRoot` (los otros 6 floating
consumers quedan byte-idénticos). **El menú destapó CUATRO colisiones**, cada una con su fix —
todas son del componente, no del motor, y aditivas:
1. **Colisión de `transition`.** Las filas declaran `transition: background, color` (hover); el
preset coordinado anima con `transition: opacity, transform` — MISMA propiedad shorthand, y el
CSS de componente carga DESPUÉS del preset generado → la regla de la fila gana y BORRA la
cascada. Fix: la fila base usa **longhands** (`transition-property/duration/timing`, sin
`transition-delay` — el shorthand lo reseteaba a 0s y mataba el stagger) + una regla
`[data-…-item][data-animation-style]` (specificity 0,2,0) que cede `transition` al preset.
2. **Opacidad de disabled.** Un `[data-disabled]` tiene `opacity` dim al mismo specificity que el
off-state → ganaba y fijaba la fila, que no podía desvanecerse. Fix: gatear el opacity dim con
`:not([data-starting-style]):not([data-ending-style])` para que el off-state (0) se vea durante
la cascada.
3. **La firma `dismiss-fade` del PANEL.** El content tiene su propia `CSSAnimation` `dismiss-fade`
(firma sema del evento `close`) que desvanece TODO el panel en ~240ms → con él se van las filas
que cascadeaban dentro («cierra de una»). La neutralización de firma del preset
(`[data-animation-style][data-event-phase='active'] { animation: none !important }`) SOLO cubre
las filas (tienen `data-animation-style`), NO el panel. Fix: el provider estampa `data-cascade`
en el content (vía `$effect` + `dom.apply`) sii hay `animation` ruteado, y eidos neutraliza
`[data-dropdown-menu-content][data-cascade] { animation: none !important }` +
`[…][data-cascade][data-state='closed'][data-ending-style] { opacity: 1 }` (evita el colapso
instantáneo del `[data-state='closed']{opacity:0}` durante el exit). El panel se queda quieto y
desmonta solo cuando `pending` settlea.
4. **El toggle no cerraba** (preexistente). El `Dismissal` cierra en `pointerdown` + el `onclick`
del trigger reabre. Fix: el provider excluye el trigger del dismissal
(`runtime.partRef('trigger')` + `contains` en `onInteractOutside`).
### C.4 — Doctrina / lecciones (children-DOM)
- **El espejado pasivo del `transitionStatus` del owner solo captura el enter si los hijos existen
durante la fase `starting`** (efímera, 1 rAF). Hijos descubiertos por DOM/selector montan tarde
→ el cascade debe CONDUCIR su propio enter (`beginEnter`), no espejar.
- **Un elemento que monta en on-state no puede recibir el off-state con la transición activa** —
interpola en vez de saltar. Hay que SUPRIMIR la transición (`transition:none` + reflow) al
estampar el off-state del ENTER, y restaurarla al soltar. El EXIT es lo contrario (quiere
interpolar) → transición activa.
- **La neutralización de firma sema del preset solo alcanza a quien lleva `data-animation-style`.**
El owner (panel) la necesita por separado (un marcador propio: `data-cascade`), o su firma
`dismiss-fade`/keyframe colapsa el panel antes de que las filas terminen.
- **Un `$effect` que escribe DOM con cleanup `clear()` se auto-interrumpe en cada re-run.** Separar
lógica (sin cleanup) de teardown (effect sin-deps).
- **Diagnóstico:** con el preview MCP roto y estados transitorios, un sampler de `opacity`/
`getAnimations()` frame-a-frame en navegador headless real (Playwright) fue lo decisivo; los
observadores/snapshots y los `console.log` capturados ayudaron pero no bastaron.
### C.5 — Demos
- [`/temas/animations/dom-cascade`](../../../web/routes/temas/animations/dom-cascade/+page.svelte) —
el modo children-DOM AISLADO (owner `Presence` + ítems DOM estáticos + `DomCascade.watch()`),
validó F1b (enter) y F1c (exit-heavy con `pending`) antes del menú.
- [`/temas/animations/dropdown-menu`](../../../web/routes/temas/animations/dropdown-menu/+page.svelte)
— el `<DropdownMenu>` REAL con la prop `animation` + selector de preset + ritmo; convive con
focus-trap / dismissal / submenús.
---
> **Fuentes.** Canon semántico: [`docs/CANON.md`](../../../docs/CANON.md). Arquitectura de
> capas: [`active_architecture.md`](../active_architecture.md). Motion actual (que este RFC
> extiende): [`eidos-motion.md`](./eidos-motion.md). Motor: `src/arts/motion/README.md`.

@ -165,6 +165,10 @@ nombre ajeno, no animar sobre los dos ejes.
> ownership. Cómo se componen los **tres** sistemas —y por qué un componente
> coordinado **silencia** su firma genérica (`channels: []`)— está en el
> [Apéndice B del RFC](./MOTION_SERVICE_RFC.md#apéndice-b--convivencia-con-la-firma-sema-los-tres-sistemas-visuales).
> El coordinado tiene **dos modos**: superficies-`Presence` registradas (Reveal/Rail) y
> **children-DOM** (un owner propaga su lifecycle sobre ítems DOM descubiertos por selector —
> el `dropdown-menu`), en el
> [Apéndice C del RFC](./MOTION_SERVICE_RFC.md#apéndice-c--m9-el-modo-children-dom-la-segunda-coordinación).
---

@ -138,6 +138,27 @@ A menu of actions triggered by a button. Supports submenus, checkbox items, radi
| `ArrowLeft` (LTR) / `Escape` | Close submenu, focus SubTrigger |
| `ArrowDown` / `ArrowUp` | Navigate items within submenu |
## Motion — item cascade (opt-in)
`<DropdownMenu.Provider animation="cascade-slide">` (a `CoordinatedPresetName` — `cascade-slide` /
`-fade` / `-scale`) staggers the menu **rows** in on open and out on close. Omitted ⇒ the menu uses
its plain panel chrome, unchanged.
This is the motion service's **children-DOM coordination mode** (RFC §M9 / Apéndice C). The Content
is the owner `Presence`; its rows are static DOM, so there is no per-row `Presence` to coordinate.
Instead a `DomCascade` (`soma/layers/dom-cascade.svelte.ts`) propagates the owner's lifecycle onto
each row — it writes `data-animation-style` + the mirrored `data-starting/ending-style` +
`--motion-stagger-index`/`-count` over `getCascadeRows()` (every visible row incl. disabled), so the
generated `cascade-*` preset just works. On close the owner holds the panel until the exit cascade
settles (`pending`). The morfo declares it: the `content` part carries
`animation: { surface: true, staggerChildren: 'item' }`.
When `animation` is set the provider also stamps `data-cascade` on the content so eidos can suppress
the panel's OWN signature (`dismiss-fade`) — otherwise the panel fades whole, taking the rows
cascading inside it, before the stagger finishes. Full design + the coexistence fixes (transition
collision, disabled opacity, dismiss-fade, the trigger/dismissal toggle) are in
[RFC Apéndice C](../../../eidos/MOTION_SERVICE_RFC.md#apéndice-c--m9-el-modo-children-dom-la-segunda-coordinación).
## Sema events
| Event | Family | Verb | Target | Intent | Sequence | When |

Loading…
Cancel
Save

Powered by TurnKey Linux.