From 27012a5c6823e1e386e03928099d2a5822b0dfaf Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 19 Jun 2026 23:05:13 +0200 Subject: [PATCH] feat(motion): structural stagger index + Cascade orchestrator on the verified model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade is no longer a separate system — it's the existing state-presets + the Material stagger + one foundation rule. Retires the --cascade-* reinvention. - render-css: foundation structural-index writer — `[data-stagger] > *:nth-child` → `--motion-stagger-index` (forward) / `:nth-last-child` → `--motion-stagger-index-rev` (reverse). Nobody writes the index; soma never writes a visual var. Every preset's enter/exit rule consumes it (parallel = `--motion-stagger-each` 0, cascade = N). - Cascade rewritten as a thin EXPLICIT orchestrator: marks `[data-stagger]`, reflects open→data-state, passes the preset to items via context; the `out:` retention flips to data-state=closed (reusing the exit preset), reading the duration via dom.getWindow. Drops the whole `--cascade-*` namespace + bespoke keyframes. Docs: MOTION_SERVICE_RFC §D.11 — the final model (three orthogonal axes sema/motion/ eidos, the full lifecycle, the realization ladder snap->transition->animation->JS, the firma-vs-realization seam, the cascade = existing system + one rule, the motion service as the engine). eidos-motion aligned. Verified: browser (enter scale-in/fade-in + nth-child index, exit reverse, zero inline writes), npm run check 0 mine, motion.test 22/22, eidos-lint 0 invalid. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/MOTION_SERVICE_RFC.md | 130 ++++++++++ src/uix/eidos/components/cascade/README.md | 94 ++++++++ .../components/cascade/cascade-item.svelte | 39 +++ src/uix/eidos/components/cascade/cascade.css | 15 ++ .../eidos/components/cascade/cascade.svelte | 34 +++ src/uix/eidos/components/cascade/context.ts | 22 ++ src/uix/eidos/components/cascade/index.ts | 22 ++ src/uix/eidos/components/cascade/types.ts | 28 +++ src/uix/eidos/eidos-motion.md | 22 +- src/uix/eidos/generated/base.css | 225 +++++++++++++++++- src/uix/eidos/lib/render-css.ts | 30 ++- src/uix/morfo/components/cascade.ts | 51 ++++ .../animations/panel-cascade/+page.svelte | 39 +++ 13 files changed, 728 insertions(+), 23 deletions(-) create mode 100644 src/uix/eidos/components/cascade/README.md create mode 100644 src/uix/eidos/components/cascade/cascade-item.svelte create mode 100644 src/uix/eidos/components/cascade/cascade.css create mode 100644 src/uix/eidos/components/cascade/cascade.svelte create mode 100644 src/uix/eidos/components/cascade/context.ts create mode 100644 src/uix/eidos/components/cascade/index.ts create mode 100644 src/uix/eidos/components/cascade/types.ts create mode 100644 src/uix/morfo/components/cascade.ts diff --git a/src/uix/eidos/MOTION_SERVICE_RFC.md b/src/uix/eidos/MOTION_SERVICE_RFC.md index 751d647a5..06e7b9554 100644 --- a/src/uix/eidos/MOTION_SERVICE_RFC.md +++ b/src/uix/eidos/MOTION_SERVICE_RFC.md @@ -1279,6 +1279,136 @@ reparto verificado y **cero `--var` visual escrita por JS** (confirmado por valo OK en este caso, pero la versión glitch-free pide que el **contenedor orqueste** la retirada como unidad (lifecycle de soma, lo que hacía el `pending` borrado — sin tocar lo visual). Pendiente para producción. +### D.11 — El modelo final: tres ejes ortogonales + el lifecycle completo (2026-06-19) + +Tras un re-análisis de fondo (workflow sobre código + canon) y correcciones duras del usuario, **este es el +modelo cerrado**. Supersede los matices de D.2/D.9 donde difieran; D.8 (retirada) y D.10 (prototipo) siguen +vigentes como historia. + +#### D.11.0 — Tres ejes ORTOGONALES (el error de raíz era confundirlos) + +El sistema NO es «una forma de animar». Son **tres cosas separables** que no hay que mezclar: + +| Eje | Qué es | Qué hace EXACTAMENTE | +| --- | --- | --- | +| **sema** (semántico) | el «qué ocurrió» | **SOLO emite `data-event-*`** en el DOM (+ corre sus 2 canales runtime sound/haptic). **NO es animación.** | +| **motion** (`arts/motion`, el SERVICIO) | el **MOTOR** de animación | corre los drivers JS (`spring`/`waapi`/`rect`/`svelte`) + el path CSS declarativo + handoff de velocidad. Motor full, par/superior a Framer. | +| **eidos** (visual) | la materialización | **LEE `data-event-*` (+ `data-state`) y reacciona**: genera el CSS, decide estilo/animación, consume el servicio motion para lo JS. Único dueño visual. | + +Y los dos que orquestan alrededor: **morfo** DECLARA (semántico + estructura, cero visual); **soma** DISPARA +(`runtime.trigger`) + ESTADO (`data-state`) + LIFECYCLE (presence/retención), cero var visual. + +**La regla mental:** sema = el QUÉ (emite el token) · motion = el MOTOR (corre lo JS) · eidos = el CÓMO-SE-VE +(lee el token y materializa). Mezclar «sema» con «la capacidad de animar», o comparar el suelo-CSS contra el +motor entero de otro framework, es el error a no repetir. + +#### D.11.1 — El lifecycle completo de un evento + +``` +1. DECLARACIÓN (estática, en código) + morfo: el evento (family/intent/verb/target/sequence/hold) + estructura (parts, data-state, data-stagger) + eidos: la realización — presets/signatures/keyframes en EidosConfig.motion + (derivados a generated/base.css + registrados en uix.motion) + +2. TRIGGER (runtime, ORIGEN) + soma: runtime.trigger('open') ← origina desde interacción/lifecycle (sin engine sigue: sema es ornamental) + + escribe data-state (en el contenedor y, propagado, en los items) + +3. EMISIÓN (sema PROCESA) + sema: engine.emit → resuelve la firma (cascada 5 capas → SOLO sound/haptic) + → el META-canal visual ESTAMPA data-event-* en signal.target (UN elemento) + → abre el HOLD; en paralelo sound/haptic se realizan + +4. REACCIÓN (eidos LEE, durante el hold) + eidos: el CSS del componente reacciona a data-event-* (+ data-state) → tres salidas posibles (D.11.2) + +5. CLEANUP + sema: al cerrar el hold, des-estampa data-event-* (transient). Persistente → soma lo limpia (clearTarget) + +6. PRESENCE (lifecycle de salida) + soma: Presence retiene el nodo durante la salida (await getAnimations / out:), luego desmonta. + La retención es soma; el visual, eidos. +``` + +#### D.11.2 — La reacción de eidos: una escalera de realización + +Leído el `data-event-*` (+ `data-state`), el CSS del componente decide CÓMO se realiza. **No es siempre una +animación** — hay una escalera de cuatro escalones, de menos a más: + +``` +snap → transition → animation (keyframe / preset) → JS (servicio motion) +``` + +- **(a) snap — estilo estático.** CSS puro, sin interpolar (la firma en el canal color): + `[data-field][data-event-intent='threat'] { border-color: var(--color-threat-border) }`. Cambia de golpe. +- **(b) transition — estilo INTERPOLADO.** Una `transition` CSS interpola la propiedad cuando su valor cambia: + `transition: border-color var(--duration-fast)`. El borde rojo **entra suave** en vez de saltar. Es el suelo de + las micro-interacciones (hover/focus, `archetypes.css`) y de cambios de estado de UNA propiedad. La corre el + navegador. +- **(c) animation — keyframe / preset.** La firma (`--event`) o el preset (`--state`: enter/exit con forma + + stagger, fill `backwards`). Declarativa; **el navegador la corre**; motion la tiene en registry + Presence la + espera, pero no la ejecuta. +- **(d) JS — el servicio `motion`.** Física (`spring`), FLIP (`rect`), reversa interrumpible. La ejecuta + `uix.motion.run`, disparada por **soma/Presence** leyendo `data-animation-style` + tokens. El CSS NO llama a JS. + +Frontera clave: **declarar (CSS) ≠ ejecutar.** El navegador corre (a)/(b)/(c); el servicio motion corre (d), +disparado por soma. El CSS DECLARA qué hace motion; no lo invoca. **Lifecycle:** `Presence` espera tanto los +`CSSAnimation` como los `CSSTransition` vía `getAnimations()` al salir — el servicio no gestiona las `transition`, +pero el lifecycle sí las contempla. + +#### D.11.3 — Firma vs realización (el seam, canon §10/§15) + +Una firma conserva IDENTIDAD bajo variación; los valores concretos son realización: + +- **GENÉRICO (firma, del evento):** la identidad — «un `emerge` aparece» (flourish del contenedor, signature + `present-rise`) y «los hijos de un contenedor que emerge aparecen JUNTOS, por orden estructural». +- **ELÁSTICO (realización, per-componente):** los valores — el preset (keyframe), la duración, el ritmo + (paralelo/cascada), el easing. Un menú a 40ms y un toast a 0ms (paralelo) realizan la MISMA firma distinto. + +→ El timing NO es un parámetro genérico de firma (eso resucitaría la Grieta 1). Es **realización per-componente**, +en su estilo de eidos. + +#### D.11.4 — La cascada/stagger: el sistema EXISTENTE + una regla + +La cascada NO es un sistema nuevo. Es el preset declarado + el stagger que ya existía + **UNA regla de foundation**: + +- **El índice (foundation, generado por `render-css.ts`):** `[data-stagger] > *:nth-child(N) { --motion-stagger-index: N-1 }` + (forward) + `:nth-last-child → --motion-stagger-index-rev` (reverse). **Nadie lo escribe; sale de la + estructura.** (El `DomCascade` borrado lo escribía desde soma = la violación raíz.) +- **El delay (en CADA preset, ya estaba):** enter `calc(var(--motion-stagger-index) * var(--motion-stagger-each))`; + exit usa el reverse. **Paralelo = `--motion-stagger-each: 0`; cascada = N.** No hay regla de «tipo»: el tipo + 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. + +**Dos dominios para usarlo:** (1) **explícito** — el componente `` (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. + +#### D.11.5 — El servicio `motion` es el motor (par/superior a Framer) + +`EngineMotion` (`arts/motion` · `uix.motion`): drivers `spring` (integrador de Euler semi-implícito: +overshoot/settle/drag/snap), `waapi`, `rect` (FLIP/genie, medido + batcheado), `svelte`, `css` (suelo +declarativo). **Handoff de velocidad** (`peek`/`takeHandoff`): la reversa continúa desde posición + velocidad +actuales (el caso difícil de la interrupción). **Puerto DOM inyectable** (`MotionDom`) → iframe/popup/SSR-test +(Framer asume `document` global). En capacidad de motor, **par o superior**. Madurez pendiente: el catálogo de +presets-JS-por-nombre está a medio poblar (los CSS vienen de serie; los JS se registran por app). + +El `spring` es además el **equivalente INTERRUMPIBLE de una `transition` CSS** (D.11.2.b): una `transition` +**salta** al revertir a media interpolación; el `spring` **continúa desde la velocidad actual** (el handoff). Por +eso la frontera práctica: interpolación simple no-interrumpible → `transition` CSS (eidos, browser); física o +reversa interrumpible → el driver JS del servicio. + +#### D.11.6 — Qué se corrigió (vs el primer intento de esta sesión) + +El primer intento de productización hizo un `` con recipe propio + un namespace `--cascade-*` +(`index`/`each`/`base`/`pass`) + keyframes propios. **Era reinventar** el `--motion-stagger-*` + los state-presets +que ya existían (+ le faltaba reduced-motion + un `getComputedStyle` crudo violando ActiveDom). Corregido: +**retirado el `--cascade-*` entero**; la cascada monta sobre el preset declarado + el índice de foundation; +`` 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. + --- > **Fuentes.** Canon semántico: [`docs/CANON.md`](../../../docs/CANON.md). Arquitectura de diff --git a/src/uix/eidos/components/cascade/README.md b/src/uix/eidos/components/cascade/README.md new file mode 100644 index 000000000..7148bcda0 --- /dev/null +++ b/src/uix/eidos/components/cascade/README.md @@ -0,0 +1,94 @@ +# Cascade + +A thin **explicit** orchestrator that staggers its children's enter/exit. It owns **no recipe +and no timing of its own** — it rides the foundation stagger + the existing state-presets. It +is the *explicit* / content-animation way to sequence a set of elements (you designed them to +stagger), as opposed to the *event-driven* path where a component's own `emerge` firma + the +same foundation stagger do it (see "Two ways" below). + +```svelte + + + + {#each items as item (item.id)} + {item.label} + {/each} + +``` + +`--motion-stagger-each: 0` (the default) → **parallel**; `N` → **cascade**. The keyframe, +duration, easing and reduced-motion all come from the `animation` preset. + +## How it works — it reuses everything + +Nothing here is a new system. The cascade is the *existing declared motion* plus one +foundation rule: + +| Piece | Where it's declared / derived | +| --- | --- | +| the per-item **animation** (keyframe, duration, ease, reduced-motion) | a **state-preset** declared in `EidosConfig.motion` (`fade` / `scale-fade` / …), generated by eidos to `generated/base.css` and registered with `uix.motion` | +| the **stagger delay** (`index × --motion-stagger-each`, default 0 = parallel) | already in **every preset's** enter/exit rule (`lib/render-css.ts`) | +| the **structural index** (`--motion-stagger-index` / `-rev`) | the one new foundation rule: `[data-stagger] > *:nth-child / :nth-last-child` (generated by `lib/render-css.ts`) — **nobody writes it**, soma never writes a visual var | + +`` just **wires** those: it marks the container `[data-stagger]`, reflects `open` as +`data-state`, and passes `open` + the chosen preset to its items (context), so each item +carries `data-animation-style` + `data-state`. Enter counts up (`:nth-child`); exit counts +down (`:nth-last-child`) so the last item leaves first. + +## Props + +### `` + +| Prop | Type | Default | Notes | +| --- | --- | --- | --- | +| `open` | `boolean` | `true` | Drives the staggered enter (forward) / exit (reverse) via `data-state`. | +| `animation` | `MotionPresetName` | `'fade'` | The state-preset every item plays (`fade` / `scale-fade` / `slide-fade` / …). | + +Plus any `HTMLAttributes`. Set the layout + the rhythm (`--motion-stagger-each`) +here. + +### `` + +A participant. On removal it flips to `data-state='closed'` (the existing **exit** preset +plays, with the reverse structural index), is retained (`out:`) for the eidos-declared exit +duration, then unmounts. Accepts any `HTMLAttributes`. + +## Ownership + +| | Owns | Never touches | +| --- | --- | --- | +| **soma** (the wrappers) | **state** (`open` → `data-state`, propagated to items) + **lifecycle** (`out:` retention) | any `--motion-*` / visual variable — it reads the eidos duration via the ActiveDom `getComputedStyle`, writes nothing | +| **eidos** | **all the visual** — the preset (keyframe/duration/ease/reduced-motion) + the foundation stagger index | the semantics / state | + +## Two ways to stagger children + +1. **Explicit** — `` (this component). You want these elements to stagger; there is no + 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 `` + 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.) + +## Known limitation — bulk removal + +When **several** items are removed at once (e.g. a slider jumping 10 → 3), the leaving nodes +unmount one-by-one as each finishes, so the grid reflows during the cascade and the remaining +items' `:nth-last-child` index re-evaluates. In practice it reads fine, but the glitch-free +version wants the **container to retain the leaving set as a unit** (one reflow at the end) — +a soma **lifecycle** concern (what the retired `pending` did, without touching the visual). +Dragging the slider down step-by-step (a sequence of lone removals) is always clean. + +## Decisions + +- **No recipe, no `--cascade-*` namespace, no bespoke keyframe.** An earlier prototype forked + all of that — it reinvented the existing `--motion-stagger-*` + state-preset system. This + replaces it by reusing them; the only new code is the foundation structural-index writer. +- **`Cascade` is the EXPLICIT domain.** Event-driven children appearance rides the firma + + the same foundation stagger, declared in the component's own morfo — not this wrapper. +- **Index from `:nth-child`, capped 1–24** (the foundation writer). Beyond the cap items share + the last index until `sibling-index()` is broadly supported. diff --git a/src/uix/eidos/components/cascade/cascade-item.svelte b/src/uix/eidos/components/cascade/cascade-item.svelte new file mode 100644 index 000000000..af31e596a --- /dev/null +++ b/src/uix/eidos/components/cascade/cascade-item.svelte @@ -0,0 +1,39 @@ + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/cascade/cascade.css b/src/uix/eidos/components/cascade/cascade.css new file mode 100644 index 000000000..7b1ee7886 --- /dev/null +++ b/src/uix/eidos/components/cascade/cascade.css @@ -0,0 +1,15 @@ +/* + * Cascade has NO recipe of its own — it rides the foundation. This file exists only as the + * component's CSS slot; the actual mechanism lives elsewhere, already declared + generated: + * + * - the structural stagger index — `[data-stagger] > *:nth-child / :nth-last-child` → + * `--motion-stagger-index` / `-rev` (foundation, generated by `lib/render-css.ts`); + * - the per-item animation — the existing state-preset the item carries via + * `data-animation-style` + `data-state` (its enter/exit rule already consumes + * index × `--motion-stagger-each`, default 0 → parallel); + * - the keyframe + duration + reduced-motion — the chosen preset, declared in + * `EidosConfig.motion` and derived by eidos to `uix.motion`. + * + * No `--cascade-*` namespace, no keyframe, no per-item visual var. The earlier prototype + * recipe (which forked all of that) was the reinvention this replaces. + */ diff --git a/src/uix/eidos/components/cascade/cascade.svelte b/src/uix/eidos/components/cascade/cascade.svelte new file mode 100644 index 000000000..f49fe1e49 --- /dev/null +++ b/src/uix/eidos/components/cascade/cascade.svelte @@ -0,0 +1,34 @@ + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/cascade/context.ts b/src/uix/eidos/components/cascade/context.ts new file mode 100644 index 000000000..5f540d5a3 --- /dev/null +++ b/src/uix/eidos/components/cascade/context.ts @@ -0,0 +1,22 @@ +import { getContext, setContext } from 'svelte'; +import type { MotionPresetName } from '$uix/eidos/lib/motion/registry'; + +const KEY = Symbol('cascade'); + +/** + * What `` passes to its items: the container's open state + the state-preset the + * items should play. Items read it to carry `data-state` + `data-animation-style` — they + * never receive a timing or a visual var, only these two semantic/structural facts. + */ +export type CascadeContext = { + readonly open: boolean; + readonly animation: MotionPresetName; +}; + +export function setCascadeContext(ctx: CascadeContext): void { + setContext(KEY, ctx); +} + +export function getCascadeContext(): CascadeContext | undefined { + return getContext(KEY); +} diff --git a/src/uix/eidos/components/cascade/index.ts b/src/uix/eidos/components/cascade/index.ts new file mode 100644 index 000000000..0d4bdd814 --- /dev/null +++ b/src/uix/eidos/components/cascade/index.ts @@ -0,0 +1,22 @@ +// Cascade — a coordinating container that staggers its children's enter/exit. +// +// import { Cascade } from '$uix/eidos/components/cascade'; +// +// +// {#each items as item (item.id)} +// {item.label} +// {/each} +// +import CascadeComponent from './cascade.svelte'; +import Item from './cascade-item.svelte'; + +type CascadeNamespace = typeof CascadeComponent & { + Item: typeof Item; +}; + +const Cascade = CascadeComponent as CascadeNamespace; +Cascade.Item = Item; + +export { Cascade }; +export default Cascade; +export type { CascadeProps, CascadeItemProps } from './types'; diff --git a/src/uix/eidos/components/cascade/types.ts b/src/uix/eidos/components/cascade/types.ts new file mode 100644 index 000000000..524073cec --- /dev/null +++ b/src/uix/eidos/components/cascade/types.ts @@ -0,0 +1,28 @@ +import type { Snippet } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; +import type { MotionPresetName } from '$uix/eidos/lib/motion/registry'; + +/** + * Props for `` — a thin explicit orchestrator that staggers its children's + * enter/exit by riding the foundation stagger + an existing state-preset. Layout is yours; + * set the rhythm with `--motion-stagger-each` (default 0 → parallel) on the element. + */ +export type CascadeProps = Omit, 'children'> & { + /** Whether the container is open — drives the staggered enter (forward) / exit (reverse). @default true */ + open?: boolean; + /** + * The state-preset every item plays (its `data-animation-style`) — any registered eidos + * motion preset (`fade`, `scale-fade`, `slide-fade`, …). The keyframe / duration / + * reduced-motion all come from the preset. @default 'fade' + */ + animation?: MotionPresetName; + children?: Snippet; +}; + +/** + * Props for `` — a participant in the cascade. On removal it is retained + * (Svelte `out:`) for its eidos-declared exit, then unmounted. + */ +export type CascadeItemProps = Omit, 'children'> & { + children?: Snippet; +}; diff --git a/src/uix/eidos/eidos-motion.md b/src/uix/eidos/eidos-motion.md index 2f25dae2a..2a48a1355 100644 --- a/src/uix/eidos/eidos-motion.md +++ b/src/uix/eidos/eidos-motion.md @@ -157,13 +157,17 @@ no sobre-escribe** atributos de estado y viceversa (ownership). NO dice que solo uno pueda animarse — **eidos lee ambos y anima ambos**. Lo prohibido es pisar el nombre ajeno, no animar sobre los dos ejes. -> **Coordinación entre superficies — en re-diseño.** Hubo un tercer eje «coordinado» -> (`PresenceGroup` / presets `cascade-*` sobre `data-starting/ending-style`); se -> **retiró** el 2026-06-19 (ver la nota de retirada del -> [`MOTION_SERVICE_RFC.md`](./MOTION_SERVICE_RFC.md)). El principio vigente: la -> coordinación padre↔hijo es **un canal de la firma del evento** (la posee sema), -> materializada por eidos reaccionando a `data-event-*` — no un motor paralelo. -> Reconstrucción pendiente: Apéndice D del RFC. +> **Coordinación de hijos (stagger / cascada) — modelo CERRADO, RFC §D.11.** Hubo un tercer +> eje «coordinado» (`PresenceGroup` / presets `cascade-*` sobre `data-starting/ending-style`); +> se **retiró** el 2026-06-19. El modelo final: la cascada NO es un sistema aparte — es el +> **momento `--state`** (un preset declarado) + el **stagger que ya existía** +> (`índice × --motion-stagger-each`, paralelo = 0, cascada = N) + **una regla de foundation** +> que escribe el índice desde estructura (`[data-stagger] > *:nth-child → --motion-stagger-index` +> / `:nth-last-child → -rev`, generada en `lib/render-css.ts`). La firma del evento (`emerge` → +> `present-rise`) es solo el flourish del **contenedor** (genérico, sobre `data-event-*`); el +> timing de los hijos es **realización per-componente**, no un canal de sema. Tres ejes +> ortogonales (sema emite · motion es el motor · eidos materializa) + el lifecycle completo: +> [§D.11 del RFC](./MOTION_SERVICE_RFC.md). --- @@ -458,8 +462,8 @@ elemento; el preset las lee con fallback al token: | `--motion-ease-{enter,exit}` | curva por componente (F3) | `@property inherits:false` | | `--motion-slide-leave` | extra de `slide-full` para paneles inset (drawer) | default `0px` | | `--height` / `--collapsed-height` | altura medida del `collapse` (soma la escribe) | — | -| `--motion-stagger-each` | ritmo del stagger (en el contenedor, hereda) | default `0ms` | -| `--motion-stagger-index` | índice del stagger (por ítem) | `@property inherits:false`, `` | +| `--motion-stagger-each` | ritmo del stagger (en el contenedor, hereda) | default `0ms` → **paralelo**; N → cascada | +| `--motion-stagger-index` / `-index-rev` | índice por ítem **desde estructura** — `[data-stagger] > *:nth-child` (fwd, enter) / `:nth-last-child` (rev, exit); generado en `render-css.ts`, **nadie lo escribe** | `@property inherits:false`, `` | Keyframes (`EidosConfig.motion.keyframes`, paridad `theme.keyframes` de Chakra): `translate`/`scale` individuales (componen sin pisarse); parametrizados por CSS diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index b7a40db3e..3b2392180 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1011,11 +1011,12 @@ --dialog-header-gap: var(--space-3); --dialog-footer-gap: var(--space-3); --dialog-footer-margin-top: var(--space-2); - --dialog-title-font-family: var(--font-heading); - --dialog-title-font-size: var(--font-size-lg); - --dialog-title-font-weight: var(--font-weight-semibold); - --dialog-title-line-height: var(--leading-heading); - --dialog-title-color: var(--color-content-primary); + --dialog-title-font-family: var(--style-h3-font-family); + --dialog-title-font-size: var(--style-h3-font-size); + --dialog-title-font-weight: var(--style-h3-font-weight); + --dialog-title-line-height: var(--style-h3-line-height); + --dialog-title-letter-spacing: var(--style-h3-letter-spacing); + --dialog-title-color: var(--style-h3-color); --dialog-description-font-family: var(--font-ui); --dialog-description-font-size: var(--font-size-sm); --dialog-description-line-height: var(--leading-ui); @@ -4527,6 +4528,204 @@ initial-value: 0; } +@property --motion-stagger-index-rev { + syntax: ''; + inherits: false; + initial-value: 0; +} + +[data-stagger] > *:nth-child(1) { + --motion-stagger-index: 0; +} + +[data-stagger] > *:nth-last-child(1) { + --motion-stagger-index-rev: 0; +} + +[data-stagger] > *:nth-child(2) { + --motion-stagger-index: 1; +} + +[data-stagger] > *:nth-last-child(2) { + --motion-stagger-index-rev: 1; +} + +[data-stagger] > *:nth-child(3) { + --motion-stagger-index: 2; +} + +[data-stagger] > *:nth-last-child(3) { + --motion-stagger-index-rev: 2; +} + +[data-stagger] > *:nth-child(4) { + --motion-stagger-index: 3; +} + +[data-stagger] > *:nth-last-child(4) { + --motion-stagger-index-rev: 3; +} + +[data-stagger] > *:nth-child(5) { + --motion-stagger-index: 4; +} + +[data-stagger] > *:nth-last-child(5) { + --motion-stagger-index-rev: 4; +} + +[data-stagger] > *:nth-child(6) { + --motion-stagger-index: 5; +} + +[data-stagger] > *:nth-last-child(6) { + --motion-stagger-index-rev: 5; +} + +[data-stagger] > *:nth-child(7) { + --motion-stagger-index: 6; +} + +[data-stagger] > *:nth-last-child(7) { + --motion-stagger-index-rev: 6; +} + +[data-stagger] > *:nth-child(8) { + --motion-stagger-index: 7; +} + +[data-stagger] > *:nth-last-child(8) { + --motion-stagger-index-rev: 7; +} + +[data-stagger] > *:nth-child(9) { + --motion-stagger-index: 8; +} + +[data-stagger] > *:nth-last-child(9) { + --motion-stagger-index-rev: 8; +} + +[data-stagger] > *:nth-child(10) { + --motion-stagger-index: 9; +} + +[data-stagger] > *:nth-last-child(10) { + --motion-stagger-index-rev: 9; +} + +[data-stagger] > *:nth-child(11) { + --motion-stagger-index: 10; +} + +[data-stagger] > *:nth-last-child(11) { + --motion-stagger-index-rev: 10; +} + +[data-stagger] > *:nth-child(12) { + --motion-stagger-index: 11; +} + +[data-stagger] > *:nth-last-child(12) { + --motion-stagger-index-rev: 11; +} + +[data-stagger] > *:nth-child(13) { + --motion-stagger-index: 12; +} + +[data-stagger] > *:nth-last-child(13) { + --motion-stagger-index-rev: 12; +} + +[data-stagger] > *:nth-child(14) { + --motion-stagger-index: 13; +} + +[data-stagger] > *:nth-last-child(14) { + --motion-stagger-index-rev: 13; +} + +[data-stagger] > *:nth-child(15) { + --motion-stagger-index: 14; +} + +[data-stagger] > *:nth-last-child(15) { + --motion-stagger-index-rev: 14; +} + +[data-stagger] > *:nth-child(16) { + --motion-stagger-index: 15; +} + +[data-stagger] > *:nth-last-child(16) { + --motion-stagger-index-rev: 15; +} + +[data-stagger] > *:nth-child(17) { + --motion-stagger-index: 16; +} + +[data-stagger] > *:nth-last-child(17) { + --motion-stagger-index-rev: 16; +} + +[data-stagger] > *:nth-child(18) { + --motion-stagger-index: 17; +} + +[data-stagger] > *:nth-last-child(18) { + --motion-stagger-index-rev: 17; +} + +[data-stagger] > *:nth-child(19) { + --motion-stagger-index: 18; +} + +[data-stagger] > *:nth-last-child(19) { + --motion-stagger-index-rev: 18; +} + +[data-stagger] > *:nth-child(20) { + --motion-stagger-index: 19; +} + +[data-stagger] > *:nth-last-child(20) { + --motion-stagger-index-rev: 19; +} + +[data-stagger] > *:nth-child(21) { + --motion-stagger-index: 20; +} + +[data-stagger] > *:nth-last-child(21) { + --motion-stagger-index-rev: 20; +} + +[data-stagger] > *:nth-child(22) { + --motion-stagger-index: 21; +} + +[data-stagger] > *:nth-last-child(22) { + --motion-stagger-index-rev: 21; +} + +[data-stagger] > *:nth-child(23) { + --motion-stagger-index: 22; +} + +[data-stagger] > *:nth-last-child(23) { + --motion-stagger-index-rev: 22; +} + +[data-stagger] > *:nth-child(24) { + --motion-stagger-index: 23; +} + +[data-stagger] > *:nth-last-child(24) { + --motion-stagger-index-rev: 23; +} + @keyframes fade-in { from { opacity: 0; @@ -4920,6 +5119,7 @@ [data-animation-style='fade'][data-state='closed'] { animation: fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; + animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } [data-animation-style='scale-fade'][data-state='open'] { @@ -4930,6 +5130,7 @@ [data-animation-style='scale-fade'][data-state='closed'] { animation: scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; + animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); transform-origin: var(--floating-transform-origin); } @@ -4980,22 +5181,27 @@ [data-animation-style='slide-fade'][data-state='closed'] { 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)); } [data-animation-style='slide-fade'][data-side='top'][data-state='closed'] { 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)); } [data-animation-style='slide-fade'][data-side='bottom'][data-state='closed'] { animation: slide-to-top 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)); } [data-animation-style='slide-fade'][data-side='left'][data-state='closed'] { animation: slide-to-right 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)); } [data-animation-style='slide-fade'][data-side='right'][data-state='closed'] { animation: slide-to-left 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)); } [data-motion='reduce'] [data-animation-style='slide-fade'][data-state='open'] { @@ -5045,22 +5251,27 @@ [data-animation-style='slide-full'][data-state='closed'] { 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)); } [data-animation-style='slide-full'][data-side='top'][data-state='closed'] { animation: slide-to-top-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)); } [data-animation-style='slide-full'][data-side='bottom'][data-state='closed'] { animation: slide-to-bottom-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)); } [data-animation-style='slide-full'][data-side='left'][data-state='closed'] { animation: slide-to-left-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)); } [data-animation-style='slide-full'][data-side='right'][data-state='closed'] { 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)); } [data-motion='reduce'] [data-animation-style='slide-full'][data-state='open'] { @@ -5090,6 +5301,7 @@ [data-animation-style='collapse'][data-state='closed'] { animation: collapse-height var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; + animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } [data-motion='reduce'] [data-animation-style='collapse'][data-state='open'] { @@ -5119,6 +5331,7 @@ [data-animation-style='shared-axis-x'][data-state='closed'] { animation: slide-axis-x-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; + animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } [data-motion='reduce'] [data-animation-style='shared-axis-x'][data-state='open'] { @@ -5148,6 +5361,7 @@ [data-animation-style='shared-axis-y'][data-state='closed'] { animation: slide-axis-y-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards, fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; + animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } [data-motion='reduce'] [data-animation-style='shared-axis-y'][data-state='open'] { @@ -5177,6 +5391,7 @@ [data-animation-style='fade-through'][data-state='closed'] { animation: fade-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards; + animation-delay: calc(var(--motion-stagger-index-rev, 0) * var(--motion-stagger-each, 0ms)); } [data-motion='reduce'] [data-animation-style='fade-through'][data-state='open'] { diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 42589967a..60a5a1a54 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -720,6 +720,19 @@ function renderMotionBlocks(motion: MotionConfig): string { blocks.push( "@property --motion-stagger-index {\n\tsyntax: '';\n\tinherits: false;\n\tinitial-value: 0;\n}" ) + blocks.push( + "@property --motion-stagger-index-rev {\n\tsyntax: '';\n\tinherits: false;\n\tinitial-value: 0;\n}" + ) + // Structural stagger index: a container marks itself `[data-stagger]` and its direct + // children get `--motion-stagger-index` from `:nth-child` (forward, for enter) and + // `--motion-stagger-index-rev` from `:nth-last-child` (reverse, for exit — last leaves + // first) — NO JS writer (the deleted DomCascade wrote it from soma, the root violation). + // Every css preset's enter/exit rule consumes index × `--motion-stagger-each` (default + // 0 → parallel, N → cascade). Capped at 24; past the cap items share the last index. + for (let n = 1; n <= 24; n++) { + blocks.push(`[data-stagger] > *:nth-child(${n}) {\n\t--motion-stagger-index: ${n - 1};\n}`) + blocks.push(`[data-stagger] > *:nth-last-child(${n}) {\n\t--motion-stagger-index-rev: ${n - 1};\n}`) + } for (const [name, stops] of Object.entries(motion.keyframes ?? {})) { blocks.push(renderKeyframes(name, stops)) @@ -789,15 +802,14 @@ function phaseDeclarations( const ease = `var(--motion-ease-${phase}, var(--ease-${cssPhase.ease ?? defaultEase}))` const animation = names.map((kf) => `${kf} ${dur} ${ease} ${fill}`).join(', ') const declarations = [`animation: ${animation};`] - // Stagger (Material list choreography, zero JS orchestration): a container - // sets `--motion-stagger-each` (rhythm) and each item `--motion-stagger-index`, - // so each item's enter is delayed by index × each. Default 0 → no stagger; the - // `backwards` fill holds the `from` state until each item's turn. - if (phase === 'enter') { - declarations.push( - 'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));' - ) - } + // Stagger (Material list choreography, zero JS orchestration): a container sets + // `--motion-stagger-each` (rhythm) and each item gets its index FROM STRUCTURE + // (foundation `[data-stagger]` writer). Enter counts UP (--motion-stagger-index); + // exit counts DOWN (--motion-stagger-index-rev) so the last item leaves first. Both + // default 0 → no stagger (a lone element is unaffected); the `backwards` fill holds + // the enter `from` state until each item's turn. + const indexVar = phase === 'enter' ? '--motion-stagger-index' : '--motion-stagger-index-rev' + declarations.push(`animation-delay: calc(var(${indexVar}, 0) * var(--motion-stagger-each, 0ms));`) if (cssPhase.transformOrigin) { declarations.push(`transform-origin: ${cssPhase.transformOrigin};`) } diff --git a/src/uix/morfo/components/cascade.ts b/src/uix/morfo/components/cascade.ts new file mode 100644 index 000000000..9ff5d76b3 --- /dev/null +++ b/src/uix/morfo/components/cascade.ts @@ -0,0 +1,51 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +/** + * Cascade — a coordinating container that staggers its children's enter/exit. + * + * Pure `--state` moment, NOT a sema event: the consumer drives `open`, the + * Provider reflects `data-state`, and eidos paints the staggered cascade by + * reacting to `data-state` + the per-item structural index (`:nth-child`, in the + * recipe). Soma's only job is lifecycle — the Item wrapper retains a removed node + * during its exit (`out:`) and marks it leaving so eidos can play the exit. + * + * No events: the container's own perceptual flourish, if any, is a SEPARATE + * `emerge` event declared by whatever wraps it — the Cascade only sequences its + * children. The index is never written: eidos computes it from structure. + */ +export const cascadeMorfo = { + name: 'Cascade', + kebab: 'cascade', + scope: ['eidos'], + events: [], + parts: [ + { + name: 'Provider', + kebab: 'provider', + archetype: 'provider', + kind: 'public', + defaultElement: 'div', + optional: false, + states: ['open', 'closed'], + data: [ + { + attr: 'data-state', + values: ['open', 'closed'], + value: v.stateRef('open') + } + ], + aria: [] + }, + { + name: 'Item', + kebab: 'item', + archetype: 'content', + kind: 'public', + defaultElement: 'div', + optional: false, + data: [], + aria: [] + } + ] +} as const satisfies Morfo; diff --git a/web/routes/temas/animations/panel-cascade/+page.svelte b/web/routes/temas/animations/panel-cascade/+page.svelte index e9c560939..12a945927 100644 --- a/web/routes/temas/animations/panel-cascade/+page.svelte +++ b/web/routes/temas/animations/panel-cascade/+page.svelte @@ -16,6 +16,7 @@ * stamp on the container + sound) would be a SEPARATE, single-target flourish. */ import { Card } from '$uix/eidos/components/card' + import { Cascade } from '$uix/eidos/components/cascade' import './panel-cascade.css' // SOMA role: state only. Default open so the enter cascade plays on load. @@ -87,6 +88,31 @@ {/each} + +

+ Productizado: <Cascade> — orquestador sobre el sistema declarado +

+

+ Sin recipe propio: el contenedor se marca data-stagger (la foundation da el + índice por :nth-child), los items cargan un preset existente + (animation="scale-fade") + data-state, y el stagger sale del + preset (--motion-stagger-each, 0 = paralelo). Retención out: en el + item. Cero --cascade-*. +

+ + {#each cards as i (i)} + + + Card {i + 1} + <Cascade.Item> · scale-fade + + + {/each} +