feat(motion): event-driven item cascade — first real consumer (DropdownMenu)

Productizes the event-driven cascade on a real component (not the explicit
`<Cascade>` orchestrator). When a DropdownMenu opens, its items fade in
staggered by structural order — the appearance flows from the `open` (emerge)
event, nothing in the app wires it.

Architecture: a +1 GENERIC selector branch in the motion generator, NOT a
per-item `data-state`. A `menuitem` isn't open/closed — the menu is — and eidos
writing a state attr is a smell. The new container-driven form
`[data-stagger][data-state='open'] > [data-animation-style='X']` fires each
child's ENTER off the container's `data-state` (which soma already writes via
the morfo `commits`); items carry only `data-animation-style`. ENTER only: the
coordinated EXIT cascade (retain the container until children finish) is the
deferred soma PresenceGroup work, so a closing container can't drive a child
exit that would be cut off on unmount.

- render-css.ts: `renderCssPresetRules` adds the container-driven enter branch
  (same declarations, shared comma selector) — reuses the existing presets +
  the foundation `:nth-child` stagger index; zero new system.
- DropdownMenu: Content marks itself `[data-stagger]` +
  `--motion-stagger-each: var(--motion-stagger)` (20ms, themeable, no magic
  number); item/checkbox-item/radio-item carry `data-animation-style="fade"`
  (overridable). `fade` so item opacity doesn't fight the panel's own scale.
- regen generated/base.css (8 css presets gain the container-driven enter).

Verified in browser (frozen frame): index from `:nth-child`, `delay = idx ×
each`, opacity gradient in flight (Ruler 0.98 → Grid 0.89 → Guides 0.69, Log
out 0.00). Honest limit (documented): only DIRECT-child items cascade — items in
a <Group>/<RadioGroup> DOM wrapper aren't `:nth-child` of the panel, so they
appear instantly (the "transparent intermediate").

Docs: dropdown README §Motion, cascade README §2 (concrete consumer), RFC §D.11.4.

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

@ -1380,11 +1380,29 @@ La cascada NO es un sistema nuevo. Es el preset declarado + el stagger que ya ex
lineal es el VALOR del token, en el estilo del componente.
- **La animación:** el preset que el item lleva en `data-animation-style` (declarado en EidosConfig.motion).
Keyframe + duración + reduced-motion: del preset, gratis.
- **Las DOS formas de selector (en cada preset, mismas declaraciones):**
- *self-driven* — `[data-animation-style='X'][data-state='Y']`: una superficie suelta que lleva su PROPIO
`data-state` (el panel Content, un dialog, un `<Cascade.Item>`).
- *container-driven (solo ENTER)* — `[data-stagger][data-state='open'] > [data-animation-style='X']`: la
cascada event-driven. Los hijos llevan SOLO `data-animation-style`; **no** un `data-state` por-item (un
`menuitem` no es open/closed — la que abre es la lista). El `data-state` del **contenedor** —que soma ya
escribe vía el `commits` del morfo— dispara el enter de los hijos. Es **+1 rama de selector genérica** en
`render-css.ts`, no un sistema nuevo. Solo ENTER: los hijos montan juntos (enter sin coordinación); el EXIT
coordinado (retener el contenedor hasta que los hijos terminen) es trabajo de soma (`PresenceGroup`), aún
diferido — un contenedor que cierra NO debe disparar un exit de hijos que se cortaría al desmontar.
**Dos dominios para usarlo:** (1) **explícito** — el componente `<Cascade>` (orquestador fino, sin recipe ni
`--cascade-*`): marca `[data-stagger]`, refleja `open`→`data-state`, pasa el preset a los items por contexto;
(2) **event-driven** — un componente real declara en SU morfo el evento `emerge` + `data-stagger` en el
contenedor; sus items llevan el preset + `data-state`. La aparición sale del evento declarado, sin wrapper.
`--cascade-*`): marca `[data-stagger]`, refleja `open`→`data-state`, pasa el preset a los items por contexto
(usa la forma self-driven + un `out:` por-item para el quitado individual); (2) **event-driven** — un componente
real declara en SU morfo `emerge` + marca su contenedor `[data-stagger]` en eidos; sus items llevan SOLO el
preset y cascadean por la forma container-driven. La aparición sale del evento declarado, sin wrapper.
**Primer consumidor real (event-driven): `<DropdownMenu>`** — los items hacen fade escalonado al abrir
(`src/uix/eidos/components/dropdown-menu/`: Content `[data-stagger]` + `--motion-stagger-each: var(--motion-stagger)`,
items `data-animation-style="fade"`). Verificado en navegador: índice desde `:nth-child`, `delay = idx ×
each`, gradiente de opacidad en vuelo (Ruler 0.98 → Grid 0.89 → Guides 0.69, Log out 0.00). Limitación honesta:
solo cascadean los items que son **hijos directos** del panel — los envueltos en `<Group>`/`<RadioGroup>` (un
elemento DOM real) no son `:nth-child` del contenedor y aparecen instantáneos (el «intermedio transparente»).
#### D.11.5 — El servicio `motion` es el motor (par/superior a Framer)

@ -69,10 +69,19 @@ duration, then unmounts. Accepts any `HTMLAttributes<HTMLDivElement>`.
semantic event. The content/intrinsic animation domain.
2. **Event-driven** — a real component (a menu, a list) declares its own `emerge` firma (the
container flourish + sound) **and** marks its item container `[data-stagger]`; its items
carry a preset + the container's `data-state`. Same foundation stagger, no `<Cascade>`
wrapper — the appearance flows from the declared event. (The firma is *generic by event*:
"children of an emerging container present together, by structural order"; the concrete
timing — parallel/cascade, ms, keyframe — is the per-component realization.)
carry a preset (`data-animation-style`). Same foundation stagger, no `<Cascade>` wrapper —
the appearance flows from the declared event. The container-driven preset rule
`[data-stagger][data-state='open'] > [data-animation-style]` fires the children's enter off
the *container's* `data-state` (which soma already writes via the morfo `commits`), so the
items carry **no per-item `data-state`** — a `menuitem` isn't open/closed; the menu is.
(The firma is *generic by event*: "children of an emerging container present together, by
structural order"; the concrete timing — parallel/cascade, ms, keyframe — is the
per-component realization.)
**First concrete consumer: `<DropdownMenu>`** — items fade in staggered on open
(`src/uix/eidos/components/dropdown-menu/`). See its README → *Motion*. The container-driven
rule is **enter-only**; the coordinated *exit* cascade (retain the container until its
children finish) is the deferred soma `PresenceGroup` work.
## Known limitation — bulk removal

@ -76,6 +76,43 @@ Soma owns:
- Modal / dismissal behaviour (overlay click, outside interact)
- Sub-menu cross-axis navigation (ArrowRight opens, ArrowLeft closes)
## Motion — event-driven item cascade
When the menu opens, its items **fade in staggered by structural order** — the
first concrete consumer of the framework's *event-driven* cascade (vs the
explicit `<Cascade>` orchestrator). Nothing in the app wires it; the appearance
**flows from the `open` (emerge) event**:
- `<DropdownMenu.Content>` marks its panel `[data-stagger]`.
- each item carries `data-animation-style="fade"`.
- the panel sets the rhythm `--motion-stagger-each: var(--motion-stagger)` (the
canonical 20ms).
The per-item **index comes from CSS structure** (`[data-stagger] > *:nth-child`
→ `--motion-stagger-index`) — **no JS writes it**, soma never touches a visual
var. The container-driven preset rule `[data-stagger][data-state='open'] >
[data-animation-style='fade']` fires each item's enter with
`delay = index × --motion-stagger-each`. The items reuse the same `fade`
state-preset + foundation stagger that `<Cascade>` rides — zero new machinery
beyond one generic selector branch in the motion generator.
Tuning, per call-site, on `<DropdownMenu.Content>`:
- `style="--motion-stagger-each: 0"` → **parallel** (all items fade together).
- `style="--motion-stagger-each: 40ms"` → a more deliberate ripple.
- a different preset per item: `<DropdownMenu.Item data-animation-style="slide-fade">`.
Two honest limits (both documented framework-wide):
- **Only direct-child items cascade.** Items wrapped in a `<Group>` /
`<RadioGroup>` (a real DOM element) are *not* `:nth-child` of the panel, so
they appear instantly (the "transparent intermediate" limitation). A flat menu
cascades fully; a grouped one cascades only its top-level items.
- **Enter only.** On close the panel exits as a unit (today an opacity snap);
per-item *exit* cascade needs the coordinated-retention `PresenceGroup` (soma
lifecycle), still deferred — so a closing container does **not** drive a child
exit that would be cut off on unmount.
## Comparativa
| Lib | Sub-menus | CheckboxItem / RadioItem | CheckboxGroup | Arrow | Modal toggle |

@ -18,7 +18,7 @@
}: DropdownMenuCheckboxItemProps = $props();
</script>
<DropdownMenu.CheckboxItem {...rest} {closeOnSelect} bind:checked>
<DropdownMenu.CheckboxItem data-animation-style="fade" {...rest} {closeOnSelect} bind:checked>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -6,6 +6,12 @@
*
* `children` is renamed to `bodyContent` so the inner snippet that
* forwards `{ open }` doesn't shadow the prop name.
*
* `data-stagger` marks the panel as the event-driven cascade container: its
* direct-child items (which carry `data-animation-style`) get their structural
* index from `:nth-child` and enter together when the menu opens. The rhythm
* is the recipe's `--motion-stagger-each` (var(--motion-stagger), 0 = parallel).
* Nobody writes the index; the appearance flows from the `open` (emerge) event.
*/
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuContentProps } from './types';
@ -16,7 +22,7 @@
}: DropdownMenuContentProps = $props();
</script>
<DropdownMenu.Content {...rest}>
<DropdownMenu.Content data-stagger="" {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -1,7 +1,13 @@
<script lang="ts">
/**
* `data-animation-style="fade"` makes the item a participant in the Content's
* event-driven cascade: when the menu opens, items fade in staggered by their
* structural index (see `<DropdownMenu.Content>`). Override with a different
* preset, or set the Content's `--motion-stagger-each: 0` for a parallel fade.
*/
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuItemProps } from './types';
let { children, ...rest }: DropdownMenuItemProps = $props();
</script>
<DropdownMenu.Item {...rest}>{@render children?.()}</DropdownMenu.Item>
<DropdownMenu.Item data-animation-style="fade" {...rest}>{@render children?.()}</DropdownMenu.Item>

@ -16,7 +16,7 @@
}: DropdownMenuRadioItemProps = $props();
</script>
<DropdownMenu.RadioItem {...rest} {closeOnSelect}>
<DropdownMenu.RadioItem data-animation-style="fade" {...rest} {closeOnSelect}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -50,6 +50,16 @@
outline: none;
}
/* Event-driven item cascade rhythm. The Content wrapper marks itself
`[data-stagger]`; each direct-child item carries `data-animation-style`
and gets its structural index from `:nth-child`. This is the per-item
delay step — `var(--motion-stagger)` (canonical 20ms); 0 = parallel,
larger = a more deliberate ripple. The cascade flows from the `open`
(emerge) event — no JS writes the index, no consumer wiring. */
[data-dropdown-menu-content] {
--motion-stagger-each: var(--motion-stagger);
}
/* ── Item rows ─────────────────────────────────────────────────────── */
[data-dropdown-menu-item],

@ -5112,7 +5112,8 @@
animation: announce-pulse-threat var(--duration-slower) var(--ease-spring);
}
[data-animation-style='fade'][data-state='open'] {
[data-animation-style='fade'][data-state='open'],
[data-stagger][data-state='open'] > [data-animation-style='fade'] {
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));
}
@ -5122,7 +5123,8 @@
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'] {
animation: scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards, fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards;
animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
transform-origin: var(--floating-transform-origin);
@ -5154,7 +5156,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'] {
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));
}
@ -5224,7 +5227,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'] {
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));
}
@ -5294,7 +5298,8 @@
}
}
[data-animation-style='collapse'][data-state='open'] {
[data-animation-style='collapse'][data-state='open'],
[data-stagger][data-state='open'] > [data-animation-style='collapse'] {
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));
}
@ -5324,7 +5329,8 @@
}
}
[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'] {
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));
}
@ -5354,7 +5360,8 @@
}
}
[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'] {
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));
}
@ -5384,7 +5391,8 @@
}
}
[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'] {
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));
}

@ -770,9 +770,26 @@ function renderCssPresetRules(name: string, preset: CssStatePreset): string[] {
for (const { phase, state, fill, dur, ease } of MOTION_PHASES) {
const cssPhase = preset[phase]
if (!cssPhase) continue
// Two selector forms share the SAME declarations:
// · self-driven — a lone surface carries its own `data-state` (Content
// panel, dialog, a `<Cascade.Item>`).
// · container-driven (ENTER only) — the EVENT-DRIVEN cascade: direct
// children of an opening `[data-stagger]` container that carry this
// preset enter together, indexed FROM STRUCTURE (`:nth-child` →
// `--motion-stagger-index`). No per-item `data-state` (a `menuitem`
// isn't open/closed); the container's state — which soma already writes
// via the morfo `commits` — drives the children.
// ENTER only: children mount together so enter needs no coordination; the
// coordinated EXIT (retain the container until its children finish) is a
// soma lifecycle concern (PresenceGroup), still deferred — so a closing
// container must NOT drive a child exit that would be cut off on unmount.
const selectors = [`[data-animation-style='${name}'][data-state='${state}']`]
if (phase === 'enter') {
selectors.push(`[data-stagger][data-state='${state}'] > [data-animation-style='${name}']`)
}
rules.push(
renderBlock(
`[data-animation-style='${name}'][data-state='${state}']`,
selectors.join(',\n'),
phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase)
)
)

Loading…
Cancel
Save

Powered by TurnKey Linux.