refactor(motion): retire parallel orchestration motor (Plan A) + verified model + Fase 1 prototype

Remove the parallel motion-coordination service per the redesign: animation is a
channel of the EVENT's firma (sema owns it across all channels), not a parallel axis.
Code is gone; the RFC stays as historical record with a retirement banner.

- morfo: drop MorfoPart.animation + its schema/compile/types/exports
- arts/motion: drop CoordinatedPreset / MotionConfig.coordinated
- eidos: drop BUILTIN_COORDINATED_PRESETS / renderCoordinatedPresetRules / the
  animation:none neutralization + registry block; regen generated/base.css
- soma: Presence reduced to a single-surface island; delete presence-group /
  dom-cascade / coordination; dropdown-menu loses the cascade wiring
- delete Reveal / Rail (morfo + soma + demos)

Docs: MOTION_SERVICE_RFC gains the retirement banner + §D.8 (retirada) + §D.9
(verified model: morfo->soma->sema->eidos pipeline + the two hard rules — soma
never writes a visual --var; data-event-* is a single-target stamp, not a bus) +
§D.10 (Fase 1 prototype). Fix stale "5 canales" claim in GUIA §11; eidos-motion +
dropdown README aligned.

Fase 1 prototype (web/routes/temas/animations/panel-cascade): validates the model
end to end — panel->cards cascade (enter/exit), dynamic removal with Svelte out:
retention, nested cascade — all via :nth-child + custom-property inheritance, with
zero JS visual writes.

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

@ -9,8 +9,6 @@ export type { SpringConfig, SpringPhysics } from './drivers'
export { isCssStatePreset } from './types'
export type {
CoordinatedPreset,
CoordinatedPresetName,
CssPhase,
CssStatePreset,
EventSignature,
@ -19,7 +17,6 @@ export type {
KeyframeName,
KeyframeStops,
MotionConfig,
MotionCoordinatedPresets,
MotionContext,
MotionDom,
MotionHandle,

@ -154,66 +154,14 @@ export interface JsStatePreset {
export type StatePreset = CssStatePreset | JsStatePreset
/**
* A COORDINATED preset (RFC: eidos/MOTION_SERVICE_RFC.md §M6) — for animable
* surfaces driven by a `PresenceGroup`, where the WHEN is governed by soma's
* `data-starting-style` / `data-ending-style` (the group's release), NOT `data-state`.
*
* Unlike `CssStatePreset` (keyframes on `data-state`, which fire on MOUNT and would
* fight the coordinated cascade — the §5 finding), this declares the "outside" state
* as a TRANSITION: eidos generates `[data-animation-style='X'][data-starting-style],
* [...][data-ending-style] { …off… }` + the transition + the canonical reversible
* stagger. The motion engine never runs these (pure CSS) — they live in
* `MotionConfig.coordinated`, NOT `presets`, so the engine returns a settled handle.
*/
export interface CoordinatedPreset {
/** The "outside" state — CSS prop → value, e.g. `{ opacity: '0', transform: 'translateX(-16px)' }`. */
readonly off: Readonly<Record<string, string>>
/** Duration token key (`'moderate'`) or raw. Overridable per-container via `--motion-cascade-duration`. */
readonly duration?: string
/** Ease token key (`'out'`) or raw. Overridable via `--motion-cascade-ease`. */
readonly ease?: string
}
/**
* Type-safe, eidos/app-extensible registry of COORDINATED preset names — the value
* of a component's `animation` prop (RFC: eidos/MOTION_SERVICE_RFC.md §5, "separadas
* por rol"). It is the counterpart of eidos's `EidosMotionPresets` (which types the
* `motion` prop), but it lives HERE in `$motion` — the shared layer — so that SOMA
* components (Rail / Reveal) can type their `animation` prop WITHOUT importing eidos
* (a lower layer cannot depend on a higher one). Eidos POPULATES it with its
* built-ins (`cascade-*`) via declaration merging:
*
* declare module '$motion' {
* interface MotionCoordinatedPresets {
* 'cascade-slide': true
* }
* }
*
* The interface starts EMPTY: the names are eidos's data, not the engine's. Same
* boundary as `EidosMotionPresets` — the engine stays open at runtime; this is a
* COMPILE-TIME ergonomic for the `animation` prop only.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface MotionCoordinatedPresets {}
/**
* The `animation` prop type — the COORDINATED counterpart of `MotionPresetName`.
* Known coordinated presets (eidos/app-merged) autocomplete; an arbitrary string is
* still accepted. Omit the prop (`undefined`) for no preset.
*/
export type CoordinatedPresetName = keyof MotionCoordinatedPresets | (string & {})
/**
* The motion config: keyframes + the two animation surfaces. Eidos generates CSS
* from `keyframes` + `signatures` + the css `presets`; the engine resolves +
* runs the `presets` (settled for css, driver for js). `coordinated` is M6 —
* CSS-only presets for `PresenceGroup`-driven surfaces (the engine never sees them).
* The motion config: keyframes + animation surfaces. Eidos generates CSS from
* `keyframes` + `signatures` + the css `presets`; the engine resolves + runs the
* `presets` (settled for css, driver for js).
*/
export interface MotionConfig {
readonly keyframes?: Readonly<Record<KeyframeName, KeyframeStops>>
readonly signatures?: Readonly<Record<string, EventSignature>>
readonly presets?: Readonly<Record<string, StatePreset>>
readonly coordinated?: Readonly<Record<string, CoordinatedPreset>>
}
export function isCssStatePreset(preset: StatePreset): preset is CssStatePreset {

@ -518,14 +518,25 @@ AudioContext se crea/resume en el primer user gesture (click/touch/keydown en do
Una firma perceptiva es el conjunto de decisiones de canal coordinadas alrededor de un evento.
**Las dimensiones no son un conjunto cerrado.** El framework ships con 5
canales canónicos (`motion`, `sound`, `color`, `presence`, `haptic`) y el
registry `SemaChannelSignatures` se extiende vía TypeScript declaration
merging cuando una app necesita `a11y`, `voice`, etc. Una family puede
no rellenar `presence`; otra puede aportar `haptic`. Una rule de
cascade puede silenciar `motion` para reducir estimulación. Los ejemplos
abajo enumeran las dimensiones relevantes para CADA evento — no una
lista canónica fija.
**Las dimensiones perceptivas no son un conjunto cerrado — pero ojo a quién
las ejecuta.** A nivel del libro, una firma compone los canales de expresión
(tiempo · motion · presencia · profundidad · forma · color · sonido · háptica).
El framework los **REPARTE por dueño** y NO los ejecuta todos en sema:
- **sema ejecuta 2 canales runtime** — `sound` + `haptic` (los únicos
declarados en `SemaChannelSignatures`) — más el **meta-canal `visual`**, que
no realiza ninguna modalidad: solo **estampa los `data-event-*` y temporiza
el hold**.
- **eidos materializa los canales visuales** (motion · presencia · profundidad ·
forma · color) reaccionando en CSS a esos `data-event-*` + `data-state` /
`data-intent`. Eidos es el **único dueño de lo visual**.
`SemaChannelSignatures` se extiende vía TypeScript declaration merging para
añadir canales **de sema** con firma (`a11y`, `voice`, …) — NO para los
visuales, que viven en eidos. Una rule de cascade afina `sound`/`haptic`;
`motion`/`color` se ajustan en los recipes de eidos, no en la cascade. Los
ejemplos de abajo enumeran las dimensiones **perceptivas** de cada evento —
recuerda que motion/forma/color las realiza **eidos**, no sema.
### 11.1. Ejemplo: commit.save + affirm

@ -1,5 +1,45 @@
# RFC — Servicio de motion de UIX (coordinación de presencia cross-layer)
> ## ⚠️ RETIRADA — diseño implementado y luego ELIMINADO (2026-06-19, «Plan A»)
>
> **El servicio de orquestación paralelo que diseña el cuerpo de este RFC (§4–§9 +
> Apéndices B/C: `MorfoPart.animation`, la prop `animation` ruteada, `PresenceGroup`,
> `DomCascade`, los presets coordinados `cascade-*` / `MotionConfig.coordinated`, la
> neutralización `animation: none !important`, y sus consumidores `Reveal` / `Rail`)
> se implementó, se validó… y se ELIMINÓ por completo.** El código ya no existe; este
> documento queda como **registro histórico** del diseño y su razonamiento.
>
> **Por qué (corrección del usuario — Apéndice D.2).** La animación NO es un eje
> paralelo: es **un canal de la firma del EVENTO**, y la firma la posee **sema en
> TODOS sus canales** (motion incluido). Modelar el motion coordinado como un motor
> propio —fuera del evento— producía DOS firmas para el mismo canal que convivían
> neutralizándose (Grieta 1, D.1). El libro pide UNA firma por evento, coordinada
> alrededor del evento.
>
> **El reparto correcto (hacia donde se reconstruye):**
> - **sema** lanza el evento: proyecta `data-event-*` + sonido/haptic + el hold.
> - **eidos** materializa el canal motion + la coordinación visual **reaccionando a
> `data-event-*`** (único dueño de lo visual; `arts/motion` sobrevive como _island_
> de progressive-enhancement para lo que CSS no puede — spring / FLIP).
> - **soma** posee el lifecycle (retener / esperar / desmontar): el `Presence` island,
> que se conserva.
> - La coordinación padre↔hijo **sigue siendo necesaria**, pero **cuelga del evento de
> sema**, no de un motor paralelo.
>
> **Qué se conservó:** `arts/motion` (engine spring/waapi/rect), el `Presence` island
> (`soma/layers/presence.svelte.ts`, reducido a una sola superficie), los **state-presets**
> (`motion` prop → `data-animation-style` sobre `data-state`: `scale-fade`, `slide-fade`,
> Material shared-axis/fade-through), las **firmas sema** visuales (`present-rise` /
> `dismiss-fade`) y el **stagger Material** (`--motion-stagger-{index,each}`). Modelo
> vigente: [`eidos-motion.md`](./eidos-motion.md) (dos momentos `--event` / `--state`).
>
> **El modelo hacia adelante** está en el **[Apéndice D](#apéndice-d--re-análisis-2026-06-16-grietas-y-principios-del-re-diseño)**
> (re-análisis 2026-06-16): principio rector, discriminante de los tres dominios, plan.
> La retirada de 2026-06-19 ejecutó de golpe la limpieza que D.6 preveía incremental
> (pasos 3 + 5): dejar el terreno limpio antes de reconstruir sobre el evento.
>
> ---
>
> Hermano de `eidos-motion.md` (que **extiende**, no contradice) y de los RFC de engine
> (`COLOR_ENGINE_RFC.md`, `DEPTH_ENGINE_RFC.md`, …). A diferencia de aquéllos —engines
> **visuales** de eidos— éste es un **servicio cross-layer** (morfo + soma + eidos + arts):
@ -21,13 +61,12 @@
> - **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 + 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.
> **Estado: RETIRADO (2026-06-19).** El texto a continuación (§0–§15, Apéndices A–C)
> documenta el diseño **tal como llegó a implementarse** —M1–M4 + M5/M6 + M9, con
> `PresenceGroup` / `DomCascade` / presets coordinados y los consumidores `Reveal` /
> `Rail` / `dropdown-menu`— y que se **eliminó por completo**. Ver la nota de retirada
> al inicio y el [Apéndice D](#apéndice-d--re-análisis-2026-06-16-grietas-y-principios-del-re-diseño)
> para el modelo hacia adelante. Se conserva como registro histórico.
## Tabla de contenidos
@ -1117,6 +1156,129 @@ real computa layout aunque la pestaña esté oculta; el preview headless no (`ge
(visual 100 % en eidos reutilizando el preset coordinado), el sonido sin neutralización (prueba de la
Grieta 1), y el registro-por-contexto real (ítems como componentes, no `{#each}`).
> El prototipo de la Fase 0 (`/temas/animations/menu-native`) se **eliminó** en la retirada D.8 — había
> cumplido su función de validar las Decisiones A y B. Su demo hermana de dos momentos
> (`/temas/animations`) se conserva (state-presets + firma sema, no coordinación).
### D.8 — La retirada ejecutada (2026-06-19, «Plan A»)
En lugar de la limpieza incremental de D.6 (pasos 3 + 5), el usuario optó por **retirar de golpe todo el
motor de orquestación paralelo** y dejar el terreno limpio antes de reconstruir la coordinación como canal
de la firma del evento (D.2). La retirada es **funcional y verificada** — `npm run check` no añade ningún
error (los pre-existentes son de otros tracks: icon-button, spin-field, scroll-area, palabras); las suites
de morfo / motion / soma-layers quedan verdes.
**Eliminado (archivos borrados):**
- `soma/layers/presence-group.ts` (+ test) · `soma/layers/dom-cascade.svelte.ts` (+ test) ·
`soma/layers/coordination.ts`.
- `soma/components/reveal/` · `soma/components/rail/` · `morfo/components/reveal.ts` ·
`morfo/components/rail.ts`.
- Demos `web/routes/temas/animations/{presence-group,rail,compuesto,reveal,dom-cascade,dropdown-menu,menu-native}/`.
**Eliminado (código recortado):**
- **morfo**: `MorfoPart.animation` + `MorfoAnimation` / `MorfoAnimationChildren` (types) · su schema +
invariante · `compilePartAnimation` / `CompiledPartAnimation` (compile) · re-exports.
- **arts/motion**: `CoordinatedPreset` / `CoordinatedPresetName` / `MotionCoordinatedPresets` ·
`MotionConfig.coordinated`.
- **eidos**: `BUILTIN_COORDINATED_PRESETS` (`cascade-*`) · `renderCoordinatedPresetRules` + su llamada ·
la neutralización `animation: none !important` · el bloque `declare module '$motion'` del registry ·
`coordinated: …` en `themes/base` · las reglas `[data-cascade]` + `[data-animation-style]` del cascade en
`dropdown-menu.css`. `generated/base.css` regenerado.
- **soma**: el camino agrupado de `Presence` (`group`/`groupRole`/`pending`/`PresenceMember`/`release`/
`unmount`/`cancel`/registro) → `presence.svelte.ts` reducido a **island de una superficie**; `FloatingShell`
pierde `pending`; el `dropdown-menu` provider pierde la prop `animation` / `data-cascade` / `getCascadeRows`.
**Conservado:** `arts/motion` (engine spring/waapi/rect) · el `Presence` island · los state-presets
(`scale-fade`/`slide-fade`/Material) · las firmas sema visuales (`present-rise`/`dismiss-fade`) · el stagger
Material (`--motion-stagger-{index,each}`) · la doctrina state/visual · el contrato morfo (menos `animation`).
**Próximo (sin implementar):** reconstruir la coordinación padre↔hijo como **canal de la firma del evento**
(D.2) — sema lanza el evento, eidos materializa motion + coordinación reaccionando a `data-event-*`, soma
posee el lifecycle (island). El prototipo Svelte-nativo (D.7) validó el _cómo_ visual; falta cablearlo al
evento de sema.
### D.9 — Modelo verificado (auditoría 2026-06-19): pipeline + las dos reglas duras
Tras la retirada, una auditoría de 8 agentes (5 lectores de ground-truth + 3 críticos adversariales) sobre
`src/uix/sema/*` + el libro fijó el modelo y cazó varios errores de framing. **El pipeline correcto, verbo
por dueño:**
```
morfo DEFINE el contrato semántico del evento (family/intent/verb/target/sequence/hold/a11y) + estructura (parts)
↓
soma DISPARA origina el evento (runtime.trigger) y envía a sema la carga semántica (engine.emit{target,name,family,intent…})
│ — el evento es de SOMA; sema es ORNAMENTAL: sin engine, trigger corre igual (prewrite+handler+effects)
↓
sema RESUELVE+PUBLICA resuelve la firma (cascada de 5 capas → SOLO sound/haptic) y, por el META-canal visual,
│ estampa data-event-* en UN target vía adom (dom.apply) + abre/cierra el HOLD
│ — en paralelo, la misma emisión realiza sound/haptic (canales reales, NO por adom)
↓
eidos REACCIONA CSS reaccionando a data-event-* (durante el hold) + data-state/data-intent
↓
MOTION (y presence/depth/shape/color) — el stagger/índice lo calcula EIDOS en CSS (sibling-index/nth-child)
```
**Las dos reglas duras (violarlas resucita bugs ya muertos):**
1. **Soma NUNCA escribe una `--var` visual.** El índice/ritmo del stagger es animación → es de eidos, que lo
computa en CSS desde la estructura (`sibling-index()` / `:nth-child`, como ya hacen `spinner.css` /
`avatar.css`). El `DomCascade` borrado escribía `--motion-stagger-index` desde soma — **esa** era la
violación de raíz, no solo el "motor paralelo". Soma aporta estructura (hijos en el DOM) + `data-state`;
nada visual.
2. **`data-event-*` es un sello de UN `signal.target`, NO un bus de difusión.** El projector estampa un solo
elemento; el resolver resuelve solo sound/haptic/hold. **No hay fan-out por hijo dirigido por el evento.**
La cascada de los hijos NO sale de `data-event-*` — sale de eidos leyendo estructura + `data-state`.
**Errores de framing que la auditoría corrigió (a no repetir):**
- "sema emite el evento" → soma lo ORIGINA; sema lo procesa (`runtime.svelte.ts:696`; sin engine soma sigue).
- "el engine no ejecuta nada" → no realiza modalidad, pero RESUELVE la firma + posee el await del hold + la
persistencia (`engine.ts:198-271`).
- "eidos es un canal par del de sonido" → error de categoría: eidos NO está en `SemaChannelSignatures` (solo
sound+haptic) ni recibe dispatch; es una **capa/realm** que reacciona a los tokens. Nunca registrar un
`EidosChannel` ni meter motion/color en `SemaChannelSignatures`.
- "`data-event-*` son tokens visuales" → son tokens **semánticos compartidos**: los lee la cascada de sema
(sound/haptic) Y el CSS de eidos (`stamp.ts:5-11`).
- el canal `visual` es **META**: no realiza modalidad — solo estampa `data-event-*` + temporiza el hold
(`engine.ts:243-248`, `sema/README.md:43`).
**Doc corregida:** `GUIA_IMPLEMENTACION_SEMAUIX.md` §11 decía "5 canales canónicos (motion, sound, color,
presence, haptic)" — stale; reescrita al split real (sema: sound+haptic+meta-visual · eidos: los visuales).
**Consecuencia para la Fase 1 (panel + N cards):** la cascada NO se conduce estampando un `data-event-*` rico
en el contenedor. El `emerge.open` del panel cae en UN target (su flourish + sonido). La entrada escalonada
de las cards es el momento `--state` + stagger, que **eidos** calcula en CSS desde la estructura; soma solo
aporta los hijos + `data-state`. El caso dinámico (ráfaga de apertura vs alta suelta) se distingue por
`data-state` (dominio de soma), no por una `--var` que soma escriba.
### D.10 — Prototipo de Fase 1 validado (2026-06-19)
Prototipo descartable en `web/routes/temas/animations/panel-cascade/` (`+page.svelte` + `panel-cascade.css`):
un contenedor coordinador + N `<Card>` reales. **Valida el modelo en los tres casos difíciles**, con el
reparto verificado y **cero `--var` visual escrita por JS** (confirmado por valores computados frame-a-frame):
- **Cascada externa** — entrada (`:nth-child` → `--row`, delay = row × ritmo) + salida en cierre de panel
(`:nth-last-child` → `--row-rev`, reverse). El índice lo computa eidos desde la estructura.
- **Borrado dinámico con retención** — al quitar cards, **soma retiene** el nodo (Svelte `out:`, leyendo la
duración de la regla de eidos) y marca `data-leaving` (flag de **estado**); **eidos pinta** la salida
(`[data-leaving]` → card-fall). Suelto → `--row-rev 0` → inmediato; en bloque → cascada inversa. Sin
`data-phase`: el `:nth-last-child` da los dos casos.
- **Anidamiento** — contenido interno de cada card cascadea componiendo el **`--row` heredado** (índice
externo de su card) + su **`--inner-row` propio** (`:nth-child` scopeado a su lista), secuenciado tras
asentar la card. Compone por **herencia de custom properties** → escala a profundidad arbitraria sin
maquinaria.
**Hallazgos para la implementación de producción:**
- **El CSS de eidos DEBE vivir en `.css` plano**, no en `<style>` de Svelte: Svelte **poda** los selectores
que dependen de atributos puestos solo en runtime (`[data-leaving]`) por detección de CSS-no-usado. Un
recipe real ya ships así, luego no es problema en producción — pero un prototipo en `<style>` falla.
- **El índice por nth-child tiene cap** (enumerado 1–16, como `spinner.css`/`avatar.css`). Para colecciones
sin tope: el wrapper de **eidos** se auto-indexa (eidos escribe su propia var, NO soma), o `sibling-index()`
cuando madure el soporte.
- **La salida en bloque de una colección dinámica es el borde** donde el CSS-puro-desde-estructura topa (el
nodo saliente ocupa layout + `nth-last-child` se desplaza al quitar hermanos). El usuario lo validó visualmente
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.
---
> **Fuentes.** Canon semántico: [`docs/CANON.md`](../../../docs/CANON.md). Arquitectura de

@ -67,30 +67,8 @@
line-height: var(--leading-ui);
cursor: pointer;
user-select: none;
/* Longhands, NOT the `transition` shorthand: the shorthand also resets
`transition-delay` to 0s, which (component CSS loads after the generated
coordinated preset) would override the cascade's per-row stagger delay when
a row joins the cascade (RFC §M9). Longhands leave `transition-delay` for the
preset to own. */
transition-property: background, color;
transition-duration: var(--duration-fast);
transition-timing-function: var(--ease-default);
}
/* A row in the coordinated item cascade (RFC §M9) hands its `transition` to the
generated `cascade-*` preset: opacity + transform, with the stagger
`transition-delay` the preset owns (a separate longhand it still sets). This
rule (specificity 0,2,0) beats the row's base transition (0,1,0) so the cascade
is not dropped. The hover background/color does not fade WHILE cascading — the
menu is the staggered surface; hover snaps. Only the four item archetypes need
this (separators / headings carry no competing transition). */
[data-dropdown-menu-item][data-animation-style],
[data-dropdown-menu-checkbox-item][data-animation-style],
[data-dropdown-menu-radio-item][data-animation-style],
[data-dropdown-menu-sub-trigger][data-animation-style] {
transition-property: opacity, transform;
transition-duration: var(--motion-cascade-duration, var(--duration-moderate));
transition-timing-function: var(--motion-cascade-ease, var(--ease-out));
transition: background var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-dropdown-menu-item]:hover:not([data-disabled]),
@ -114,16 +92,10 @@
cursor: default;
}
/* The dimmed disabled opacity must NOT fight the cascade off-state (RFC §M9): both are
`opacity` at the same specificity, and this component rule loads after the generated
preset, so it would win and pin a disabled row at its dim value — it could never fade
in/out. Gate it off while the row is in a coordinated enter/exit so the off-state
(opacity:0) shows through; at rest the row dims normally. (Value left as-is so the
theme agent's opacity-token canonicalisation stays the single source.) */
[data-dropdown-menu-item][data-disabled]:not([data-starting-style]):not([data-ending-style]),
[data-dropdown-menu-checkbox-item][data-disabled]:not([data-starting-style]):not([data-ending-style]),
[data-dropdown-menu-radio-item][data-disabled]:not([data-starting-style]):not([data-ending-style]),
[data-dropdown-menu-sub-trigger][data-disabled]:not([data-starting-style]):not([data-ending-style]) {
[data-dropdown-menu-item][data-disabled],
[data-dropdown-menu-checkbox-item][data-disabled],
[data-dropdown-menu-radio-item][data-disabled],
[data-dropdown-menu-sub-trigger][data-disabled] {
opacity: var(--dropdown-menu-item-disabled-opacity, 0.55);
}
@ -203,18 +175,3 @@
transform: none;
}
}
/* ── Cascade mode (RFC §M9) ────────────────────────────────────────────
When the item cascade owns the menu's motion (the provider stamps `data-cascade`
on the panel iff an `animation` preset is routed), the PANEL must not run its own
signature animation — the close `dismiss-fade` / the open keyframe — nor collapse
to opacity:0 the instant `data-state` flips to closed. Either would fade the whole
panel, taking the rows cascading INSIDE it along, before the stagger finishes (and
the panel's early unmount cancels the cascade). The panel stays put until the
cascade settles (soma's `pending` hook), then unmounts. */
[data-dropdown-menu-content][data-cascade] {
animation: none !important;
}
[data-dropdown-menu-content][data-cascade][data-state='closed'][data-ending-style] {
opacity: 1;
}

@ -157,18 +157,13 @@ 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.
> **Un tercer eje — el coordinado.** El **servicio de motion de coordinación**
> ([`MOTION_SERVICE_RFC.md`](./MOTION_SERVICE_RFC.md)) añade una tercera superficie
> visual: el **coordinado**, sobre `data-starting/ending-style` (la presencia de un
> `PresenceGroup`), con sus propios presets (`MotionConfig.coordinated`, p. ej.
> `cascade-slide`). Convive con los dos momentos de aquí bajo la misma regla de
> 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).
> **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.
---
@ -415,7 +410,9 @@ el mismo patrón vale para cualquier overlay — basta pasar `motion` a su `Pres
**sin prop ni acoplamiento** (el viejo `DialogProps.runMotion` / `eidos.motionRunner`
desapareció en el refactor).
**Orquestación de hijos** (pendiente): `getAnimations({ subtree: true })`.
**Orquestación de hijos** (pendiente, en re-diseño): cuelga de la firma del evento
(sema) materializada por eidos, no de un motor paralelo — ver Apéndice D del
[`MOTION_SERVICE_RFC.md`](./MOTION_SERVICE_RFC.md).
### 9.1 — SSR / hidratación
Los overlays montan al abrir en cliente; motion dispara en transiciones, no en

@ -991,12 +991,8 @@
--dialog-overlay-opacity: 62%;
--dialog-overlay-blur: var(--blur-lg);
--dialog-overlay-z: 70;
--dialog-content-bg: var(--color-surface-overlay);
--dialog-content-color: var(--color-content-primary);
--dialog-content-border: var(--color-border-default);
--dialog-content-border-width: var(--border-width);
--dialog-content-radius: var(--radius-xl);
--dialog-content-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);
--dialog-content-max-height: calc(100dvh - var(--space-8));
--dialog-content-max-height-sheet: 85dvh;
--dialog-content-width-sm: 420px;
@ -1151,10 +1147,7 @@
--drawer-handle-transition-ease: var(--ease-out);
--drawer-content-bg: var(--color-surface-overlay);
--drawer-content-color: var(--color-content-primary);
--drawer-content-border: var(--color-border-default);
--drawer-content-border-width: var(--border-width);
--drawer-content-radius: var(--radius-xl);
--drawer-content-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);
--drawer-content-shadow-dragging: var(--shadow-overlay-strong, var(--depth-overlay-shadow)), var(--depth-overlay-halo);
--drawer-content-ring-dragging-width: var(--border-width-medium);
--drawer-content-ring-dragging-color: color-mix( in srgb, var(--color-content-secondary) 32%, transparent );
@ -4408,12 +4401,16 @@
[data-depth='raised'] {
background: var(--depth-raised-surface);
font-family: var(--font-ui);
line-height: var(--leading-ui);
border: var(--border-width) solid var(--depth-raised-border);
box-shadow: var(--depth-raised-shadow), var(--depth-raised-halo);
}
[data-depth='overlay'] {
background: var(--depth-overlay-surface);
font-family: var(--font-ui);
line-height: var(--leading-ui);
border: var(--border-width) solid var(--depth-overlay-border);
box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);
}
@ -4426,6 +4423,8 @@
[data-depth='modal'] {
background: var(--depth-modal-surface);
font-family: var(--font-ui);
line-height: var(--leading-ui);
border: var(--border-width) solid var(--depth-modal-border);
box-shadow: var(--depth-modal-shadow), var(--depth-modal-halo);
}
@ -4438,6 +4437,8 @@
[data-depth='recessed'] {
background: var(--depth-recessed-surface);
font-family: var(--font-ui);
line-height: var(--leading-ui);
border: var(--border-width) solid var(--depth-recessed-border);
box-shadow: var(--depth-recessed-shadow);
}

@ -16,7 +16,7 @@
* measured size a component sets). The keyframe is a template.
*/
import type { CoordinatedPreset, CssStatePreset, EventSignature, KeyframeStops } from '$motion'
import type { CssStatePreset, EventSignature, KeyframeStops } from '$motion'
const D = 'var(--motion-distance-md)'
const NEG_D = `calc(${D} * -1)`
@ -377,17 +377,3 @@ export const BUILTIN_CSS_PRESETS: Readonly<Record<string, CssStatePreset>> = {
}
}
/**
* Coordinated presets (M6) — for `PresenceGroup`-driven surfaces. They react to
* soma's `data-starting-style` / `data-ending-style` (the group's release), NOT
* `data-state`, so they compose with the coordinated cascade instead of firing on
* mount. A component sets `animation="cascade-…"` + the rhythm
* (`--motion-stagger-each` / `--motion-stagger-count`); eidos generates the transition,
* the off-state, and the reversible stagger. `cascade-` prefix keeps them out of the
* `data-state` preset namespace. Custom ones: add to `EidosConfig.motion.coordinated`.
*/
export const BUILTIN_COORDINATED_PRESETS: Readonly<Record<string, CoordinatedPreset>> = {
'cascade-slide': { off: { opacity: '0', transform: 'translateX(-16px)' } },
'cascade-fade': { off: { opacity: '0' } },
'cascade-scale': { off: { opacity: '0', transform: 'scale(0.9)' } }
}

@ -38,21 +38,3 @@ export interface EidosMotionPresets {
* shape sema uses for `SemaChannelId`.
*/
export type MotionPresetName = keyof EidosMotionPresets | 'none' | (string & {})
/**
* Eidos POPULATES the COORDINATED-preset registry — the type-safe catalog for the
* `animation` prop (RFC: eidos/MOTION_SERVICE_RFC.md §5, "separadas por rol"). That
* registry (`MotionCoordinatedPresets`) lives in `$motion` (the shared layer) so soma
* components (Rail / Reveal) can type their `animation` prop without importing eidos.
* Eidos owns the NAMES (its built-in coordinated presets), so it merges them in here.
*
* Keep these keys in sync with `BUILTIN_COORDINATED_PRESETS` (`presets/css.ts`) — a
* test in `motion.test.ts` enforces the built-ins are all declared.
*/
declare module '$motion' {
interface MotionCoordinatedPresets {
'cascade-slide': true
'cascade-fade': true
'cascade-scale': true
}
}

@ -948,7 +948,17 @@ function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
const blocks: string[] = []
for (const [plane, cues] of Object.entries(depth.planes)) {
const lines: string[] = []
if (cues.surface !== undefined) lines.push(`background: var(--depth-${plane}-surface);`)
if (cues.surface !== undefined) {
lines.push(`background: var(--depth-${plane}-surface);`)
// On-surface typography (Decisión 8): a portaled surface does NOT inherit
// the page font, so unstyled body text falls to the browser serif (Times
// New Roman). Anchor the UI font + leading on the surface so every
// elevated/portaled overlay reads correctly regardless of the portal.
// Parts that want a different face (e.g. a serif `--font-heading` title)
// override it on their own element.
lines.push(`font-family: var(--font-ui);`)
lines.push(`line-height: var(--leading-ui);`)
}
if (cues.border !== undefined)
lines.push(`border: var(--border-width) solid var(--depth-${plane}-border);`)
const shadowParts: string[] = []

@ -16,8 +16,7 @@ import { RADIX_EXTRA_LIGHT_SCALES, RADIX_EXTRA_DARK_SCALES } from './radix-scale
import {
BUILTIN_KEYFRAMES,
BUILTIN_SIGNATURES,
BUILTIN_CSS_PRESETS,
BUILTIN_COORDINATED_PRESETS
BUILTIN_CSS_PRESETS
} from '../motion/presets/css'
export const THEME_BASE_COLOR_ROLES: ColorRoleMap = {
@ -496,8 +495,7 @@ export const THEME_BASE_OPTIONS: EidosConfig = defineEidosConfig({
motion: {
keyframes: BUILTIN_KEYFRAMES,
signatures: BUILTIN_SIGNATURES,
presets: BUILTIN_CSS_PRESETS,
coordinated: BUILTIN_COORDINATED_PRESETS
presets: BUILTIN_CSS_PRESETS
},
themes: {
'base-light': {

@ -2,15 +2,10 @@ import { describe, expect, it } from 'vitest';
import { validateEidosConfig } from './lib/config';
import { createEngineMotion } from '$motion';
import {
BUILTIN_COORDINATED_PRESETS,
BUILTIN_CSS_PRESETS,
BUILTIN_KEYFRAMES
} from './lib/motion/presets/css';
import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES } from './lib/motion/presets/css';
import { renderStaticCss } from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base';
import type { EidosMotionPresets } from './lib/motion/registry';
import type { MotionCoordinatedPresets } from '$motion';
describe('eidos motion — CSS generation', () => {
const css = renderStaticCss(createThemeBaseEidosConfig());
@ -51,45 +46,6 @@ describe('eidos motion — CSS generation', () => {
);
});
it('emits coordinated presets (M6) — gated on data-starting/ending-style, not data-state', () => {
// The off-state is applied while soma holds data-starting-style (before enter)
// or data-ending-style (during exit) — the group's release, NOT data-state.
expect(css).toContain(
"[data-animation-style='cascade-slide'][data-starting-style], [data-animation-style='cascade-slide'][data-ending-style] {"
);
// The transition covers exactly the off properties (slide = opacity + transform),
// with the INHERITING `--motion-cascade-*` overrides (a container sets them once).
expect(css).toContain(
'transform var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out))'
);
// fade is opacity-only → no transform in its rules.
expect(css).toContain("[data-animation-style='cascade-fade'][data-starting-style]");
// The reversible canonical stagger: enter counts UP, exit counts DOWN.
expect(css).toContain(
'transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms))'
);
expect(css).toContain(
'transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms))'
);
// Crucially: coordinated presets do NOT emit data-state rules (those fire on
// MOUNT and would break the coordinated cascade — the §5 finding).
expect(css).not.toContain("[data-animation-style='cascade-slide'][data-state=");
});
it('coordinated surfaces opt OUT of the sema visual firma (modo c enabler)', () => {
// If a coordinated surface ALSO fires a sema event, the engine stamps data-event-*
// and eidos's generic firma (present-rise…) would fight the cascade. eidos
// neutralises its OWN firma here — motion owns the visual axis, sema is untouched:
// animation:none kills the firma keyframe; the cascade is a transition, so it
// survives. This lets a coordinated component compose sound/haptic.
expect(css).toMatch(
/\[data-animation-style='cascade-slide'\]\[data-event-phase='active'\] \{\s*animation: none !important;/
);
for (const name of ['cascade-slide', 'cascade-fade', 'cascade-scale']) {
expect(css).toContain(`[data-animation-style='${name}'][data-event-phase='active'] {`);
}
});
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-y'][data-state='open']`);
@ -297,17 +253,4 @@ describe('eidos motion — F7 typegen registry', () => {
] as const satisfies readonly (keyof EidosMotionPresets)[];
expect([...Object.keys(BUILTIN_CSS_PRESETS)].sort()).toEqual([...registered].sort());
});
it('declares every built-in COORDINATED preset as a type-safe registry name', () => {
// Mirror of the CSS-preset test, for the `animation` prop's catalog. The
// `satisfies keyof MotionCoordinatedPresets` makes a typo fail compile (the
// registry lives in `$motion`, populated by eidos); the runtime check pins it
// to the ACTUAL coordinated presets, so a new built-in must be declared (RFC §5).
const registered = [
'cascade-slide',
'cascade-fade',
'cascade-scale'
] as const satisfies readonly (keyof MotionCoordinatedPresets)[];
expect([...Object.keys(BUILTIN_COORDINATED_PRESETS)].sort()).toEqual([...registered].sort());
});
});

@ -437,90 +437,3 @@ describe('compileMorfo — invariants', () => {
})
})
describe('compileMorfo — animation surfaces (RFC: MOTION_SERVICE_RFC.md §4)', () => {
// Synthetic morfo: a list whose Provider is an animable surface that
// coordinates its Item children on EXIT (`when: 'after'` — the parent waits
// for the items to leave). Mirrors the exit-heavy prototype of RFC §13.
const listMorfo = {
name: 'AnimList',
kebab: 'anim-list',
scope: ['soma', 'eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'ul',
optional: false,
data: [],
aria: [],
animation: { surface: true, children: { exit: 'after' } }
},
{
name: 'Item',
kebab: 'item',
kind: 'public',
defaultElement: 'li',
optional: false,
data: [],
aria: [],
animation: { surface: true }
},
{
name: 'Label',
kebab: 'label',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as never
it('normalizes a coordinating surface (surface + children.exit)', () => {
const compiled = compileMorfo(listMorfo)
const provider = compiled.parts.byKebab.get('provider')!
expect(provider.animation).toEqual({
surface: true,
children: { enter: 'together', exit: 'after' }
})
})
it('normalizes a leaf surface (surface only, no children)', () => {
const compiled = compileMorfo(listMorfo)
const item = compiled.parts.byKebab.get('item')!
expect(item.animation).toEqual({ surface: true, children: undefined })
})
it('leaves animation undefined for non-surface parts', () => {
const compiled = compileMorfo(listMorfo)
const label = compiled.parts.byKebab.get('label')!
expect(label.animation).toBeUndefined()
})
it("defaults children.enter/exit to 'together' when omitted", () => {
const morfo = {
name: 'Together',
kebab: 'together',
scope: ['soma'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: [],
animation: { surface: true, children: {} }
}
]
} as never
const compiled = compileMorfo(morfo)
expect(compiled.parts.byKebab.get('provider')!.animation).toEqual({
surface: true,
children: { enter: 'together', exit: 'together' }
})
})
})

@ -29,7 +29,6 @@
import type {
Morfo,
MorfoA11ySemantic,
MorfoAnimation,
MorfoAriaEntry,
MorfoArchetype,
MorfoCondition,
@ -140,24 +139,6 @@ export interface SourceDeps {
readonly needsTranslations: boolean
}
/**
* Compiled, normalized animation/presence structure of a part (RFC:
* eidos/MOTION_SERVICE_RFC.md §4). `surface` is always a boolean; `children.when`
* is defaulted to `'together'`. `undefined` when the part declares no
* `animation`. The soma `PresenceGroup` (phase M2) derives the coordination
* tree from these compiled views — the compiler never coordinates, it only
* normalizes the declared structure.
*/
export interface CompiledPartAnimation {
readonly surface: boolean
readonly children:
| {
readonly enter: 'before' | 'after' | 'together'
readonly exit: 'before' | 'after' | 'together'
}
| undefined
}
/**
* Compiled view of a single part. Replaces walking `MorfoPart` at runtime.
*
@ -189,11 +170,6 @@ export interface CompiledPart {
readonly parentKebab: string | undefined
/** Direct child kebabs (one level deep). Empty for leaves. */
readonly childKebabs: readonly string[]
/**
* Normalized animation/presence structure (RFC: eidos/MOTION_SERVICE_RFC.md
* §4). `undefined` when this part is not an animable surface.
*/
readonly animation: CompiledPartAnimation | undefined
}
// ── Action plan ────────────────────────────────────────────────────────────
@ -496,8 +472,7 @@ function walkParts(
needsTranslations
}),
parentKebab,
childKebabs: Object.freeze((part.parts ?? []).map((p) => p.kebab)),
animation: compilePartAnimation(part.animation)
childKebabs: Object.freeze((part.parts ?? []).map((p) => p.kebab))
})
partsByKebab.set(part.kebab, compiledPart)
@ -649,28 +624,6 @@ function compileEvent(event: MorfoEvent): ActionPlan {
})
}
/**
* Compile a part's declared `animation` into its normalized presence structure
* (RFC: eidos/MOTION_SERVICE_RFC.md §4). STRUCTURE only — no visual values.
* `surface` defaults to `false`; `children.when` defaults to `'together'`.
* Returns `undefined` for parts that declare no `animation` (most parts) so the
* soma runtime can cheaply skip them.
*/
function compilePartAnimation(
animation: MorfoAnimation | undefined
): CompiledPartAnimation | undefined {
if (!animation) return undefined
return Object.freeze({
surface: animation.surface ?? false,
children: animation.children
? Object.freeze({
enter: animation.children.enter ?? 'together',
exit: animation.children.exit ?? 'together'
})
: undefined
})
}
// ── Source dep collection ──────────────────────────────────────────────────
interface DepSink {

@ -124,11 +124,7 @@ export const dropdownMenuMorfo = {
{ key: 'End', action: 'last-item' },
{ key: 'Enter', action: 'activate' },
{ key: ' ', action: 'activate' }
],
// RFC §M9 (children-DOM cascade): the content is an animable surface
// that staggers its menu rows. `staggerChildren` names the lead item
// part; the soma provider resolves the DOM node set (all visible rows).
animation: { surface: true, staggerChildren: 'item' }
]
},
{
name: 'Item',

@ -1,55 +0,0 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Rail — a horizontal strip of items that CASCADE in/out, parent-controlled via
* `open` (no trigger, no events). The SECOND consumer of the coordinated-motion
* helper (`soma/layers/coordination.ts`), built to prove it is reusable across a
* DIFFERENT shape than `Reveal`:
*
* - the ROOT (`Provider`) IS the owner surface (vs Reveal's virtual root + separate
* Panel owner);
* - the relation is `{ enter: 'together', exit: 'together' }` (all parallel, the
* visual stagger is CSS only) — vs Reveal's `{ before, after }` bracket;
* - NO sema events — the parent owns `open`, the component is pure visual
* coordination. So `scope` is just `['soma']` (no events ⇒ no 'sema' needed) and
* there is no `channels: []`/`expression` to silence (nothing emits).
*
* RFC: eidos/MOTION_SERVICE_RFC.md.
*/
export const railMorfo = {
name: 'Rail',
kebab: 'rail',
scope: ['soma'],
texts: {
label: '#?components.rail.label|Rail'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'list',
optional: false,
// ROOT + owner animable surface. `together` = rail + items enter/exit in
// parallel; the visual stagger is CSS only (data-starting/ending-style).
animation: { surface: true, children: { enter: 'together', exit: 'together' } },
data: [],
aria: []
},
{
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'div',
role: 'listitem',
optional: false,
animation: { surface: true },
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -1,136 +0,0 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Reveal — disclosure list (Trigger + Panel + Items). The FIRST real consumer of
* the motion-coordination contract (RFC: eidos/MOTION_SERVICE_RFC.md). The Panel
* is an animable OWNER surface that coordinates its Item CHILD surfaces:
*
* panel.animation.children = { enter: 'before', exit: 'after' }
*
* — the panel appears, THEN the items cascade in; on close the items leave first
* while the panel RETAINS its DOM (exit-heavy, §8.1), then the panel goes. soma
* reads these compiled `animation.children` to build the `PresenceGroup` (it is
* the contract that drives the coordination, not hand-wiring). The visual stagger
* is CSS (the demo / eidos) — soma only sequences WHEN each surface enters/exits.
*
* Disclosure semantics (Trigger aria-expanded/aria-controls, Panel region) mirror
* Collapsible; the only addition is the parent↔children animation coordination.
* `emerge` family (transitional, no intent) for open/close.
*/
export const revealMorfo = {
name: 'Reveal',
kebab: 'reveal',
scope: ['soma', 'sema'],
// Modo (c) — composición sema + motion (RFC Apéndice B). `open` declares
// `channels: ['sound']`: it PLAYS an `emerge` sound (sema) WHILE the coordinated
// cascade runs (motion). They don't fight because eidos NEUTRALISES its own generic
// VISUAL firma over coordinated surfaces (`render-css.ts`: `[data-animation-style][
// data-event-phase='active'] { animation: none !important }`) — the firma's keyframe
// dies, the cascade's `transition` survives. The opt-out lives in MOTION, never in
// sema. Both `open` and `close` declare `channels: ['sound']` — emerge sounds on
// appear AND on dismiss; eidos neutralises the visual firma either way (cascade survives).
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/',
texts: {
label: '#?components.reveal.label|Reveal'
},
events: [
{
name: 'open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('panel'),
// State is set in the soma handler → `post`, so the perceptual hold never
// blocks the functional open (CLAUDE.md sequencing doctrine).
sequence: 'post',
// Modo (c): the sound plays WITH the cascade. eidos neutralises the generic
// VISUAL firma over the coordinated surface, so only sound + cascade land.
channels: ['sound']
}
},
{
name: 'close',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('panel'),
sequence: 'post',
// Modo (c) también al cerrar — emerge sounds on dismiss too (symmetry); the
// exit cascade (data-ending-style) runs while the visual firma is neutralised.
channels: ['sound']
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'virtual',
defaultElement: 'none',
optional: false,
data: [],
aria: []
},
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{
attr: 'aria-controls',
value: v.partRef('panel'),
condition: { when: 'part-present', part: 'panel' },
severity: 'recommended'
}
]
},
{
name: 'Panel',
kebab: 'panel',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
role: 'region',
optional: false,
states: ['open', 'closed'],
// OWNER animable surface — coordinates its Item children. `before` enter
// = panel in, then items cascade; `after` exit = items out first while the
// panel retains its DOM, then the panel (§8.1 exit-heavy). soma derives the
// PresenceGroup `when` from exactly this.
animation: { surface: true, children: { enter: 'before', exit: 'after' } },
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('trigger'),
condition: { when: 'part-present', part: 'trigger' },
severity: 'recommended'
}
]
},
{
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'div',
optional: false,
// CHILD animable surface — the group releases it per the panel's
// `children` relation. No state/aria of its own; it is a coordinated leaf.
animation: { surface: true },
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -25,8 +25,6 @@ export type {
MorfoEventSemantic,
MorfoA11ySemantic,
MorfoEvent,
MorfoAnimationChildren,
MorfoAnimation,
MorfoPart,
Morfo
} from './types';
@ -61,7 +59,6 @@ export {
type ActionPlan,
type CompiledMorfo,
type CompiledPart,
type CompiledPartAnimation,
type CssSelectorContract,
type DataAttrContract,
type KeyboardPlan,

@ -84,37 +84,3 @@ describe('validateMorfo — data attrs', () => {
).toThrow(/emit is only valid for non-enum data attrs/);
});
});
describe('validateMorfo — animation surfaces (RFC: MOTION_SERVICE_RFC.md §4)', () => {
function withAnimation(animation: unknown) {
return {
...baseMorfo,
parts: [{ ...baseMorfo.parts[0], animation }]
};
}
it('accepts a surface with no children', () => {
expect(() => validateMorfo(withAnimation({ surface: true }))).not.toThrow();
});
it('accepts a coordinating surface (surface + children.exit)', () => {
expect(() =>
validateMorfo(withAnimation({ surface: true, children: { exit: 'after' } }))
).not.toThrow();
});
it('rejects children declared on a non-surface part', () => {
expect(() => validateMorfo(withAnimation({ children: { exit: 'after' } }))).toThrow(
MorfoInvariantError
);
expect(() => validateMorfo(withAnimation({ children: { exit: 'after' } }))).toThrow(
/must itself be a surface/
);
});
it('rejects an unknown phase value', () => {
expect(() =>
validateMorfo(withAnimation({ surface: true, children: { exit: 'eventually' } }))
).toThrow();
});
});

@ -316,22 +316,6 @@ const focusSchema = object({
// Sium lacks `lazy()`, so we validate a single part without its `parts?`
// children, then the walker recurses.
// Animation — presence-coordination structure (RFC: eidos/MOTION_SERVICE_RFC.md
// §4). Shape only; the cross-field invariant (children ⇒ surface) lives in
// `validateInvariants`. The `when` enum is validated structurally here.
const animationWhenSchema = union(literal('before'), literal('after'), literal('together'));
const animationChildrenSchema = object({
enter: optional(animationWhenSchema),
exit: optional(animationWhenSchema)
});
const animationSchema = object({
surface: optional(boolean()),
children: optional(animationChildrenSchema),
staggerChildren: optional(string())
});
const partShallowSchema = object({
name: string(),
kebab: string(),
@ -345,7 +329,6 @@ const partShallowSchema = object({
data: array(dataSchema),
aria: array(ariaEntrySchema),
keyboard: optional(array(keyboardSchema)),
animation: optional(animationSchema),
parts: optional(array(object({}, { unknownKeys: 'passthrough' })))
// ^ children passed through opaquely — shape is checked by the walker
});
@ -590,36 +573,6 @@ function validateInvariants(morfo: Morfo): void {
}
}
// Animation (RFC: eidos/MOTION_SERVICE_RFC.md §4/§6). A part that coordinates
// child surfaces is itself a surface, so `animation.children` requires
// `animation.surface: true`. Choreography is opt-in BY DECLARATION (RFC §6):
// only a part that declares `children` awaits/staggers its descendants.
for (const { part, path } of flat) {
if (part.animation?.children && part.animation.surface !== true) {
throw new MorfoInvariantError(
`part "${part.kebab}" declares animation.children but animation.surface is not true — a part that coordinates child surfaces must itself be a surface`,
path
);
}
// staggerChildren (RFC §M9): the owner of a children-DOM cascade is itself a
// surface, and the two coordination models are mutually exclusive (Presence
// children vs DOM children — pick one).
if (part.animation?.staggerChildren !== undefined) {
if (part.animation.surface !== true) {
throw new MorfoInvariantError(
`part "${part.kebab}" declares animation.staggerChildren but animation.surface is not true — the owner of a children-DOM cascade must itself be a surface`,
path
);
}
if (part.animation.children) {
throw new MorfoInvariantError(
`part "${part.kebab}" declares both animation.children and animation.staggerChildren — Presence-children and DOM-children are different coordination models; pick one`,
path
);
}
}
}
if (morfo.focus) {
const f = morfo.focus;
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {

@ -684,78 +684,6 @@ export const ARCHETYPE_VOCABULARY = [
// ── Part ──────────────────────────────────────────────────────────────────
// ── Animation — presence-coordination structure ─────────────────────────────
//
// The morfo half of the motion service (RFC: eidos/MOTION_SERVICE_RFC.md §4,
// phase M1). STRUCTURE only — no visual/expressive values: stagger-ms, easing
// and keyframes live in eidos; the runtime coordination (PresenceGroup) lives
// in soma. Justified in the contract by the 2-of-3 rule (soma lifecycle + eidos
// paints both consume it).
/**
* How a parent surface coordinates the presence lifecycle of its child
* surfaces. This lives in the contract (not in eidos) because it has a
* LIFECYCLE consequence the runtime must honor — see `when`.
*/
export interface MorfoAnimationChildren {
/**
* Parent↔child relation on ENTER. `'before'` = the parent enters, then its
* children; `'after'` = children enter, then the parent; `'together'` =
* parallel (default).
*/
enter?: 'before' | 'after' | 'together';
/**
* Parent↔child relation on EXIT. `'after'` = children leave first while the
* parent RETAINS its DOM, then the parent leaves (RFC §8.1); `'before'` = the
* parent leaves first; `'together'` = parallel (default).
*
* Enter and exit are SEPARATE because the natural container BRACKETS its
* children — it appears BEFORE them and leaves AFTER them. A single symmetric
* relation can't express that; `{ enter: 'before', exit: 'after' }` can.
*/
exit?: 'before' | 'after' | 'together';
}
/**
* Declares a part as an animable surface and (optionally) how it coordinates
* the presence lifecycle of its child surfaces.
*
* STRUCTURE only — this is the cross-layer contract, not the look. Which
* animation plays, the stagger duration, easing and keyframes are EIDOS's
* (visual/expressive); the lifecycle coordination is SOMA's (PresenceGroup,
* phase M2). Morfo only declares *which parts are surfaces* and the
* parent↔child dependency.
*
* Backward-compatible: optional. A part that doesn't declare `animation`
* animates exactly as today (its `Presence` an island). `children` is opt-in
* coordination (RFC §6): a parent only awaits/staggers its children when it
* declares `children`.
*/
export interface MorfoAnimation {
/** This part is an animable surface (has a coordinable presence lifecycle). */
surface?: boolean;
/**
* Coordination with this part's child surfaces. Declaring it OPTS the part
* into parent↔child choreography. Requires `surface: true` (a part that
* coordinates children is itself a surface) — enforced by `validateMorfo`.
*/
children?: MorfoAnimationChildren;
/**
* Children-DOM stagger (RFC §M9 — the second coordination mode). Unlike
* `children` (each child is a registered `Presence` that mounts/unmounts and
* derives its index from the `PresenceGroup`), this owner has STATIC DOM
* children — items already in the DOM when the owner opens, discovered by
* selector (e.g. a menu's `menuitem`s). The owner PROPAGATES its own
* `data-starting/ending-style` + a per-DOM-order `--motion-stagger-index` + the
* routed `data-animation-style` onto each child, so eidos's coordinated preset
* applies UNCHANGED. The value is the kebab of the child item part; the soma
* provider resolves its DOM nodes. Requires `surface: true`. Mutually exclusive
* with `children` (Presence vs DOM are different coordination models) — enforced
* by `validateMorfo`.
*/
staggerChildren?: string;
}
/**
* A single part of a component. Parts compose into a tree.
*
@ -823,13 +751,6 @@ export interface MorfoPart {
keyboard?: readonly MorfoKeyboard[];
/** Nested parts (e.g. `Accordion.Item` contains `Header`, `Trigger`, `Content`). */
parts?: readonly MorfoPart[];
/**
* Animation / presence-coordination structure (RFC: eidos/MOTION_SERVICE_RFC.md
* §4, phase M1). Marks this part as an animable surface and declares how it
* coordinates its child surfaces' lifecycle. STRUCTURE only — no visual values.
* Optional; absence = not an animable surface (today's behavior).
*/
animation?: MorfoAnimation;
}
// ── Sema expression mode ─────────────────────────────────────────────────

@ -138,26 +138,17 @@ 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).
## Motion
The panel uses the standard overlay **state-preset**: `motion="slide-fade"` (default) writes
`data-animation-style`, and the recipe animates it over `data-state` (`dropdown-menu-enter` on open).
The open/close **sema firma** (`emerge`) plays on the content during the hold (see Sema events).
> A previous **opt-in item cascade** (`animation="cascade-slide"`, the rows staggering in/out via a
> `DomCascade`) was **removed** on 2026-06-19 along with the rest of the parallel motion-orchestration
> service — see the retirement note in
> [`MOTION_SERVICE_RFC.md`](../../../eidos/MOTION_SERVICE_RFC.md). Coordinated per-row motion will
> return as a channel of the menu's `emerge.open` firma, materialised by eidos — not a parallel motor.
## Sema events

@ -19,7 +19,6 @@
onOpenChangeComplete = () => {},
dir,
accessibleWhenDisabled = true,
animation,
children
}: MenuProps = $props();
@ -34,8 +33,7 @@
),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
accessibleWhenDisabled: readableActive(() => accessibleWhenDisabled),
animation: readableActive(() => animation)
accessibleWhenDisabled: readableActive(() => accessibleWhenDisabled)
});
</script>

@ -8,7 +8,6 @@ import {
type ActiveProps,
type StateProps
} from '$libs/reactive';
import type { CoordinatedPresetName } from '$motion';
import type {
OnChangeFn,
SomaMouseEvent,
@ -22,7 +21,6 @@ import { Soma } from '../../core/soma.svelte';
import { Typeahead } from '../../typeahead';
import { Presence } from '../../layers/presence.svelte';
import { DomCascade } from '../../layers/dom-cascade.svelte';
import { FocusScope } from '../../layers/focus-scope.svelte';
import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte';
import { ScrollLock } from '../../layers/scroll-lock.svelte';
@ -56,7 +54,6 @@ interface MenuOpts
dir: Direction;
accessibleWhenDisabled: boolean;
onOpenChangeComplete: OnChangeFn<boolean>;
animation: CoordinatedPresetName | undefined;
}> {}
export class MenuProvider {
@ -79,8 +76,6 @@ export class MenuProvider {
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
readonly typeahead: Typeahead;
/** RFC §M9 children-DOM cascade over the menu rows; inert until `animation` is set. */
readonly cascade: DomCascade;
contentId = state('');
triggerId = state('');
@ -107,43 +102,11 @@ export class MenuProvider {
dom: this.soma.dom,
open: opts.open,
contentRef: this.contentRef,
onOpenChangeComplete: opts.onOpenChangeComplete,
// RFC §M9: on exit, hold the panel until the item cascade settles. The
// arrow is lazy (the cascade is built just below); `pending` only fires
// on a real close, long after construction. No `animation` ⇒ inert.
pending: () => this.cascade?.pending()
onOpenChangeComplete: opts.onOpenChangeComplete
});
this.floatingProvider = shell.floatingProvider;
this.contentPresence = shell.contentPresence;
// RFC §M9 children-DOM cascade: mirror the content owner's lifecycle onto the
// menu rows so they stagger in/out, reusing eidos's coordinated preset
// unchanged. `animationStyle` undefined (no `animation` prop) ⇒ `sync`
// early-returns, so the menu behaves exactly as before until opted in.
this.cascade = new DomCascade({
dom: this.soma.dom,
animationStyle: opts.animation,
ownerTransitionAttrs: readableActive(() => this.contentPresence.transitionAttrs),
items: () => this.getCascadeRows(this.contentRef.current)
});
this.cascade.watch();
// When a cascade is routed, mark the panel so eidos can suppress its OWN
// signature animation (the `dismiss-fade` close keyframe / the open keyframe):
// otherwise the panel fades as a whole — taking the rows cascading inside it
// with it — before the stagger finishes, and its early unmount cancels the
// cascade. The eidos firma-neutralization only covers the rows (they carry
// `data-animation-style`); the owner panel needs this explicit marker.
$effect(() => {
const content = this.contentRef.current;
if (!content) return;
if (this.opts.animation.current) {
this.soma.dom.apply({ target: content, attrs: { 'data-cascade': '' } });
} else {
this.soma.dom.remove(content, ['data-cascade']);
}
});
// Cleanup typeahead timer on unmount
$effect(() => {
return () => this.typeahead.destroy();
@ -194,30 +157,6 @@ export class MenuProvider {
(el) => el.closest(`[${attrs.content}], [${attrs['sub-content']}]`) === container
);
}
/**
* The rows the children-DOM cascade animates (RFC §M9). BROADER than
* `getItems` (the keyboard-nav set): every visible row — all item archetypes
* AND separators AND group headings, disabled included — so the whole menu
* staggers as one (decision C). Scoped to this container's own rows (an open
* submenu's rows are excluded via `closest`), in document order.
*/
getCascadeRows(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
const selector = [
attrs.item,
attrs['checkbox-item'],
attrs['radio-item'],
attrs['sub-trigger'],
attrs.separator,
attrs['group-heading']
]
.map((a) => `[${a}]`)
.join(', ');
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.closest(`[${attrs.content}], [${attrs['sub-content']}]`) === container
);
}
}
// ── Trigger ──────────────────────────────────────────────────────────────────

@ -1,5 +1,4 @@
import type { Snippet } from 'svelte';
import type { CoordinatedPresetName } from '$motion';
import type {
WithChild,
Without,
@ -30,13 +29,6 @@ export type MenuProps = {
* @default true
*/
accessibleWhenDisabled?: boolean;
/**
* Coordinated entrance/exit cascade for the menu rows (RFC §M9). Names a
* built-in coordinated preset (`'cascade-slide' | 'cascade-fade' |
* 'cascade-scale'`). When set, the rows stagger in/out and the panel holds
* open until the exit cascade settles. Omitted ⇒ no cascade (default chrome).
*/
animation?: CoordinatedPresetName;
children?: Snippet;
};

@ -1,31 +0,0 @@
<script lang="ts">
import { writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { RailItemProvider } from '../rail-provider.svelte';
import type { RailItemProps } from '../types';
let { ref = $bindable(null), children, ...restProps }: RailItemProps = $props();
// CHILD surface — same shape as Reveal's item: `data-rail-item` marker, a child
// Presence (via the shared Coordination), and the auto `--motion-stagger-index`.
const state = RailItemProvider.create({
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, { 'data-rail-item': '' }));
</script>
{#if state.isPresent}
<div
bind:this={ref}
{...mergedProps}
{...state.transitionAttrs}
data-animation-style={state.animationStyle}
style:--motion-stagger-index={state.staggerIndex}
>
{@render children?.()}
</div>
{/if}

@ -1,57 +0,0 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RailProvider } from '../rail-provider.svelte';
import type { RailProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'rail'),
open = $bindable(false),
onOpenChange = () => {},
animation,
children,
child,
...restProps
}: RailProps = $props();
// Root AND owner surface: the rail element coordinates its Item children. The ref
// is attached by the runtime part (in `state.props`); `state.isPresent` /
// `transitionAttrs` come from the owner Presence.
const state = RailProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
open: writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
),
animation: readableActive(() => animation)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent}
{#if child}
{@render child({
props: {
...mergedProps,
...state.transitionAttrs,
'data-animation-style': state.animationStyle
}
})}
{:else}
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
{@render children?.()}
</div>
{/if}
{/if}

@ -1,4 +0,0 @@
export { default as Provider } from './components/rail.svelte';
export { default as Item } from './components/rail-item.svelte';
export type { RailProps as ProviderProps, RailItemProps as ItemProps } from './types';

@ -1 +0,0 @@
export * from './exports';

@ -1,96 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import { compileMorfo, type Morfo } from '$uix/morfo';
import { validateMorfo } from '$uix/morfo/schema';
import { railMorfo } from '$uix/morfo/components/rail';
import { createEngineMotion } from '$motion';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { PresenceGroup } from '$soma/layers/presence-group';
import { RailItemProvider, RailProvider } from './rail-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const motion = createEngineMotion();
const soma = {
dom,
motion,
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(RailProvider.ctx, 'set').mockImplementation((value) => value as never);
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
return { dom };
}
describe('Rail — second consumer of the coordination helper (RFC: MOTION_SERVICE_RFC.md §7)', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('the morfo is valid: the root Provider is the owner surface, Item is a child', () => {
expect(() => validateMorfo(railMorfo)).not.toThrow();
const compiled = compileMorfo(railMorfo);
// Provider (root) declares `children` → it is the owner; `together` relation.
expect(compiled.parts.byKebab.get('provider')?.animation?.children).toEqual({
enter: 'together',
exit: 'together'
});
expect(compiled.parts.byKebab.get('item')?.animation).toEqual({
surface: true,
children: undefined
});
});
it('the root registers as owner; items as children; stagger + routing via the shared helper', () => {
installSomaHarness();
const railEl = document.createElement('div');
const itemEls = [
document.createElement('div'),
document.createElement('div'),
document.createElement('div')
];
const { result, cleanup } = withEffectRoot(() => {
const provider = RailProvider.create({
id: state('rail'),
ref: state<HTMLElement | null>(railEl),
open: state(true),
animation: state<string | undefined>('cascade-slide')
});
vi.spyOn(RailProvider, 'require').mockReturnValue(provider);
const items = itemEls.map((el) =>
RailItemProvider.create({ ref: state<HTMLElement | null>(el) })
);
return { provider, items };
});
// The root IS the owner → owner + 3 children = 4 members in ONE group.
expect(result.provider.coord.group.size).toBe(4);
// Items auto-derive their stagger (owner excluded from the child index).
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
// Routing reaches both declared surfaces.
expect(result.provider.coord.routeAnimation('provider')).toBe('cascade-slide');
expect(result.provider.coord.routeAnimation('item')).toBe('cascade-slide');
cleanup();
});
});

@ -1,121 +0,0 @@
import { context } from '../../provider';
import { type Active, type State, type StateProps } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
import { railMorfo } from '../../../morfo/components/rail';
// ── Provider (root + owner surface) ─────────────────────────────────────────
interface RailOpts extends StateProps<{ open: boolean }> {
id: Active<string>;
ref: State<HTMLElement | null>;
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
animation: Active<string | undefined>;
}
/**
* Rail root — unlike Reveal's virtual root, the Provider IS the owner surface: it
* holds the runtime, the open state, the `Coordination`, AND registers itself as the
* group's `owner` (the rail element coordinates its Item children). Parent-controlled
* via `open`; no trigger, no events.
*/
export class RailProvider {
static readonly ctx = context<RailProvider>('Rail');
static get(): RailProvider | undefined {
return this.ctx.getOr(undefined) as RailProvider | undefined;
}
static require(): RailProvider {
return this.ctx.get();
}
static create(opts: RailOpts) {
return new RailProvider(opts);
}
readonly opts: RailOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
readonly coord: Coordination;
/** OWNER member — the rail element; its presence triggers the coordinated enter/exit. */
readonly surface: CoordinatedSurface;
private constructor(opts: RailOpts) {
this.opts = opts;
RailProvider.ctx.set(this);
this.soma = Soma.require();
this.runtime = this.soma.runtime(railMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: RailProvider.ctx,
syncAttrs: true
});
this.coord = new Coordination({
morfo: railMorfo,
dom: this.soma.dom,
motion: this.soma.motion,
open: opts.open,
animation: opts.animation
});
this.surface = this.coord.surface('provider', opts.ref, 'owner');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Item (child surface) ──────────────────────────────────────────────────────
interface RailItemOpts {
ref: State<HTMLElement | null>;
}
export class RailItemProvider {
static create(opts: RailItemOpts) {
return new RailItemProvider(opts);
}
readonly opts: RailItemOpts;
readonly provider: RailProvider;
/** CHILD member — mounts and waits for the group to release it (cascade). */
readonly surface: CoordinatedSurface;
private constructor(opts: RailItemOpts) {
this.opts = opts;
this.provider = RailProvider.require();
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
get staggerIndex(): number {
return this.surface.staggerIndex;
}
}

@ -1,24 +0,0 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
import type { CoordinatedPresetName } from '$motion';
export type RailProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Whether the rail is open. Bindable. @default false */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Coordinated-motion preset, routed to the declared surfaces (RFC §5). */
animation?: CoordinatedPresetName;
}> &
Without<PrimitiveDivAttributes, { open: boolean }>;
export type RailItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
/** Element reference. Bindable — the child Presence animates this node. */
ref?: HTMLElement | null;
/** Inline style (e.g. `--motion-stagger-index`, set automatically by the item). */
style?: string | Record<string, unknown> | null;
children?: Snippet;
};

@ -1,32 +0,0 @@
<script lang="ts">
import { writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { RevealItemProvider } from '../reveal-provider.svelte';
import type { RevealItemProps } from '../types';
let { ref = $bindable(null), children, ...restProps }: RevealItemProps = $props();
// CHILD surface. No runtime part — it is a coordinated leaf; `data-reveal-item`
// (the morfo's part marker) + the child Presence are all it needs. `bind:this`
// feeds the ref the Presence animates; mergeProps normalises `style` for the div.
const state = RevealItemProvider.create({
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, { 'data-reveal-item': '' }));
</script>
{#if state.isPresent}
<div
bind:this={ref}
{...mergedProps}
{...state.transitionAttrs}
data-animation-style={state.animationStyle}
style:--motion-stagger-index={state.staggerIndex}
>
{@render children?.()}
</div>
{/if}

@ -1,35 +0,0 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RevealPanelProvider } from '../reveal-provider.svelte';
import type { RevealPanelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'reveal-panel'),
children,
...restProps
}: RevealPanelProps = $props();
// OWNER surface. The ref is attached by the runtime part (in `state.props`);
// `state.isPresent` / `state.transitionAttrs` come from the owner Presence, so
// the panel mounts on open and RETAINS its DOM through the children's exit.
const state = RevealPanelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent}
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
{@render children?.()}
</div>
{/if}

@ -1,33 +0,0 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RevealTriggerProvider } from '../reveal-provider.svelte';
import type { RevealTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'reveal-trigger'),
children,
child,
...restProps
}: RevealTriggerProps = $props();
const state = RevealTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -1,31 +0,0 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { createId } from '$active-uix/id';
import { RevealProvider } from '../reveal-provider.svelte';
import type { RevealProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'reveal'),
open = $bindable(false),
onOpenChange = () => {},
animation,
children
}: RevealProps = $props();
// Virtual root — owns the runtime + the coordination group, renders no element.
RevealProvider.create({
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
),
animation: readableActive(() => animation)
});
</script>
{@render children?.()}

@ -1,11 +0,0 @@
export { default as Provider } from './components/reveal.svelte';
export { default as Trigger } from './components/reveal-trigger.svelte';
export { default as Panel } from './components/reveal-panel.svelte';
export { default as Item } from './components/reveal-item.svelte';
export type {
RevealProps as ProviderProps,
RevealTriggerProps as TriggerProps,
RevealPanelProps as PanelProps,
RevealItemProps as ItemProps
} from './types';

@ -1 +0,0 @@
export * from './exports';

@ -1,178 +0,0 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import { compileMorfo, type Morfo } from '$uix/morfo';
import { validateMorfo } from '$uix/morfo/schema';
import { revealMorfo } from '$uix/morfo/components/reveal';
import { createEngineMotion } from '$motion';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { PresenceGroup } from '$soma/layers/presence-group';
import { RevealItemProvider, RevealPanelProvider, RevealProvider } from './reveal-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
async function flushRuntimeTrigger() {
await Promise.resolve();
await tick();
}
function installSomaHarness() {
const dom = createActiveDom();
const motion = createEngineMotion();
const soma = {
dom,
motion,
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
// No component context in a bare effect root — stub the context publication
// (both the provider's own ctx and the group's, set by PresenceGroup.create).
vi.spyOn(RevealProvider.ctx, 'set').mockImplementation((value) => value as never);
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
return { dom };
}
describe('Reveal — contract → coordination wiring (RFC: MOTION_SERVICE_RFC.md §4)', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('the morfo is valid and the Panel declares the bracket coordination', () => {
expect(() => validateMorfo(revealMorfo)).not.toThrow();
const panel = compileMorfo(revealMorfo).parts.byKebab.get('panel');
expect(panel?.animation?.surface).toBe(true);
// `{ enter: 'before', exit: 'after' }` — what the provider feeds the group.
expect(panel?.animation?.children).toEqual({ enter: 'before', exit: 'after' });
// The Item is a coordinated child surface with no relation of its own.
const item = compileMorfo(revealMorfo).parts.byKebab.get('item');
expect(item?.animation).toEqual({ surface: true, children: undefined });
});
it('the provider builds a PresenceGroup that the panel (owner) + items (children) register into', () => {
installSomaHarness();
const panelEl = document.createElement('div');
const itemEls = [document.createElement('div'), document.createElement('div')];
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open: state(false),
animation: state<string | undefined>(undefined)
});
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
const panel = RevealPanelProvider.create({
id: state('reveal-panel'),
ref: state<HTMLElement | null>(panelEl)
});
const items = itemEls.map((el) =>
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
);
return { provider, panel, items };
});
expect(result.provider.group).toBeInstanceOf(PresenceGroup);
// owner + 2 children all registered as group members.
expect(result.provider.group.size).toBe(3);
cleanup();
});
it('routes the `animation` DX prop ONLY to the declared surfaces (RFC §5)', () => {
installSomaHarness();
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open: state(false),
animation: state<string | undefined>('slide')
});
return { provider };
});
// Panel + Item are `surface: true` in the morfo → routed; Trigger is not.
// Routing now lives on the shared `Coordination` helper.
expect(result.provider.coord.routeAnimation('panel')).toBe('slide');
expect(result.provider.coord.routeAnimation('item')).toBe('slide');
expect(result.provider.coord.routeAnimation('trigger')).toBeUndefined();
cleanup();
});
it('items auto-derive their stagger index from the group order (M6)', () => {
installSomaHarness();
const panelEl = document.createElement('div');
const itemEls = [
document.createElement('div'),
document.createElement('div'),
document.createElement('div')
];
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open: state(false),
animation: state<string | undefined>(undefined)
});
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
RevealPanelProvider.create({
id: state('reveal-panel'),
ref: state<HTMLElement | null>(panelEl)
});
const items = itemEls.map((el) =>
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
);
return { items };
});
// The dev numbers nothing — the cascade offsets itself from the registration order.
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
cleanup();
});
it('toggle flips the open state through the open/close events', async () => {
installSomaHarness();
const open = state(false);
const { result, cleanup } = withEffectRoot(() => {
const provider = RevealProvider.create({
id: state('reveal'),
open,
animation: state<string | undefined>(undefined)
});
return { provider };
});
result.provider.toggle();
await flushRuntimeTrigger();
expect(open.current).toBe(true);
result.provider.toggle();
await flushRuntimeTrigger();
expect(open.current).toBe(false);
cleanup();
});
});

@ -1,220 +0,0 @@
import { context } from '../../provider';
import { state, type Active, type State, type StateProps } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { PresenceGroup } from '../../layers/presence-group';
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
import { revealMorfo } from '../../../morfo/components/reveal';
// ── Provider (virtual root) ────────────────────────────────────────────────
interface RevealOpts extends StateProps<{ open: boolean }> {
id: Active<string>;
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
animation: Active<string | undefined>;
}
/**
* Reveal root — owns the morfo runtime, the open state, and the `Coordination`
* (the shared coordinated-motion wiring: PresenceGroup + animation routing + auto
* stagger, all derived from the morfo's `animation` contract). The Panel registers
* as the `owner` surface, each Item as a `child` — see `soma/layers/coordination.ts`.
*/
export class RevealProvider {
static readonly ctx = context<RevealProvider>('Reveal');
static get(): RevealProvider | undefined {
return this.ctx.getOr(undefined) as RevealProvider | undefined;
}
static require(): RevealProvider {
return this.ctx.get();
}
static create(opts: RevealOpts) {
return new RevealProvider(opts);
}
readonly opts: RevealOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
/** Shared coordination wiring, built from the morfo contract (RFC §7 / M5 / M6). */
readonly coord: Coordination;
// Cross-part id sources read by partRef('trigger') / partRef('panel') for the
// Trigger's aria-controls and the Panel's aria-labelledby.
triggerId = state('');
panelId = state('');
private constructor(opts: RevealOpts) {
this.opts = opts;
RevealProvider.ctx.set(this);
this.soma = Soma.require();
this.runtime = this.soma.runtime(revealMorfo, {
states: {
open: () => opts.open.current
},
parts: {
trigger: () => this.triggerId.current,
panel: () => this.panelId.current
},
events: {
// State is set in the HANDLER, so both are `sequence: 'post'` in the
// morfo (the perceptual hold must not block the functional change).
open: () => {
this.opts.open.current = true;
},
close: () => {
this.opts.open.current = false;
}
}
});
this.coord = new Coordination({
morfo: revealMorfo,
dom: this.soma.dom,
motion: this.soma.motion,
open: opts.open,
animation: opts.animation
});
}
/** The coordination group (Panel owner + Item children). */
get group(): PresenceGroup {
return this.coord.group;
}
toggle() {
void this.runtime.trigger(this.opts.open.current ? 'close' : 'open');
}
}
// ── Trigger ─────────────────────────────────────────────────────────────────
interface RevealTriggerOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class RevealTriggerProvider {
static create(opts: RevealTriggerOpts) {
return new RevealTriggerProvider(opts);
}
readonly opts: RevealTriggerOpts;
readonly provider: RevealProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: RevealTriggerOpts) {
this.opts = opts;
this.provider = RevealProvider.require();
this.provider.triggerId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly onclick = () => {
this.provider.toggle();
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
onclick: this.onclick
}));
}
// ── Panel (owner surface) ─────────────────────────────────────────────────────
interface RevealPanelOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class RevealPanelProvider {
static create(opts: RevealPanelOpts) {
return new RevealPanelProvider(opts);
}
readonly opts: RevealPanelOpts;
readonly provider: RevealProvider;
readonly runtimePart: SomaRuntimePart;
/** OWNER member of the group — its presence triggers the coordinated enter/exit. */
readonly surface: CoordinatedSurface;
private constructor(opts: RevealPanelOpts) {
this.opts = opts;
this.provider = RevealProvider.require();
this.provider.panelId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('panel', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
this.surface = this.provider.coord.surface('panel', opts.ref, 'owner');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
/** The routed `data-animation-style` for this surface (RFC §5). */
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Item (child surface) ──────────────────────────────────────────────────────
interface RevealItemOpts {
ref: State<HTMLElement | null>;
}
export class RevealItemProvider {
static create(opts: RevealItemOpts) {
return new RevealItemProvider(opts);
}
readonly opts: RevealItemOpts;
readonly provider: RevealProvider;
/** CHILD member — mounts and waits for the group to release it (cascade). */
readonly surface: CoordinatedSurface;
private constructor(opts: RevealItemOpts) {
this.opts = opts;
this.provider = RevealProvider.require();
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
}
get isPresent(): boolean {
return this.surface.isPresent;
}
get transitionAttrs() {
return this.surface.transitionAttrs;
}
/** The routed `data-animation-style` for this surface (RFC §5). */
get animationStyle(): string | undefined {
return this.surface.animationStyle;
}
/** The auto `--motion-stagger-index`, derived from the group order (M6). */
get staggerIndex(): number {
return this.surface.staggerIndex;
}
}

@ -1,40 +0,0 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type { CoordinatedPresetName } from '$motion';
export type RevealProps = {
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Whether the panel is open. Bindable. @default false */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/**
* Motion preset name, routed to the declared animable surfaces (Panel + Items)
* as `data-animation-style` — name it once here, the system applies it where the
* morfo says (RFC §5). `undefined` = no preset.
*/
animation?: CoordinatedPresetName;
children?: Snippet;
};
export type RevealTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, Record<never, never>>;
export type RevealPanelProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, Record<never, never>>;
export type RevealItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
/** Element reference. Bindable — the child Presence animates this node. */
ref?: HTMLElement | null;
/** Inline style (e.g. `--i` for the CSS stagger index). */
style?: string | Record<string, unknown> | null;
children?: Snippet;
};

@ -1,137 +0,0 @@
/**
* Coordination — the shared soma wiring for a COORDINATED-motion component
* (RFC: eidos/MOTION_SERVICE_RFC.md §7 + the M5/M6 increments). It reads a
* component's morfo `animation` contract and does, ONCE, what every coordinated
* component needs:
*
* - derives the parent↔children `when` (from the owner part's `animation.children`)
* and the set of `surface: true` parts — the CONTRACT drives the coordination;
* - creates the `PresenceGroup`;
* - routes the DX `animation` prop to the declared surfaces (`routeAnimation`);
* - mints per-surface members (`CoordinatedSurface`) — an owner/child `Presence`
* that exposes the routed `data-animation-style` and the auto stagger index.
*
* A coordinated component's ROOT provider creates ONE `Coordination`; each animable
* part provider creates a `CoordinatedSurface` via `coord.surface(...)`. This is
* what makes a second coordinated component a handful of lines instead of the full
* hand-wiring `Reveal` first did — both `Reveal` and `Rail` consume it.
*
* The opt-OUT doctrine (silence the generic sema signature, react to
* `data-starting/ending-style` not `data-state`) lives in the morfo (`channels: []`,
* `expression: 'none'`) + the component's CSS — NOT here. This helper only owns the
* lifecycle coordination.
*/
import { compileMorfo, type Morfo } from '$uix/morfo';
import { readableActive, type Active } from '$libs/reactive';
import type { ActiveDom } from '$adom';
import type { EngineMotion } from '$motion';
import { Presence } from './presence.svelte';
import { PresenceGroup, type PresenceRole, type PresenceWhen } from './presence-group';
interface CoordinationStructure {
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
readonly surfaces: ReadonlySet<string>;
}
// Cached by morfo identity (compileMorfo is itself cached) — the structure is a
// pure function of the contract.
const STRUCTURE_CACHE = new WeakMap<Morfo, CoordinationStructure>();
function structureOf(morfo: Morfo): CoordinationStructure {
const cached = STRUCTURE_CACHE.get(morfo);
if (cached) return cached;
let when: { enter: PresenceWhen; exit: PresenceWhen } = { enter: 'together', exit: 'together' };
const surfaces = new Set<string>();
for (const [kebab, part] of compileMorfo(morfo).parts.byKebab) {
if (part.animation?.surface) surfaces.add(kebab);
// The owner is the part that declares `children` — its `when` is the tree's.
if (part.animation?.children) when = part.animation.children;
}
const structure: CoordinationStructure = { when, surfaces };
STRUCTURE_CACHE.set(morfo, structure);
return structure;
}
export interface CoordinationOptions {
readonly morfo: Morfo;
readonly dom: ActiveDom;
readonly motion: EngineMotion;
/** Shared open state — every surface (owner + children) observes the same flag. */
readonly open: Active<boolean>;
/** DX preset name routed to the declared surfaces. Omit for none. */
readonly animation?: Active<string | undefined>;
}
export class Coordination {
readonly group: PresenceGroup;
private readonly surfaces: ReadonlySet<string>;
private readonly opts: CoordinationOptions;
private readonly animation: Active<string | undefined>;
constructor(opts: CoordinationOptions) {
this.opts = opts;
const structure = structureOf(opts.morfo);
this.surfaces = structure.surfaces;
this.animation = opts.animation ?? readableActive((): string | undefined => undefined);
this.group = PresenceGroup.create({ when: structure.when, dom: opts.dom });
}
/**
* Route the DX `animation` to a part ONLY if the morfo declares it a surface
* (RFC §5). A non-surface part gets `undefined`, so it can never be mis-targeted.
*/
routeAnimation(kebab: string): string | undefined {
return this.surfaces.has(kebab) ? this.animation.current : undefined;
}
/** Mint a coordinated member (an animable surface) for a part. */
surface(kebab: string, ref: Active<HTMLElement | null>, role: PresenceRole): CoordinatedSurface {
const presence = new Presence({
dom: this.opts.dom,
motion: this.opts.motion,
open: this.opts.open,
ref,
group: this.group,
groupRole: role
});
return new CoordinatedSurface(this, presence, kebab);
}
}
/**
* A single animable surface of a `Coordination`: its registered `Presence`, the
* routed `data-animation-style`, and the auto `--motion-stagger-index`. The part
* provider exposes these to its svelte wrapper.
*/
export class CoordinatedSurface {
constructor(
private readonly coord: Coordination,
readonly presence: Presence,
private readonly kebab: string
) {}
get isPresent(): boolean {
return this.presence.isPresent;
}
get transitionAttrs() {
return this.presence.transitionAttrs;
}
/** The routed `data-animation-style` for this surface (RFC §5). */
get animationStyle(): string | undefined {
return this.coord.routeAnimation(this.kebab);
}
/**
* The canonical `--motion-stagger-index`, derived from this surface's position in
* the group's registration order (M6) — the cascade offsets itself; the dev never
* numbers the items. Owner surfaces are not children, so this is 0 for them.
*/
get staggerIndex(): number {
return this.coord.group.childIndex(this.presence);
}
}

@ -1,218 +0,0 @@
import type { Active } from '$libs/reactive';
import type { ActiveDom } from '$adom';
/**
* DomCascade — the SECOND coordination mode of the motion service (RFC §M9): a
* staggered cascade over an owner's STATIC DOM children, as opposed to the
* `PresenceGroup` (which coordinates registered child `Presence` surfaces —
* Reveal/Rail).
*
* Why it exists: a real menu/list has ONE `Presence` (the content/overlay that
* mounts as a unit) and its items are plain DOM the runtime owns no wrapper for —
* discovered by selector, arbitrary consumer markup. There is nothing to register
* in a group, and no Svelte binding site to set `--motion-stagger-index` on.
*
* The trick (so eidos's coordinated preset is reused UNCHANGED): the owner
* PROPAGATES its own lifecycle onto each DOM child. While the owner is
* `data-starting/ending-style`, the cascade writes the SAME attr on every item
* plus the routed `data-animation-style` and a per-DOM-order `--motion-stagger-
* index` (+ `--motion-stagger-count` for the reversible exit). The owner
* `Presence` already owns the TIMING (starting → next-frame → transition); this
* just mirrors it. Items end up with exactly the attrs a child `Presence` would
* have produced, so `[data-animation-style='X'][data-starting-style]` matches.
*
* The reactive mirror lives in the `$effect`; the imperative DOM writes live in
* `sync` / `clear` (pure over an injected `ActiveDom`, so they're unit-testable
* with a mock — no component/DOM needed).
*/
const ANIMATION_STYLE = 'data-animation-style';
const STAGGER_INDEX = '--motion-stagger-index';
const STAGGER_COUNT = '--motion-stagger-count';
/** The lifecycle attrs the owner produces and the cascade mirrors onto items. */
const LIFECYCLE_ATTRS = ['data-starting-style', 'data-ending-style'] as const;
export interface DomCascadeOptions {
readonly dom: ActiveDom;
/** The routed coordinated preset name (the owner's `data-animation-style`). `undefined` ⇒ no cascade. */
readonly animationStyle: Active<string | undefined>;
/**
* The owner surface's live transition attrs — `{ 'data-starting-style': '' }`,
* `{ 'data-ending-style': '' }`, or `{}` once settled. This is exactly
* `Presence.transitionAttrs`; the cascade mirrors it onto the DOM children.
*/
readonly ownerTransitionAttrs: Active<Readonly<Record<string, string | undefined>>>;
/** Resolve the current DOM children to cascade, in document order. */
readonly items: () => readonly HTMLElement[];
}
export class DomCascade {
private readonly opts: DomCascadeOptions;
/** Items currently carrying the cascade attrs — cleared before each re-sync. */
private applied: readonly HTMLElement[] = [];
constructor(opts: DomCascadeOptions) {
this.opts = opts;
}
/**
* Start mirroring the owner's lifecycle onto the DOM children. Call from the
* owner provider's init (inside a component): it runs a reactive `$effect` that
* re-syncs whenever the routed style or the owner's transition attrs change; the
* cleanup strips the previous items so a changed set (or unmount) leaves no stale
* attrs. Kept OUT of the constructor so `sync`/`clear` stay unit-testable without
* a component (a server test drives them directly with a mock `dom`).
*/
watch(): void {
let hadItems = false;
// Logic effect: enter (beginEnter) / exit (mirror) / close (clear). It has NO
// `$effect` cleanup — clearing the items' attrs on every re-run is what cut the
// enter mid-transition (the owner's starting→settled re-run fires WHILE the enter
// transition is playing). beginEnter is synchronous, so by the time that re-run
// lands there is no phase to handle and the `else` branch leaves the items alone.
$effect(() => {
const items = this.opts.items();
const style = this.opts.animationStyle.current;
const ownerAttrs = this.opts.ownerTransitionAttrs.current;
const ending = ownerAttrs['data-ending-style'] !== undefined;
const appeared = items.length > 0 && !hadItems;
hadItems = items.length > 0;
if (items.length === 0) {
// Owner closed / unmounted.
this.clear();
} else if (ending) {
// EXIT: ease the items on→off (reversed stagger). They are present and the
// preset transition is active, so `sync` interpolates — correct for exit.
// The owner awaits them via `pending` before unmounting (RFC §9).
this.sync(items, style, ownerAttrs);
} else if (style && appeared) {
// ENTER: the items just mounted in the on-state. `beginEnter` jumps them to
// the off-state with the transition SUPPRESSED, then releases → they ease in.
this.beginEnter(items, style);
}
// else: settled / no phase change. Leave the attrs as beginEnter left them — a
// re-sync would clear+rewrite and cut the in-flight enter transition (this
// effect re-runs when the owner goes starting→settled). A mid-open item-set
// change is not re-synced — acceptable for F2.
});
// Teardown on dispose only (no reactive reads ⇒ never re-runs).
$effect(() => () => this.clear());
}
/**
* Drive a just-mounted item set into the cascade. The items mount in the ON-state, so
* we cannot just stamp the off-state: with the preset's `transition` already active
* the change would EASE toward off (and, released a frame later, barely move — the
* dropdown-menu enter bug; verified in-browser, opacity crept 1 → 0.95 → 1). Instead
* stamp the off-state with the element transition SUPPRESSED so opacity/transform JUMP
* to it, force a reflow to commit that, then restore the transition and drop
* `data-starting-style` → the items ease IN with the preset's per-row stagger. (The
* `Presence` model needs none of this: its `data-starting-style` is in the initial
* render, i.e. the mount state, which never transitions.) Synchronous — the owner's
* later starting→settled re-run finds nothing to do.
*/
private beginEnter(items: readonly HTMLElement[], style: string): void {
const count = String(items.length);
items.forEach((item, i) => {
this.opts.dom.apply({
target: item,
attrs: { [ANIMATION_STYLE]: style, 'data-starting-style': '' }
});
this.opts.dom.writeProperty(item, STAGGER_INDEX, String(i));
this.opts.dom.writeProperty(item, STAGGER_COUNT, count);
this.opts.dom.writeProperty(item, 'transition', 'none');
});
this.applied = items;
this.forceReflow(items[0]);
for (const item of items) {
this.opts.dom.removeProperty(item, 'transition');
this.opts.dom.remove(item, ['data-starting-style']);
}
}
/** Read a layout property to flush the transition-suppressed off-state before release. */
private forceReflow(el: HTMLElement | undefined): void {
if (el) void this.opts.dom.getWindow(el).getComputedStyle(el).opacity;
}
/**
* Write the cascade attrs onto each DOM child: the routed `data-animation-style`,
* the mirrored owner lifecycle attrs, and the per-order stagger index + count.
* Idempotent — clears the prior set first. Pure over `opts.dom`.
*/
sync(
items: readonly HTMLElement[],
style: string | undefined,
ownerAttrs: Readonly<Record<string, string | undefined>>
): void {
this.clear();
if (!style || items.length === 0) return;
const count = String(items.length);
items.forEach((item, i) => {
this.opts.dom.apply({ target: item, attrs: { [ANIMATION_STYLE]: style, ...ownerAttrs } });
this.opts.dom.writeProperty(item, STAGGER_INDEX, String(i));
this.opts.dom.writeProperty(item, STAGGER_COUNT, count);
});
this.applied = items;
}
/** Strip every cascade attr/property the last `sync` wrote. */
clear(): void {
for (const item of this.applied) {
this.opts.dom.remove(item, [ANIMATION_STYLE, ...LIFECYCLE_ATTRS]);
this.opts.dom.removeProperty(item, STAGGER_INDEX);
this.opts.dom.removeProperty(item, STAGGER_COUNT);
}
this.applied = [];
}
/**
* Aggregate the in-flight transitions of every cascaded item into ONE promise —
* the cabo of RFC §9 (children-DOM mode, §M9). The owner's `getAnimations()` does
* NOT see these: the items are DOM descendants, not the owner node, and the engine
* stays hierarchy-agnostic (no `{subtree:true}`). The owner `Presence` awaits this
* via `PresenceOptions.pending`, so on exit it holds the subtree until the whole
* cascade settles instead of dropping it mid-stagger.
*
* Deferred a frame so the mirrored `data-(starting|ending)-style` has triggered the
* items' CSS transitions before we read them (the same reason
* `Presence.waitForAnimations` waits a frame). Reads the items stamped by the last
* `sync` (stable during exit — the set does not change and the owner cannot unmount
* until this resolves). Resolves immediately when nothing is animating, and resolves
* (never rejects) if an item animation is cancelled — a cancelled exit must still
* finalize the owner's lifecycle, never hang it.
*/
pending(): Promise<void> {
const items = this.applied;
return new Promise<void>((resolve) => {
// TWO frames, not one: the exit transitions are written a microtask after
// close (the owner's ending → our mirror `sync`), but they only surface in
// getAnimations() a frame or two later — verified in-browser, the item's
// animation count is 0 at +1ms and 2 at +29ms. Reading after a single frame
// races ahead of them, so `getAnimations()` is empty and the owner unmounts
// before the staggered exit plays ("closes in one go"). A second frame lets
// every row's transition (including the long delay-phase ones) register.
const read = () => {
const finishers = items.flatMap((el) => el.getAnimations().map((a) => a.finished));
if (finishers.length === 0) {
resolve();
return;
}
// settle (resolve OR reject) every finisher — a single cancelled transition
// must not collapse the whole wait (Promise.all would reject early). We wait
// for the LAST row to finish or be cancelled.
Promise.all(
finishers.map((f) =>
f.then(
() => {},
() => {}
)
)
).then(() => resolve());
};
this.opts.dom.requestFrame(() => this.opts.dom.requestFrame(read, items[0]), items[0]);
});
}
}

@ -1,186 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readableActive } from '$libs/reactive';
import { DomCascade } from './dom-cascade.svelte';
import type { ActiveDom } from '$adom';
type ApplyCall = { target: HTMLElement; attrs?: Record<string, unknown> };
type PropCall = { target: HTMLElement; property: string; value: string };
// A mock that records the DOM-writing methods DomCascade.sync/clear use, and runs
// `requestFrame` synchronously so `pending()` is deterministic in a server test.
function mockDom() {
const applied: ApplyCall[] = [];
const removed: { target: HTMLElement; names: readonly string[] }[] = [];
const props: PropCall[] = [];
const removedProps: { target: HTMLElement; property: string }[] = [];
const dom = {
apply: (c: ApplyCall) => applied.push(c),
remove: (target: HTMLElement, names: readonly string[]) => removed.push({ target, names }),
writeProperty: (target: HTMLElement, property: string, value: string) =>
props.push({ target, property, value }),
removeProperty: (target: HTMLElement, property: string) =>
removedProps.push({ target, property }),
// Run the frame synchronously so `pending()` is deterministic in a server test.
requestFrame: (cb: () => void) => {
cb();
return 0;
},
// `beginEnter` reads a computed style to force a reflow between the suppressed
// off-state and its release; the mock just needs to be callable.
getWindow: () => ({ getComputedStyle: () => ({ opacity: '1' }) })
} as unknown as ActiveDom;
return { dom, applied, removed, props, removedProps };
}
const el = () => ({}) as HTMLElement;
/** A fake item whose `getAnimations()` returns animations with the given finishers. */
const animItem = (...finished: Promise<unknown>[]) =>
({ getAnimations: () => finished.map((f) => ({ finished: f })) }) as unknown as HTMLElement;
function make(dom: ActiveDom, items: HTMLElement[]) {
return new DomCascade({
dom,
animationStyle: readableActive(() => 'cascade-slide'),
ownerTransitionAttrs: readableActive(() => ({})),
items: () => items
});
}
describe('DomCascade — children-DOM stagger (RFC §M9)', () => {
it('mirrors the routed style + owner lifecycle + per-order index/count onto each item', () => {
const m = mockDom();
const items = [el(), el(), el()];
make(m.dom, items).sync(items, 'cascade-slide', { 'data-starting-style': '' });
expect(m.applied).toHaveLength(3);
expect(m.applied[0].attrs).toEqual({
'data-animation-style': 'cascade-slide',
'data-starting-style': ''
});
const indices = m.props
.filter((p) => p.property === '--motion-stagger-index')
.map((p) => p.value);
expect(indices).toEqual(['0', '1', '2']);
expect(
m.props.filter((p) => p.property === '--motion-stagger-count').every((p) => p.value === '3')
).toBe(true);
});
it('mirrors data-ending-style on exit', () => {
const m = mockDom();
const items = [el()];
make(m.dom, items).sync(items, 'cascade-slide', { 'data-ending-style': '' });
expect(m.applied[0].attrs).toEqual({
'data-animation-style': 'cascade-slide',
'data-ending-style': ''
});
});
it('no-ops when no preset is routed (style undefined)', () => {
const m = mockDom();
const items = [el(), el()];
make(m.dom, items).sync(items, undefined, { 'data-starting-style': '' });
expect(m.applied).toHaveLength(0);
expect(m.props).toHaveLength(0);
});
it('clear() strips every attr/property a prior sync wrote', () => {
const m = mockDom();
const items = [el(), el()];
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', {});
cascade.clear();
expect(m.removed).toHaveLength(2);
expect(m.removed[0].names).toContain('data-animation-style');
expect(m.removed[0].names).toContain('data-starting-style');
expect(m.removed[0].names).toContain('data-ending-style');
const removedProps = m.removedProps.map((p) => p.property);
expect(removedProps).toContain('--motion-stagger-index');
expect(removedProps).toContain('--motion-stagger-count');
});
it('re-sync clears the prior set before writing (idempotent, owner settles)', () => {
const m = mockDom();
const items = [el()];
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', { 'data-starting-style': '' });
cascade.sync(items, 'cascade-slide', {}); // owner settled → drop starting-style
expect(m.removed.length).toBeGreaterThan(0);
const last = m.applied[m.applied.length - 1];
expect(last.attrs).toEqual({ 'data-animation-style': 'cascade-slide' });
});
// ── beginEnter() — items mount in the on-state, must ease IN (F2) ────────────
it('beginEnter jumps to the off-state with transition suppressed, then releases', () => {
const m = mockDom();
const items = [el(), el()];
const cascade = make(m.dom, items) as unknown as {
beginEnter(i: readonly HTMLElement[], s: string): void;
};
cascade.beginEnter(items, 'cascade-slide');
// 1. off-state stamped on each item: data-animation-style + data-starting-style
expect(m.applied).toHaveLength(2);
expect(m.applied[0].attrs).toEqual({
'data-animation-style': 'cascade-slide',
'data-starting-style': ''
});
// 2. the element transition is suppressed inline so the off-state JUMPS (no ease)
expect(m.props.some((p) => p.property === 'transition' && p.value === 'none')).toBe(true);
// 3. release: transition restored + ONLY data-starting-style dropped (the routed
// style + stagger vars stay), so the items ease to the on-state.
expect(m.removedProps.some((p) => p.property === 'transition')).toBe(true);
expect(m.removed).toHaveLength(2);
expect(m.removed.every((r) => r.names.length === 1 && r.names[0] === 'data-starting-style')).toBe(
true
);
});
// ── pending() — the exit-heavy cabo (F1c, RFC §9 / §M9) ────────────────────
it('pending() resolves only after every cascaded item animation finishes', async () => {
const m = mockDom();
let doneA!: () => void;
let doneB!: () => void;
const a = new Promise<void>((r) => (doneA = r));
const b = new Promise<void>((r) => (doneB = r));
const items = [animItem(a), animItem(b)];
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', { 'data-ending-style': '' });
let settled = false;
const p = cascade.pending().then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false); // both items still animating
doneA();
await Promise.resolve();
expect(settled).toBe(false); // one still animating
doneB();
await p;
expect(settled).toBe(true);
});
it('pending() resolves immediately when nothing is animating', async () => {
const m = mockDom();
const items = [animItem(), animItem()]; // getAnimations() → []
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', {});
let settled = false;
await cascade.pending().then(() => (settled = true)); // resolves — must not hang
expect(settled).toBe(true);
});
it('pending() resolves (never rejects) when an item animation is cancelled', async () => {
const m = mockDom();
const items = [animItem(Promise.reject(new Error('cancelled')))];
const cascade = make(m.dom, items);
cascade.sync(items, 'cascade-slide', { 'data-ending-style': '' });
// A cancelled exit must still finalize the owner's lifecycle, never hang it.
let settled = false;
await cascade.pending().then(() => (settled = true));
expect(settled).toBe(true);
});
});

@ -42,14 +42,6 @@ export interface FloatingShellRootOpts {
* is a no-op.
*/
onOpenChangeComplete?: Active<OnChangeFn<boolean> | undefined>;
/**
* Optional per-phase pending hook forwarded to the content `Presence`
* (RFC §M9). A consumer that staggers STATIC DOM children — e.g.
* dropdown-menu's item cascade — passes `() => domCascade.pending()` so the
* owner holds the subtree until the exit cascade settles. Omitted by the
* other floating consumers ⇒ the Presence stays an island, byte-identical.
*/
pending?: (phase: 'enter' | 'exit') => Promise<void> | undefined;
}
export interface FloatingShellRoot {
@ -71,8 +63,7 @@ export function createFloatingShellRoot(opts: FloatingShellRootOpts): FloatingSh
ref: opts.contentRef,
onComplete: opts.onOpenChangeComplete
? (open) => opts.onOpenChangeComplete!.current?.(open)
: undefined,
pending: opts.pending
: undefined
});
return { floatingProvider, contentPresence };

@ -1,16 +1,5 @@
// ── Behavior layers (classes — consumed by Providers) ────────────────────────
export { Presence, type PresenceOptions, type TransitionStatus } from './presence.svelte';
export {
PresenceGroup,
type PresenceGroupOptions,
type PresenceFrameScheduler,
type PresenceMember,
type PresenceRole,
type PresenceWhen,
type PresencePhase
} from './presence-group';
export { Coordination, CoordinatedSurface, type CoordinationOptions } from './coordination';
export { DomCascade, type DomCascadeOptions } from './dom-cascade.svelte';
export { FocusScope } from './focus-scope.svelte';
export { Dismissal, type DismissalOpts, type DismissalBehavior } from './dismissal.svelte';
export { TextSelection, type TextSelectionOpts } from './text-selection.svelte';

@ -1,326 +0,0 @@
import { describe, expect, it } from 'vitest';
import { PresenceGroup, type PresenceMember, type PresencePhase } from './presence-group';
// Frame scheduler stub — runs the callback synchronously so the registration
// window collapses to "now" in tests. The coordination core (playEnter) does
// not touch the dom; this only feeds `requestEnter`.
const syncDom = { requestFrame: (cb: () => void) => (cb(), 0) };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
/** Records release/cancel calls; an optional gate controls when `release` resolves. */
function member(
role: 'owner' | 'child',
id: string,
calls: string[],
gate?: { promise: Promise<void> }
): PresenceMember {
return {
role,
release(phase: PresencePhase) {
calls.push(`${id}:${phase}`);
return gate ? gate.promise : Promise.resolve();
},
unmount() {
calls.push(`${id}:unmount`);
},
cancel() {
calls.push(`${id}:cancel`);
}
};
}
// Frame scheduler that QUEUES callbacks (unlike syncDom which runs them inline),
// so a test can interleave requestEnter/requestExit BEFORE the scheduled play runs
// — the timing the interruption guard (RFC §8.3) actually defends against.
function manualDom() {
const cbs: Array<() => void> = [];
return {
dom: {
requestFrame: (cb: () => void) => {
cbs.push(cb);
return cbs.length;
}
},
flush() {
for (const cb of cbs.splice(0)) cb();
}
};
}
describe('PresenceGroup — enter coordination (RFC: MOTION_SERVICE_RFC.md §7)', () => {
it('together: releases every member in parallel', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls));
group.register(member('child', 'b', calls));
await group.playEnter();
expect([...calls].sort()).toEqual(['a:enter', 'b:enter', 'owner:enter']);
});
it('before: owner releases, children only after the owner finishes', async () => {
const calls: string[] = [];
const ownerGate = deferred();
const group = new PresenceGroup({ when: { enter: 'before', exit: 'together' }, dom: syncDom });
group.register(member('owner', 'owner', calls, ownerGate));
group.register(member('child', 'a', calls));
const done = group.playEnter();
await Promise.resolve();
// Owner released; the child must wait for the owner's finished.
expect(calls).toEqual(['owner:enter']);
ownerGate.resolve();
await done;
expect(calls).toEqual(['owner:enter', 'a:enter']);
});
it('after: children release, owner only after they finish', async () => {
const calls: string[] = [];
const childGate = deferred();
const group = new PresenceGroup({ when: { enter: 'after', exit: 'together' }, dom: syncDom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls, childGate));
const done = group.playEnter();
await Promise.resolve();
// Child released; the owner must wait for the children's finished.
expect(calls).toEqual(['a:enter']);
childGate.resolve();
await done;
expect(calls).toEqual(['a:enter', 'owner:enter']);
});
it('playEnter resolves only after every member finishes (aggregation §9)', async () => {
const calls: string[] = [];
const gate = deferred();
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls, gate));
group.register(member('child', 'a', calls, gate));
let settled = false;
const done = group.playEnter().then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
gate.resolve();
await done;
expect(settled).toBe(true);
});
it('register returns a deregister that removes the member', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
const off = group.register(member('child', 'a', calls));
expect(group.size).toBe(1);
off();
expect(group.size).toBe(0);
await group.playEnter();
expect(calls).toEqual([]);
});
it('childIndex returns each child position in registration order (M6)', () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
const owner = member('owner', 'owner', calls);
const a = member('child', 'a', calls);
const b = member('child', 'b', calls);
const c = member('child', 'c', calls);
group.register(owner);
group.register(a);
group.register(b);
group.register(c);
// Owner is skipped; children index from 0 in document (registration) order.
expect(group.childIndex(a)).toBe(0);
expect(group.childIndex(b)).toBe(1);
expect(group.childIndex(c)).toBe(2);
});
it('cancel propagates to every member (RFC §8.3)', () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls));
group.cancel();
expect([...calls].sort()).toEqual(['a:cancel', 'owner:cancel']);
});
it('requestEnter coordinates after the registration-window frame', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.requestEnter();
// syncDom runs the frame synchronously; a macrotask drains the microtask
// queue so the triggered playEnter has fully settled.
await new Promise((r) => setTimeout(r, 0));
expect(calls).toEqual(['owner:enter']);
});
});
describe('PresenceGroup — exit coordination + DOM retention (RFC: MOTION_SERVICE_RFC.md §8.1)', () => {
it('after: children exit first while the owner is retained, then owner, then unmount', async () => {
const calls: string[] = [];
const childGate = deferred();
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: syncDom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls, childGate));
const done = group.playExit();
await Promise.resolve();
// Child is exiting; the owner is RETAINED — it has neither exited nor
// unmounted (no owner:exit, no unmount yet).
expect(calls).toEqual(['a:exit']);
childGate.resolve();
await done;
// Owner exits only after the children, then the whole tree unmounts.
expect(calls).toEqual(['a:exit', 'owner:exit', 'owner:unmount', 'a:unmount']);
});
it('before: owner exits first, then children, then unmount', async () => {
const calls: string[] = [];
const group = new PresenceGroup({ when: { enter: 'together', exit: 'before' }, dom: syncDom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls));
await group.playExit();
expect(calls).toEqual(['owner:exit', 'a:exit', 'owner:unmount', 'a:unmount']);
});
it('together: all exit in parallel; nobody unmounts before the exit aggregates', async () => {
const calls: string[] = [];
const gate = deferred();
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls, gate));
group.register(member('child', 'a', calls, gate));
const done = group.playExit();
await Promise.resolve();
// Both released; nobody unmounts until the exit aggregates (retention).
expect([...calls].sort()).toEqual(['a:exit', 'owner:exit']);
gate.resolve();
await done;
// Unmounts come strictly after each surface's own exit.
expect(calls.indexOf('owner:unmount')).toBeGreaterThan(calls.indexOf('owner:exit'));
expect(calls.indexOf('a:unmount')).toBeGreaterThan(calls.indexOf('a:exit'));
});
it('requestExit coordinates the exit after the next frame', async () => {
const calls: string[] = [];
const group = new PresenceGroup({
when: { enter: 'together', exit: 'together' },
dom: syncDom
});
group.register(member('owner', 'owner', calls));
group.requestExit();
// Drain the microtask queue (macrotask) so playExit + the unmount sweep
// have fully settled, not just the first await.
await new Promise((r) => setTimeout(r, 0));
expect(calls).toEqual(['owner:exit', 'owner:unmount']);
});
});
describe('PresenceGroup — interruption / reversa (RFC: MOTION_SERVICE_RFC.md §8.3)', () => {
it('re-open mid-exit cancels the exit and does NOT tear the tree down', async () => {
const calls: string[] = [];
const childGate = deferred();
const md = manualDom();
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: md.dom });
group.register(member('owner', 'owner', calls));
group.register(member('child', 'a', calls, childGate));
// Start the exit; the child's release is gated → playExit is mid-flight,
// the owner is retained.
group.requestExit();
md.flush();
await Promise.resolve();
expect(calls).toEqual(['a:exit']);
// Re-open mid-exit: a reversal cancels the in-flight members and supersedes
// the exit's generation.
group.requestEnter();
expect(calls).toEqual(['a:exit', 'owner:cancel', 'a:cancel']);
// Resolve the gated child exit so the superseded playExit resumes — it must
// bail at its interruption guard instead of running the unmount loop.
childGate.resolve();
md.flush();
await new Promise((r) => setTimeout(r, 0));
expect(calls).not.toContain('owner:unmount');
expect(calls).not.toContain('a:unmount');
// The reversing enter ran instead.
expect(calls).toContain('owner:enter');
expect(calls).toContain('a:enter');
});
it('close mid-enter cancels the enter and proceeds to the exit (final intent wins)', async () => {
const calls: string[] = [];
const gate = deferred();
const md = manualDom();
const group = new PresenceGroup({ when: { enter: 'together', exit: 'together' }, dom: md.dom });
group.register(member('owner', 'owner', calls, gate));
group.requestEnter();
md.flush();
await Promise.resolve();
expect(calls).toEqual(['owner:enter']);
// Close mid-enter: reversal cancels the enter, supersedes with an exit.
group.requestExit();
expect(calls).toEqual(['owner:enter', 'owner:cancel']);
// Resolve the gate; the superseded enter bails, the exit runs and tears down
// (the user's final intent is closed).
gate.resolve();
md.flush();
await new Promise((r) => setTimeout(r, 0));
expect(calls).toContain('owner:exit');
expect(calls).toContain('owner:unmount');
});
});

@ -1,294 +0,0 @@
/**
* PresenceGroup — coordinates the presence LIFECYCLE of a tree of animable
* surfaces (RFC: eidos/MOTION_SERVICE_RFC.md §7). This is the soma half of the
* motion service: pure state/lifecycle, NEVER visual. `Presence` manages one
* surface; `PresenceGroup` generalizes it to a parent surface + its child
* surfaces — sequencing WHEN each starts (per the morfo's
* `animation.children.{enter,exit}`) and aggregating their `finished`. The visual
* stagger/easing stays in eidos (CSS), the surface motion in `arts/motion`; the
* group only owns the timing.
*
* Discovery is by Svelte context, NOT DOM proximity (RFC §7.1): the parent
* surface's provider calls `PresenceGroup.create(...)`; child surfaces find it
* with `PresenceGroup.get()` and register. Non-animable wrappers in between are
* transparent — they neither create a group nor register, so the context flows
* through them.
*
* No runes here on purpose: the coordination core is plain async + a plain
* member set, so it is unit-testable without a component/DOM. The reactive
* trigger (open → playEnter) lives in the owner `Presence` and the provider —
* which is why this is a plain `.ts`, not a `.svelte.ts`.
*
* Scope — phases M2 + M3 + M4: ENTER coordination (M2) + EXIT with DOM retention
* (M3, §8.1) + interruption/reversa (M4, §8.3: a generation token supersedes a
* stale phase so a reversing enter never lets the exit tear down a surface it
* wants to keep — no unmount→remount flash) + registration + context +
* degradation (no group ⇒ each `Presence` is an island, exactly as before).
* Count-based registration close-out (§8.2) is still deferred to the first
* consumer that needs a fixed-cardinality barrier.
*/
import { context } from '../provider/context';
/** Parent↔child lifecycle relation, compiled from the morfo (RFC §4). */
export type PresenceWhen = 'before' | 'after' | 'together';
/** A member's place in the group: the coordinating surface vs a coordinated child. */
export type PresenceRole = 'owner' | 'child';
export type PresencePhase = 'enter' | 'exit';
/**
* A surface the group coordinates. The leaf is a `Presence`; a nested
* `PresenceGroup` is also a member (a black box — RFC §9). The member owns the
* "what/how" (its own motion); the group owns the "when".
*/
export interface PresenceMember {
/** Coordinating surface (`owner`) or coordinated child (`child`). */
readonly role: PresenceRole;
/**
* Start this surface's motion for the phase and resolve when ITS OWN
* animation finishes. The group never reads visual values — it only awaits.
*/
release(phase: PresencePhase): Promise<void>;
/**
* Tear down after the group's coordinated exit settles (RFC §8.1). The group
* calls this once the whole tree's exit has aggregated — never before, so a
* retained owner survives its children's exit.
*/
unmount(): void;
/** Cancel any in-flight motion on this surface (interruption — RFC §8.3, M4). */
cancel(): void;
}
/**
* Minimal frame scheduler the group needs for its registration window. Declared
* as a method (bivariant) so the real `ActiveDom` satisfies it structurally —
* the group never passes a frame timestamp, so the callback takes no args. Keeps
* this art-adjacent coordinator decoupled from the full `$adom` surface.
*/
export interface PresenceFrameScheduler {
requestFrame(callback: () => void): unknown;
}
export interface PresenceGroupOptions {
/**
* Parent↔child coordination per phase (from `morfo.animation.children`). Enter
* and exit are independent so a container can BRACKET its children —
* `{ enter: 'before', exit: 'after' }` appears before them and leaves after.
*/
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
/**
* Frame scheduler for the registration window (RFC §8.2): children that mount
* in the same pass as the owner register before the scheduled frame, so the
* owner-triggered `requestEnter` sees the full member set. `ActiveDom`
* satisfies this — the group only needs `requestFrame`.
*/
readonly dom: PresenceFrameScheduler;
}
export class PresenceGroup {
/** Discovered by child surfaces via Svelte context (RFC §7.1). */
static readonly ctx = context<PresenceGroup>('PresenceGroup');
/** Optional read — `undefined` when there is no group ancestor (the common case). */
static get(): PresenceGroup | undefined {
return PresenceGroup.ctx.getOr(undefined) as PresenceGroup | undefined;
}
/** Required read — throws when called outside a group scope. */
static require(): PresenceGroup {
return PresenceGroup.ctx.get();
}
/**
* Create the group and publish it in context. Call from the parent surface's
* provider constructor (component init), so descendant surfaces discover it.
*/
static create(opts: PresenceGroupOptions): PresenceGroup {
const group = new PresenceGroup(opts);
PresenceGroup.ctx.set(group);
return group;
}
private readonly opts: PresenceGroupOptions;
private readonly members = new Set<PresenceMember>();
/**
* Generation token (RFC §8.3) — the group-level analogue of `Presence`'s
* `runId`. Every `requestEnter`/`requestExit`/`cancel` bumps it; a scheduled
* `playEnter`/`playExit` captures the value at request time and bails at each
* await boundary once it goes stale. This is what stops a superseded exit from
* tearing down a surface the reversing enter wants to keep.
*/
private generation = 0;
/** The phase currently mid-flight — lets `requestEnter`/`requestExit` detect a reversal. */
private active: PresencePhase | undefined;
constructor(opts: PresenceGroupOptions) {
this.opts = opts;
}
/**
* Register a member. Returns a deregister function — call it from the
* member's cleanup (`$effect` teardown) so the set stays accurate as surfaces
* mount/unmount (RFC §7.1: dynamic member set).
*/
register(member: PresenceMember): () => void {
this.members.add(member);
return () => {
this.members.delete(member);
};
}
/** How many members are registered (the owner uses this to decide whether to coordinate). */
get size(): number {
return this.members.size;
}
/**
* The 0-based position of a CHILD among the children, in registration (document)
* order — the canonical stagger index. A coordinated child derives its
* `--motion-stagger-index` from this, so the cascade offsets itself from the
* coordination order and the dev never hand-numbers the items (RFC §15 / M6).
*/
childIndex(member: PresenceMember): number {
let index = 0;
for (const m of this.members) {
if (m === member) return index;
if (m.role === 'child') index++;
}
return 0;
}
/**
* Schedule a coordinated enter after a registration-window frame, so children
* that registered in the owner's mount pass are included (RFC §8.2). The owner
* `Presence` calls this when its `open` flips true.
*/
requestEnter(): void {
// Reversal (RFC §8.3): an exit is in flight — cancel it so the two phases do
// not stack motion on the same surfaces, then supersede it via a fresh `gen`.
if (this.active === 'exit') this.cancel();
const gen = ++this.generation;
this.opts.dom.requestFrame(() => {
void this.playEnter(gen);
});
}
/**
* Schedule a coordinated exit on the next frame (RFC §8.1). Members are already
* registered (they mounted on enter), so no registration window is needed — the
* frame defer just lets every member record its `ending` intent first.
*/
requestExit(): void {
// Reversal (RFC §8.3): an enter is in flight — cancel it before superseding.
if (this.active === 'enter') this.cancel();
const gen = ++this.generation;
this.opts.dom.requestFrame(() => {
void this.playExit(gen);
});
}
/**
* Coordinate the tree's ENTER (RFC §7, §9). Sequences each member's `release`
* per `when` and aggregates their `finished`: the owner releases before the
* children (`before`), after them (`after`), or all in parallel (`together` —
* the CSS stagger does the visual offset). Resolves when the whole tree settles.
*/
async playEnter(gen: number = this.generation): Promise<void> {
if (this.superseded(gen)) return;
this.active = 'enter';
const members = [...this.members];
const owner = members.filter((m) => m.role === 'owner');
const children = members.filter((m) => m.role === 'child');
switch (this.opts.when.enter) {
case 'before':
await releaseAll(owner, 'enter');
if (this.superseded(gen)) return;
await releaseAll(children, 'enter');
break;
case 'after':
await releaseAll(children, 'enter');
if (this.superseded(gen)) return;
await releaseAll(owner, 'enter');
break;
case 'together':
default:
await releaseAll(members, 'enter');
break;
}
if (this.superseded(gen)) return;
this.active = undefined;
}
/**
* Coordinate the tree's EXIT (RFC §8.1). Sequences each member's `release` per
* `when` (the inverse intent of enter), then tears the tree down. For
* `when: 'after'` the children leave first while the owner RETAINS its DOM,
* then the owner runs its own exit — only after the whole exit aggregates does
* anyone unmount. The visual stagger is CSS (eidos); soma only sequences,
* awaits, and unmounts.
*/
async playExit(gen: number = this.generation): Promise<void> {
if (this.superseded(gen)) return;
this.active = 'exit';
const members = [...this.members];
const owner = members.filter((m) => m.role === 'owner');
const children = members.filter((m) => m.role === 'child');
switch (this.opts.when.exit) {
case 'before':
await releaseAll(owner, 'exit');
if (this.superseded(gen)) return;
await releaseAll(children, 'exit');
break;
case 'after':
// Children leave first; the owner RETAINS its DOM (stays mounted)
// until their exit aggregates, then runs its own exit (RFC §8.1).
await releaseAll(children, 'exit');
if (this.superseded(gen)) return;
await releaseAll(owner, 'exit');
break;
case 'together':
default:
await releaseAll(members, 'exit');
break;
}
// Interruption guard (RFC §8.3): a reversing `requestEnter` bumps
// `generation`; if we were superseded mid-exit, DO NOT tear down — the new
// enter keeps the surfaces mounted (no unmount→remount flash). Only when we
// are still the current phase does the coordinated exit tear the tree down:
// the owner's unmount drops the retained subtree, siblings unmount themselves.
if (this.superseded(gen)) return;
this.active = undefined;
for (const member of members) member.unmount();
}
/**
* Propagate cancellation to every member (interruption — RFC §8.3). Bumps the
* generation so any in-flight `playEnter`/`playExit` bails at its next guard (no
* spurious unmount), clears the active phase, and stops each surface's motion via
* `member.cancel()` (→ `motion.cancel(node)`). Called automatically on a reversal
* by `requestEnter`/`requestExit`; also the public interruption entry point.
*/
cancel(): void {
this.generation++;
this.active = undefined;
for (const member of this.members) member.cancel();
}
/** Whether a newer request has superseded the coordination tagged `gen` (RFC §8.3). */
private superseded(gen: number): boolean {
return gen !== this.generation;
}
}
/** Release every member for a phase in parallel; resolve when all finish (RFC §9 aggregation). */
function releaseAll(members: readonly PresenceMember[], phase: PresencePhase): Promise<void> {
if (members.length === 0) return Promise.resolve();
return Promise.all(members.map((m) => m.release(phase))).then(() => undefined);
}

@ -108,27 +108,4 @@ describe('soma Presence — motion.run (JS-driver gating)', () => {
cleanup();
});
it('cancel() stops in-flight JS motion via motion.cancel(node) (RFC §8.3)', () => {
const cancelled: HTMLElement[] = [];
const node = fakeNode();
const open = state(true);
const ref = state(node);
const fd = makeFakeDom();
const motion = {
run: () => ({ finished: new Promise<void>(() => {}), cancel() {} }),
cancel: (n: HTMLElement) => cancelled.push(n)
} as never;
const { result: presence, cleanup } = withEffectRoot(
() => new Presence({ dom: fd.dom, open, ref, motion })
);
flushSync();
fd.flush();
presence.cancel();
// Propagates the per-node cancel to the motion engine (RFC §8.3) — the runId
// bump already neutralises the async tail; this stops the JS animation itself.
expect(cancelled).toEqual([node]);
cleanup();
});
});

@ -1,8 +1,7 @@
import { type Active, type ActiveProps } from '$libs/reactive';
import { type ActiveProps } from '$libs/reactive';
import { watch } from 'runed';
import type { ActiveDom } from '$adom';
import type { EngineMotion } from '$motion';
import type { PresenceGroup, PresenceMember, PresencePhase, PresenceRole } from './presence-group';
// ── Types ────────────────────────────────────────────────────────────────────
@ -21,31 +20,6 @@ export interface PresenceOptions extends ActiveProps<{ open: boolean; ref: HTMLE
* `this.soma.motion`; the engine reads the node's `data-animation-style`.
*/
motion?: EngineMotion;
/**
* Presence-coordination group (RFC: eidos/MOTION_SERVICE_RFC.md §7). When
* present, this surface CEDES its enter timing to the group: it mounts and
* waits to be released instead of self-driving (the group sequences releases
* per the morfo's `animation.children.{enter,exit}` and aggregates `finished`).
* Absent (the common case) ⇒ this `Presence` is an island, exactly as before.
*/
group?: PresenceGroup;
/**
* Role within the group. The parent/coordinating surface is `'owner'`; nested
* surfaces are `'child'` (the default). Only the owner triggers the group's
* coordinated enter; children mount and wait to be released.
*/
groupRole?: PresenceRole;
/**
* External per-phase pending hook (RFC §M9 / §9). When this surface coordinates
* STATIC DOM children via a `DomCascade` (the children-DOM mode), the children's
* transitions are invisible to the owner's `getAnimations()` — they are DOM
* descendants, not the owner node, and the engine takes no `{subtree:true}` (it
* stays hierarchy-agnostic). The provider wires this to `DomCascade.pending` so the
* owner aggregates the items' `finished` alongside its own animations; otherwise the
* owner would drop the subtree mid-cascade on exit. Absent ⇒ no extra wait (the
* common case — an island surface with no DOM-child cascade).
*/
pending?: (phase: PresencePhase) => Promise<void> | undefined;
}
export type TransitionStatus = 'starting' | 'ending' | undefined;
@ -53,7 +27,7 @@ export type TransitionStatus = 'starting' | 'ending' | undefined;
// ── Presence ─────────────────────────────────────────────────────────────────
/**
* Animation-aware presence manager.
* Animation-aware presence manager for a single surface (an island).
*
* Lifecycle:
*
@ -67,7 +41,7 @@ export type TransitionStatus = 'starting' | 'ending' | undefined;
* → getAnimations().finished
* → shouldRender=false + transitionStatus=undefined → onComplete(false)
*/
export class Presence implements PresenceMember {
export class Presence {
readonly opts: PresenceOptions;
shouldRender = $state(false);
@ -75,23 +49,11 @@ export class Presence implements PresenceMember {
private runId = 0;
private frameIds = new Set<number>();
/** Run id captured when a grouped surface mounts and waits to be released (RFC §7). */
private pendingRunId = 0;
/** Deregister callback from the coordination group, if any. */
private deregister: (() => void) | undefined;
constructor(opts: PresenceOptions) {
this.opts = opts;
this.shouldRender = opts.open.current;
// RFC §7.1: cede to a coordination group if one is in scope. Register now
// (synchronously, in the provider's init) and deregister on teardown so the
// group's member set tracks mount/unmount. No group ⇒ island (below).
if (opts.group) {
this.deregister = opts.group.register(this);
$effect(() => () => this.deregister?.());
}
watch(
() => opts.open.current,
(isOpen) => {
@ -117,13 +79,6 @@ export class Presence implements PresenceMember {
// ── Open ─────────────────────────────────────────────────────────────────
private handleOpen() {
// Grouped surface: cede the release timing to the group (RFC §7). The
// island path below is unchanged.
if (this.opts.group) {
this.handleOpenGrouped();
return;
}
this.cleanup();
this.shouldRender = true;
this.transitionStatus = 'starting';
@ -135,8 +90,8 @@ export class Presence implements PresenceMember {
if (runId !== this.runId) return;
this.transitionStatus = undefined;
// Start any JS-driven motion + DOM-child cascade, then wait for CSS + JS.
const extra = this.startPhase('enter');
// Start any JS-driven motion, then wait for CSS + JS.
const extra = this.startMotion('enter');
this.waitForAnimations(runId, extra, () => {
this.opts.onComplete?.(true);
});
@ -146,13 +101,6 @@ export class Presence implements PresenceMember {
// ── Close ────────────────────────────────────────────────────────────────
private handleClose() {
// Grouped surface: cede the exit to the group, which coordinates the tree's
// exit and unmounts after (DOM retention §8.1). The island path is unchanged.
if (this.opts.group) {
this.handleCloseGrouped();
return;
}
this.cleanup();
const enabled = this.opts.enabled ?? true;
@ -167,10 +115,8 @@ export class Presence implements PresenceMember {
const runId = ++this.runId;
// Start any JS-driven exit motion + DOM-child cascade, then wait for CSS + JS,
// then unmount. With a `pending` hook this holds the subtree until the cascade
// settles (RFC §M9 exit-heavy) instead of dropping the items mid-stagger.
const extra = this.startPhase('exit');
// Start any JS-driven exit motion, then wait for CSS + JS, then unmount.
const extra = this.startMotion('exit');
this.waitForAnimations(runId, extra, () => {
if (runId !== this.runId) return;
this.shouldRender = false;
@ -179,104 +125,6 @@ export class Presence implements PresenceMember {
});
}
// ── Grouped lifecycle (RFC §7) — only active when `opts.group` is set ──────
/**
* Grouped ENTER: mount immediately, then WAIT to be released by the group.
* Only the owner triggers the group's coordinated enter; children mount and
* stay in `data-starting-style` until the group calls `release('enter')`.
*/
private handleOpenGrouped() {
this.cleanup();
this.shouldRender = true;
this.transitionStatus = 'starting';
this.pendingRunId = ++this.runId;
if (this.role === 'owner') this.opts.group?.requestEnter();
}
/**
* Grouped EXIT: cede to the group (RFC §8.1). Mark `ending` and WAIT — the
* group runs the tree's exit in `when` order and calls `unmount()` here only
* after the whole exit settles (DOM retention). Only the owner triggers it.
*/
private handleCloseGrouped() {
this.cleanup();
const enabled = this.opts.enabled ?? true;
if (!enabled) {
this.unmount();
return;
}
// Stay in the OPEN visual state (no `data-ending-style` yet) until the group
// RELEASES this surface. That is what makes `when: 'after'` exit the children
// before the owner — and the owner stay visibly intact while its children
// leave — instead of everyone's exit animation firing at once. `release`
// stamps `ending` when our turn comes (RFC §8.1).
this.pendingRunId = ++this.runId;
if (this.role === 'owner') this.opts.group?.requestExit();
}
/** This surface's role within its group (`PresenceMember`). */
get role(): PresenceRole {
return this.opts.groupRole ?? 'child';
}
/**
* Release this surface's motion for the phase — the group calls this in
* coordinated order (`PresenceMember`). Removes `data-starting-style` on enter,
* starts the JS motion, and resolves when this surface's own animation finishes
* (CSS via `getAnimations()` + any JS `finished`). The visual stagger is CSS
* (eidos); soma only awaits.
*/
release(phase: PresencePhase): Promise<void> {
const runId = this.pendingRunId;
return new Promise<void>((resolve) => {
this.requestFrame(() => {
if (runId !== this.runId) {
resolve();
return;
}
// Enter: drop `starting` → the CSS transition plays. Exit: stamp
// `ending` NOW (not at close) so the surface holds its open state until
// the group reaches it, giving `when: 'after'` its sequencing (§8.1).
if (phase === 'enter') this.transitionStatus = undefined;
else this.transitionStatus = 'ending';
const extra = this.startPhase(phase);
this.waitForAnimations(runId, extra, () => {
if (phase === 'enter') this.opts.onComplete?.(true);
resolve();
});
});
});
}
/**
* Tear down after the group's coordinated exit settles (`PresenceMember`, RFC
* §8.1). Mirrors the island unmount: drop the surface and notify. The group
* calls this once the whole tree's exit has aggregated — never before, so the
* owner's DOM (and any retained subtree) survives the children's exit.
*/
unmount(): void {
this.shouldRender = false;
this.transitionStatus = undefined;
this.opts.onComplete?.(false);
}
/**
* Cancel in-flight motion (interruption — RFC §8.3, M4). `cleanup()` bumps the
* runId so the in-flight async tail (frames, `waitForAnimations`) becomes a
* no-op, and `motion.cancel(node)` stops any JS-driven run so a reversal does
* not stack a second animation on the node (CSS presets are untracked → no-op;
* the reversed CSS transition continues from its current value). The group calls
* this on every member when a reversal supersedes the in-flight phase.
*/
cancel(): void {
this.cleanup();
const node = this.opts.ref.current;
if (node) this.opts.motion?.cancel(node);
}
// ── Animation waiting ────────────────────────────────────────────────────
/** Start the JS-driven motion for this phase, if a `motion` engine is wired. */
@ -286,22 +134,6 @@ export class Presence implements PresenceMember {
return this.opts.motion?.run(node, phase)?.finished;
}
/**
* Begin this phase's awaitable side effects and fold them into ONE promise to gate
* completion on: the JS-driven motion (`startMotion`) AND the children-DOM cascade's
* aggregated `finished` (`opts.pending` — RFC §M9). The owner node's own
* `getAnimations()` is awaited separately in `waitForAnimations`; this covers what
* that call cannot see — a JS spring (invisible to `getAnimations()`) and the
* DOM-child cascade (descendants, no `{subtree:true}`). With neither wired this is
* exactly `startMotion`, so an island surface behaves precisely as before.
*/
private startPhase(phase: PresencePhase): Promise<void> | undefined {
const motion = this.startMotion(phase);
const cascade = this.opts.pending?.(phase);
if (motion && cascade) return Promise.all([motion, cascade]).then(() => undefined);
return motion ?? cascade;
}
private waitForAnimations(
runId: number,
extra: Promise<void> | undefined,
@ -336,11 +168,10 @@ export class Presence implements PresenceMember {
// This is not only rapid toggling (stale runId → no-op below): a
// visual exit animation can be interrupted WITHOUT a state change —
// e.g. sema unstamps the `data-event-*` that drives the close
// `dismiss-fade` when its hold ends BEFORE the animation's own
// duration, cancelling it. A cancelled exit MUST still finalize the
// lifecycle; otherwise a closed overlay stays mounted forever and
// its FocusScope traps focus (no block can be focused to type).
// Stale runs are filtered by the runId guard, exactly as in `then`.
// animation when its hold ends BEFORE the animation's own duration,
// cancelling it. A cancelled exit MUST still finalize the lifecycle;
// otherwise a closed overlay stays mounted forever and its FocusScope
// traps focus. Stale runs are filtered by the runId guard, as in `then`.
if (runId !== this.runId) return;
onDone();
});

@ -23,10 +23,8 @@
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
// `sound: true` so the modo (c) demo (Reveal — RFC Apéndice B) is audible: opening
// the reveal plays an `emerge` sound WHILE the cascade runs (eidos neutralises the
// generic visual firma over the coordinated surface, so they compose, not fight).
// haptic stays off. The sound also makes each Button's contact press audible.
// `sound: true` so the demo's component interactions are audible — each Button's
// contact press and the overlay components' firmas. haptic stays off.
events: { sound: true, haptic: false }
})
setActiveUix(uix)

@ -19,7 +19,6 @@
import { ActiveEidos } from '$uix/eidos'
import { rect, spring, waapi } from '$motion'
import { Presence } from '$soma/layers/presence.svelte'
import { PresenceGroup } from '$soma/layers/presence-group'
import { readableActive } from '$libs/reactive'
import { Dialog } from '$uix/eidos/components/dialog'
import { Popover } from '$uix/eidos/components/popover'
@ -200,39 +199,6 @@
motion: eidos.motion
})
// ── PresenceGroup — coordinated list with EXIT retention (RFC §13 prototype) ──
// The motion service end-to-end (M2 enter + M3 exit): a container (owner) and
// its items (children) coordinated by a `PresenceGroup` with `when: 'after'`.
// On CLOSE the items stagger OUT while the container RETAINS its DOM; only when
// their exit aggregates does the container leave, and only then does the tree
// unmount (§8.1). The visual stagger is pure CSS (transition / animation-delay
// × --i); soma only sequences + awaits `getAnimations()` — no JS motion here.
const PG_ITEMS = [0, 1, 2, 3]
let pgOpen = $state(false)
const pgOpenActive = readableActive(() => pgOpen)
const pgGroup = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: eidos.dom })
let pgContainerEl = $state<HTMLElement | null>(null)
const pgContainer = new Presence({
dom: eidos.dom,
open: pgOpenActive,
ref: readableActive(() => pgContainerEl),
group: pgGroup,
groupRole: 'owner'
})
const pgItemEls = $state<Record<number, HTMLElement | null>>({})
const pgItems = PG_ITEMS.map(
(i) =>
new Presence({
dom: eidos.dom,
open: pgOpenActive,
ref: readableActive(() => pgItemEls[i] ?? null),
group: pgGroup,
groupRole: 'child'
})
)
// FLIP — layout transition via the `rect` driver: measure first, mutate the
// layout, then animate the inverse delta. The WAAPI animation is in
// getAnimations(), so it composes with the rest of the system for free.
@ -556,54 +522,6 @@ eidos.motion.enter(el, 'pop', { dom: eidos.dom })`}</pre>
// panel markup: <div data-animation-style="panel-spring" /> → motion.run runs the spring`}</pre>
</section>
<!-- PresenceGroup — coordinated list with EXIT retention (§13 prototype) -->
<section class="preset">
<div class="head">
<span class="eyebrow">soma PresenceGroup · when='after' · coordinación de presencia</span>
<h2>presence group <small>(coreografía exit-heavy · retención de DOM)</small></h2>
<p>
El <strong>servicio de motion end-to-end</strong> (M2 enter + M3 exit). Un contenedor
(<code>owner</code>) y sus ítems (<code>child</code>) coordinados por un
<code>PresenceGroup</code> con <code>when='after'</code>. Al <strong>cerrar</strong>, los
ítems salen <strong>en stagger</strong> mientras el contenedor <strong>retiene su DOM</strong>;
solo cuando su salida agrega, el contenedor se va y entonces se desmonta el árbol (§8.1). El
stagger visual es CSS puro (<code>animation-delay × --i</code>); soma solo secuencia y espera
<code>getAnimations()</code>. (En el preview en segundo plano el rAF puede congelarse; enfoca
el browser para ver la coreografía.)
</p>
</div>
<div class="demo">
<div class="actions">
<button class="btn" type="button" onclick={() => (pgOpen = !pgOpen)}>
{pgOpen ? 'Cerrar' : 'Abrir'} lista
</button>
<span class="tag">contenedor: {pgContainer.isPresent ? 'montado' : 'desmontado'}</span>
</div>
<div class="stage auto">
{#if pgContainer.isPresent}
<div class="pg-container" bind:this={pgContainerEl} {...pgContainer.transitionAttrs}>
{#each PG_ITEMS as i (i)}
{#if pgItems[i].isPresent}
<div
class="pg-item"
bind:this={pgItemEls[i]}
style="--i: {i}"
{...pgItems[i].transitionAttrs}
>
item {i + 1}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<pre class="code">{`const group = new PresenceGroup({ when: 'after', dom })
const container = new Presence({ open, ref, group, groupRole: 'owner' })
const items = [...].map(i => new Presence({ open, ref: itemRef(i), group, groupRole: 'child' }))
// close → items stagger out (container retained) → container exits → unmount`}</pre>
</section>
<!-- FLIP — layout transition via the rect driver -->
<section class="preset">
<div class="head">
@ -1172,52 +1090,4 @@ eidos.motion.enter(boxEl, 'genie') // ▶ play`}</pre>
will-change: transform, opacity;
}
.pg-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
padding: var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border-subtle);
}
.pg-container[data-ending-style] {
animation: pg-container-out 0.3s var(--ease-in, ease) forwards;
}
@keyframes pg-container-out {
to {
opacity: 0;
transform: scale(0.97);
}
}
.pg-item {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
font-size: var(--font-size-sm);
font-weight: 600;
opacity: 1;
transform: translateY(0);
transition:
opacity 0.3s ease,
transform 0.3s ease;
transition-delay: calc(var(--i) * 0.06s);
will-change: transform, opacity;
}
.pg-item[data-starting-style] {
opacity: 0;
transform: translateY(10px);
}
.pg-item[data-ending-style] {
animation: pg-item-out 0.35s ease forwards;
animation-delay: calc(var(--i) * 0.08s);
}
@keyframes pg-item-out {
to {
opacity: 0;
transform: translateY(10px);
}
}
</style>

@ -1,296 +0,0 @@
<script lang="ts">
/**
* Compuesto · presets de la LIBRERÍA de eidos.
*
* Unlike the `reveal` / `rail` demos — which hand-write the `cascade-*` CSS on
* this page — this one uses the eidos BUILT-IN coordinated presets
* (`BUILTIN_COORDINATED_PRESETS` → `generated/base.css`): there is ZERO
* animation CSS here. The component just names `animation="cascade-slide"`; the
* library supplies the off-state, the transition and the reversible stagger
* (gated on the Presence's `data-starting/ending-style`, M6). The picker swaps
* the prop live — the LOOK changes with no page CSS touched.
*
* It also COMPOSES the two orthogonal feedback systems (see /temas/animations
* → the sema↔motion explainer): the Rail.Item surfaces cascade in (motion
* coordinated, `data-animation-style`), while each <Button> inside fires its
* own press firma (sema `contact-activate` → `press-squeeze` on
* `data-event-family='contact'`). Two nodes, two axes, no conflict — pressing a
* button squeezes it while the row stays put.
*
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import * as Rail from '$soma/components/rail';
import { Button } from '$uix/eidos/components/button';
// The items are real <Button>s — one per variant, to show the prominence scale
// (solid → soft → surface → outline → ghost). The variant is the VISUAL axis;
// every button fires the SAME generic `contact` press (no intent), per book
// cap. 22 §11 — the press is the gesture's reception, not its evaluation.
const ITEMS = [
{ label: 'Guardar', variant: 'solid' },
{ label: 'Compartir', variant: 'soft' },
{ label: 'Duplicar', variant: 'surface' },
{ label: 'Archivar', variant: 'outline' },
{ label: 'Eliminar', variant: 'ghost' }
] as const;
// The THREE built-in coordinated presets of eidos — nothing page-local.
const STYLES = [
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' }
];
let open = $state(true);
let animStyle = $state('cascade-slide');
let duration = $state(320); // ms → drives the preset's `--motion-cascade-duration`
let stagger = $state(60); // ms → the rhythm between items (`--motion-stagger-each`)
</script>
<svelte:head>
<title>Compuesto · presets de la librería de eidos</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>Compuesto <small>presets predefinidos de eidos</small></h1>
<p class="lede">
A diferencia de <a href="/temas/animations/rail">rail</a> y
<a href="/temas/animations/reveal">reveal</a> —que escriben el CSS de
<code>cascade-*</code> a mano en la página— este compuesto usa los
<strong>presets coordinados de la LIBRERÍA</strong> de eidos
(<code>BUILTIN_COORDINATED_PRESETS</code> → <code>generated/base.css</code>). Esta página no
tiene <strong>ni una línea</strong> de CSS de animación: el componente solo nombra
<code>animation="cascade-slide"</code> y la librería aporta el off-state, la transición y el
stagger reversible (sobre <code>data-starting/ending-style</code>, M6). El selector cambia la
<strong>prop en vivo</strong> — cambia el look sin tocar la página.
</p>
<p class="lede compose">
Y <strong>compone los dos ejes</strong>: las superficies <code>Rail.Item</code> entran en
cascada (<em>motion coordinado</em>, <code>data-animation-style</code>) mientras cada
<code>&lt;Button&gt;</code> dispara su propia firma de press
(<em>sema</em>, <code>contact-activate</code> → <code>press-squeeze</code>). Dos nodos, dos
sistemas, sin conflicto: al pulsar un botón se comprime él, la fila no se mueve.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={() => (open = !open)}>
{open ? 'Ocultar' : 'Mostrar'} barra
</button>
<div class="picker" role="group" aria-label="Preset de animación (librería de eidos)">
{#each STYLES as s (s.value)}
<button
class="chip-btn"
class:on={animStyle === s.value}
type="button"
onclick={() => (animStyle = s.value)}
>
{s.label}
</button>
{/each}
</div>
</div>
<div class="sliders">
<label>
duración
<input type="range" min="80" max="700" step="20" bind:value={duration} />
<span class="val">{duration}ms</span>
</label>
<label>
stagger
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
<span class="val">{stagger}ms</span>
</label>
</div>
<span class="hint">
Para la cascada fluida, abre esta página enfocada en el browser (en preview de fondo el rAF se
throttlea). La animación NO está aquí — sale de la foundation generada.
</span>
</header>
<!-- The stage feeds the library preset its knobs, which INHERIT to the surfaces:
`--motion-cascade-duration` (speed), `--motion-stagger-each` (rhythm) and
`--motion-stagger-count` (so the exit stagger can reverse). The per-item
`--motion-stagger-index` is written by soma automatically (M6). -->
<div
class="stage"
style="--motion-cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
>
<Rail.Provider bind:open animation={animStyle}>
{#each ITEMS as item (item.label)}
<Rail.Item>
<Button variant={item.variant}>{item.label}</Button>
</Rail.Item>
{/each}
</Rail.Provider>
</div>
<pre class="code">{`<Rail.Provider animation="cascade-slide"> <!-- a library preset, no page CSS -->
{#each items as item}
<Rail.Item>
<Button variant={item.variant}>{item.label}</Button>
</Rail.Item>
{/each}
</Rail.Provider>`}</pre>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0 0 0.75rem;
max-width: 66ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede.compose {
margin-block-end: 0;
}
.lede a {
color: var(--color-primary-solid, #4f46e5);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.hint {
display: inline-block;
margin-block-start: 0.75rem;
font-size: 0.78rem;
color: var(--color-content-muted, #888);
max-width: 56ch;
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.primary {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
border-color: transparent;
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip-btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip-btn.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.sliders {
display: flex;
gap: 1.5rem;
margin-block-start: 1rem;
flex-wrap: wrap;
}
.sliders label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--color-content-secondary, #555);
}
.sliders input[type='range'] {
accent-color: var(--color-primary-solid, #4f46e5);
}
.sliders .val {
min-width: 3.5ch;
font-variant-numeric: tabular-nums;
color: var(--color-content-muted, #888);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 1.5rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 120px;
display: flex;
align-items: center;
}
/* The Rail surfaces — STATIC layout only. NO transition, NO off-state: those
come entirely from the eidos library preset (the whole point of this demo).
soma stamps data-animation-style + data-starting/ending-style; the generated
CSS reacts. The item is a transparent animable wrapper around the Button. */
:global([data-rail]) {
display: inline-flex;
flex-direction: row;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
}
:global([data-rail-item]) {
display: inline-flex;
}
.code {
max-width: 880px;
margin: 1.25rem auto 0;
overflow-x: auto;
padding: 1rem 1.25rem;
border-radius: 12px;
background: var(--color-surface-default, #fff);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: 0.78rem;
line-height: 1.5;
color: var(--color-content-secondary, #555);
}
</style>

@ -1,242 +0,0 @@
<script lang="ts">
/**
* DomCascade — children-DOM stagger (RFC §M9), validated in ISOLATION before the
* menu. Unlike Reveal/Rail (each item is a registered child `Presence`), here the
* owner is ONE `Presence` (the container) and the items are STATIC DOM — exactly
* the shape a real menu/list has. The `DomCascade` mirrors the owner's
* `transitionAttrs` + a per-DOM-order `--motion-stagger-index` onto each item,
* reusing eidos's coordinated preset UNCHANGED (the items end up carrying the same
* attrs a child `Presence` would have produced).
*
* F1b validated the ENTER cascade (the items stagger in when the container opens).
* F1c closes the EXIT: the owner `Presence` cannot see the items' transitions on its
* own node (`getAnimations()`, no subtree), so the `DomCascade` exposes a `pending()`
* that the owner aggregates via `PresenceOptions.pending` — it now HOLDS the subtree
* until the cascade settles instead of dropping the items mid-stagger. Inherits the
* ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import { ActiveEidos } from '$uix/eidos';
import { readableActive } from '$libs/reactive';
import { Presence } from '$soma/layers/presence.svelte';
import { DomCascade } from '$soma/layers/dom-cascade.svelte';
const eidos = ActiveEidos.require();
const ITEMS = ['Perfil', 'Ajustes', 'Notificaciones', 'Equipo', 'Facturación', 'Salir'];
const STYLES = [
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' }
];
let open = $state(false);
let animStyle = $state('cascade-slide');
let containerEl = $state<HTMLElement | null>(null);
// ONE Presence for the owner (the container). It owns the lifecycle; the cascade
// only mirrors it onto the DOM items. F1c: on exit the owner awaits the items'
// cascade (`pending`) before unmounting — their transitions are invisible to the
// owner's `getAnimations()` (no subtree), so without this the container would drop
// mid-stagger on close. `cascade` is referenced lazily (only when `pending` fires).
// Explicit annotations break the inference cycle: `presence.pending` reads
// `cascade`, whose `ownerTransitionAttrs` reads `presence.transitionAttrs`.
const presence: Presence = new Presence({
dom: eidos.dom,
open: readableActive(() => open),
ref: readableActive(() => containerEl),
pending: () => cascade.pending()
});
// The propagator: route `animStyle` to each DOM child, mirror the owner's
// transition attrs, number them by DOM order. `items()` is a plain selector read —
// the same shape a menu's `getItems` uses.
const cascade: DomCascade = new DomCascade({
dom: eidos.dom,
animationStyle: readableActive(() => animStyle),
ownerTransitionAttrs: readableActive(() => presence.transitionAttrs),
items: () =>
containerEl ? Array.from(containerEl.querySelectorAll<HTMLElement>('[data-dc-item]')) : []
});
cascade.watch();
</script>
<svelte:head>
<title>DomCascade · children-DOM (M9 F1c)</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>DomCascade <small>children-DOM · M9 F1c</small></h1>
<p class="lede">
La <strong>segunda coordinación</strong> del servicio (RFC §M9). A diferencia de
<a href="/temas/animations/reveal">Reveal</a> / <a href="/temas/animations/rail">Rail</a>
—donde cada ítem es un <code>Presence</code> registrado— aquí el owner es <strong>un solo</strong>
<code>Presence</code> (el contenedor) y los ítems son <strong>DOM estático</strong>: justo la
forma de un menú real. El propagador <code>DomCascade</code>
<strong>espeja</strong> el <code>data-starting/ending-style</code> del owner + un
<code>--motion-stagger-index</code> por orden DOM en cada ítem, <strong>reutilizando el preset
coordinado de eidos sin cambiarlo</strong>. Es la grieta que el menú destapó, ya pavimentada.
</p>
<p class="lede note">
F1b validó la <strong>entrada</strong>; <strong>F1c</strong> cierra la
<strong>salida</strong>: el owner ahora <strong>espera</strong> a los ítems antes de
desmontar. Su <code>getAnimations()</code> no ve las transiciones de los ítems —sin
<code>subtree</code>— así que el <code>DomCascade</code> expone un <code>pending()</code> que el
<code>Presence</code> agrega. Ciérrala: los ítems salen en cascada <strong>antes</strong> de que
el contenedor desaparezca.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={() => (open = !open)}>
{open ? 'Ocultar' : 'Mostrar'} lista
</button>
<div class="picker" role="group" aria-label="Estilo de animación">
{#each STYLES as s (s.value)}
<button
class="chip-btn"
class:on={animStyle === s.value}
type="button"
onclick={() => (animStyle = s.value)}
>
{s.label}
</button>
{/each}
</div>
</div>
</header>
<!-- The stage feeds the cascade its rhythm; the items inherit it. The container is
the owner `Presence` (its transition attrs drive the cascade); the items are
plain DOM the propagator writes onto. -->
<div class="stage" style="--motion-cascade-duration: 320ms; --motion-stagger-each: 55ms">
{#if presence.isPresent}
<div class="container" bind:this={containerEl} {...presence.transitionAttrs}>
{#each ITEMS as label (label)}
<div class="dc-item" data-dc-item>{label}</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0 0 0.75rem;
max-width: 66ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede.note {
font-size: 0.85rem;
color: var(--color-content-muted, #777);
}
.lede a {
color: var(--color-primary-solid, #4f46e5);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.primary {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
border-color: transparent;
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip-btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip-btn.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 1.5rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 360px;
}
.container {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: min(280px, 100%);
padding: 0.5rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
}
/* The item's base look. The off-state + transition + stagger come from eidos's
coordinated preset (generated/base.css), applied via the attrs the DomCascade
propagates — NO animation CSS here. */
.dc-item {
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--color-surface-default, #fff);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
font-size: 0.9rem;
}
</style>

@ -1,229 +0,0 @@
<script lang="ts">
/**
* Dropdown-menu item cascade (RFC §M9 F2) — the children-DOM coordination mode
* on a REAL component. Unlike the `dom-cascade` isolation demo (static items in
* a bare container), here the cascade rides the actual `<DropdownMenu>`: the
* Content is the owner `Presence`, its rows are discovered by the provider
* (`getCascadeRows`), and the cascade COEXISTS with the focus-trap, dismissal,
* roving focus and a submenu. Opt-in via the `animation` prop; `undefined` ⇒ the
* menu behaves exactly as before (its plain `dropdown-menu-enter` chrome).
*
* Decision C: the WHOLE menu staggers — items, separators and group headings,
* disabled rows included. Decision A: focus returns to the trigger at close,
* not after the exit cascade. Submenus (decision B) keep their own animation.
* Inherits the ActiveUix + Soma + Eidos + sound scope from `../+layout.svelte`.
*/
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
type Preset = 'off' | 'cascade-slide' | 'cascade-fade' | 'cascade-scale';
const PRESETS: { value: Preset; label: string }[] = [
{ value: 'off', label: 'off' },
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' }
];
let open = $state(false);
let preset = $state<Preset>('cascade-slide');
let staggerEach = $state(45);
let cascadeDuration = $state(320);
// Bound menu state
let notifications = $state(true);
let theme = $state<'light' | 'dark' | 'system'>('system');
let lastSelected = $state<string | null>(null);
const animation = $derived(preset === 'off' ? undefined : preset);
const contentStyle = $derived(
`--motion-cascade-duration: ${cascadeDuration}ms; --motion-stagger-each: ${staggerEach}ms`
);
const pick = (label: string) => () => (lastSelected = label);
</script>
<svelte:head>
<title>DropdownMenu cascade · children-DOM (M9 F2)</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>DropdownMenu <small>item cascade · M9 F2</small></h1>
<p class="lede">
El modo <strong>children-DOM</strong> sobre un componente real. El
<code>&lt;DropdownMenu&gt;</code> expone una prop <code>animation</code>: al activarla, las filas
del menú <strong>entran y salen en cascada</strong> reusando el preset coordinado de eidos sin
cambiarlo. El Content es el <code>Presence</code> dueño; el provider descubre las filas
(<code>getCascadeRows</code>) y propaga el lifecycle. Convive con focus-trap, dismissal, roving
focus y submenú.
</p>
<p class="lede note">
<strong>Ábrelo y ciérralo</strong>: en salida, el panel <strong>espera</strong> a que las filas
salgan (F1c · <code>pending</code>) antes de desmontar. El foco vuelve al botón al cerrar, no al
terminar la animación. Cascadea <strong>todo</strong>: items, separadores y cabeceras,
incluyendo deshabilitados.
</p>
<div class="controls">
<div class="picker" role="group" aria-label="Preset de animación">
{#each PRESETS as p (p.value)}
<button
class="chip-btn"
class:on={preset === p.value}
type="button"
onclick={() => (preset = p.value)}
>
{p.label}
</button>
{/each}
</div>
<label class="slider">
<span>stagger {staggerEach}ms</span>
<input type="range" min="10" max="120" step="5" bind:value={staggerEach} />
</label>
<label class="slider">
<span>duración {cascadeDuration}ms</span>
<input type="range" min="120" max="600" step="20" bind:value={cascadeDuration} />
</label>
</div>
</header>
<div class="stage">
<DropdownMenu bind:open {animation}>
<DropdownMenu.Trigger>Cuenta ▾</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" sideOffset={6} style={contentStyle}>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Cuenta</DropdownMenu.GroupHeading>
<DropdownMenu.Item onSelect={pick('Perfil')}>Perfil</DropdownMenu.Item>
<DropdownMenu.Item onSelect={pick('Ajustes')}>Ajustes</DropdownMenu.Item>
<DropdownMenu.Item disabled>Facturación (no disponible)</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={notifications}>
Notificaciones
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={theme}>
<DropdownMenu.GroupHeading>Tema</DropdownMenu.GroupHeading>
<DropdownMenu.RadioItem value="light">Claro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Oscuro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">Sistema</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Más opciones</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item onSelect={pick('Exportar')}>Exportar…</DropdownMenu.Item>
<DropdownMenu.Item onSelect={pick('Importar')}>Importar…</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={pick('Salir')}>Salir</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<p class="echo">
Última selección: <strong>{lastSelected ?? '—'}</strong>
</p>
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0 0 0.75rem;
max-width: 66ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede.note {
font-size: 0.85rem;
color: var(--color-content-muted, #777);
}
.lede code {
font-size: 0.85em;
}
.controls {
display: flex;
align-items: center;
gap: 1.25rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip-btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip-btn.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.slider {
display: inline-flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--color-content-muted, #777);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 2rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 220px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.25rem;
}
.echo {
margin: 0;
font-size: 0.85rem;
color: var(--color-content-muted, #777);
}
</style>

@ -1,216 +0,0 @@
<script lang="ts">
import { cubicOut, cubicIn } from 'svelte/easing';
/*
* Fase 0 — Svelte-native menu cascade prototype (THROWAWAY).
*
* Close choreography (RFC `when: { exit: 'after' }` bracket):
* 1. the items leave in a reversed cascade (opacity+transform) — you SEE them
* go; they keep their space, so the cascade isn't masked;
* 2. THEN the panel collapses like a blind (its OWN close firma — the size
* channel of `emerge.close` of the panel), delayed so it "drags" the items.
* The blind is CSS grid `1fr → 0fr` (no JS measuring; visual stays in eidos).
* The panel's delay is tunable below so the drag feel can be dialled in.
*/
let open = $state(false);
let opening = $state(false);
let closing = $state(false);
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let items = $state(['Perfil', 'Facturación', 'Equipo', 'Integraciones', 'Ajustes', 'Cerrar sesión']);
let added = 0;
// Panel-blind tuning. delay = how long after the items start leaving the panel
// begins to collapse → the "drag" feel. Tune it live with the slider.
let panelDelay = $state(250);
const panelDur = 280;
function toggle() {
if (open) {
closing = true;
open = false;
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (closing = false), 1200);
return;
}
closing = false;
opening = true;
open = true;
clearTimeout(openTimer);
openTimer = setTimeout(() => (opening = false), 800);
}
function addItem() {
added += 1;
items.push('Nuevo ' + added);
}
function removeLast() {
if (items.length) items.pop();
}
function readMs(node: Element, prop: string, fallback: number): number {
const raw = getComputedStyle(node).getPropertyValue(prop).trim();
if (!raw) return fallback;
if (raw.endsWith('ms')) return parseFloat(raw);
if (raw.endsWith('s')) return parseFloat(raw) * 1000;
const n = parseFloat(raw);
return Number.isFinite(n) ? n : fallback;
}
function readPx(node: Element, prop: string, fallback: number): number {
const n = parseFloat(getComputedStyle(node).getPropertyValue(prop));
return Number.isFinite(n) ? n : fallback;
}
/**
* Item lifecycle shim — opacity + transform only (items keep their space so the
* cascade is visible). Stagger only during the open/close burst; an individual
* add/remove enters/leaves with delay 0. Values from eidos tokens.
*/
function cascade(
node: Element,
{ index, count, dir }: { index: number; count: number; dir: 'in' | 'out' }
) {
const dur = readMs(node, '--motion-cascade-duration', 240);
const each = readMs(node, '--motion-stagger-each', 55);
const shift = readPx(node, '--proto-cascade-shift', -14);
let delay = 0;
if (dir === 'in' && opening) delay = index * each;
else if (dir === 'out' && closing) delay = (count - 1 - index) * each;
return {
delay,
duration: dur,
easing: dir === 'out' ? cubicIn : cubicOut,
css: (t: number, u: number) => `opacity:${t};transform:translateX(${u * shift}px)`
};
}
/**
* Panel blind — the panel's OWN close firma. CSS grid `1fr → 0fr` collapses the
* height with NO JS measurement. Delayed so the items leave first and the panel
* appears to drag them down.
*/
function blind(node: Element, { delay, duration }: { delay: number; duration: number }) {
// Roll-up: collapse the panel's measured height to 0. `height` IS WAAPI-
// animatable; `grid-template-rows` is NOT (Svelte produced no animation for
// it). Real browsers compute layout, so getBoundingClientRect is reliable.
const h = node.getBoundingClientRect().height;
return {
delay,
duration,
easing: cubicIn,
css: (t: number) => `overflow:hidden;height:${t * h}px`
};
}
</script>
<div class="page">
<h1>Fase 0 — cierre con arrastre (persiana del panel)</h1>
<p class="note">
Al cerrar: los ítems salen en cascada (se ven) y <strong>luego</strong> el panel colapsa como persiana
(<code>grid 1fr→0fr</code>, sin medir en JS), con retardo → el panel los «arrastra». Es la firma de cierre
propia del panel (<code>when: exit: 'after'</code>). Ajusta el retardo:
</p>
<label class="slider">
Retardo de la persiana: <strong>{panelDelay}ms</strong>
<input type="range" min="0" max="700" step="10" bind:value={panelDelay} />
</label>
<div class="controls">
<button class="trigger" onclick={toggle} aria-expanded={open}>
{open ? 'Cerrar' : 'Abrir'} menú
</button>
{#if open}
<button class="add" onclick={addItem}>Añadir ítem</button>
<button class="remove" onclick={removeLast}>Quitar último</button>
{/if}
</div>
{#if open}
<div class="panel" data-proto-panel out:blind={{ delay: panelDelay, duration: panelDur }}>
{#each items as label, i (label)}
<div
class="item"
data-proto-item
data-index={i}
in:cascade|global={{ index: i, count: items.length, dir: 'in' }}
out:cascade|global={{ index: i, count: items.length, dir: 'out' }}
>
{label}
</div>
{/each}
</div>
{/if}
</div>
<style>
.page {
display: flex;
flex-direction: column;
gap: var(--space-4, 1rem);
max-inline-size: 32rem;
padding: var(--space-6, 2rem);
color: var(--color-content-primary, #111);
}
.note {
color: var(--color-content-muted, #666);
font-size: var(--font-size-sm, 0.875rem);
line-height: var(--leading-ui, 1.5);
}
.slider {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
align-items: flex-start;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-content-muted, #666);
}
.controls {
display: flex;
gap: var(--space-2, 0.5rem);
}
button {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border: 1px solid var(--color-border-default, #ccc);
border-radius: var(--radius-md, 0.5rem);
background: var(--color-surface-raised, #fff);
color: inherit;
cursor: pointer;
}
.panel {
--motion-cascade-duration: var(--duration-moderate, 240ms);
--motion-stagger-each: 55ms;
overflow: hidden;
display: flex;
flex-direction: column;
inline-size: 14rem;
padding: var(--space-1, 0.25rem);
border: 1px solid var(--color-border-default, #ccc);
border-radius: var(--radius-md, 0.5rem);
background: var(--color-surface-overlay, #fff);
box-shadow: var(--shadow-overlay, 0 8px 24px rgba(0, 0, 0, 0.12));
}
.item {
--proto-cascade-shift: -14px;
box-sizing: border-box;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border-radius: var(--radius-sm, 0.25rem);
font-size: var(--font-size-sm, 0.875rem);
cursor: pointer;
}
.item:hover {
background: var(--color-primary-element, #eef);
}
</style>

@ -0,0 +1,156 @@
<script lang="ts">
/**
* Fase 1 prototype — panel → cards cascade on the VERIFIED model (RFC §D.9).
*
* Doctrine being demonstrated:
* - SOMA role (here the page) owns ONLY state + LIFECYCLE: it flips `data-state`
* on the container, and it RETAINS a removed card during its exit (Svelte-native
* `out:` — Decision A). It writes NO visual variable.
* - EIDOS owns ALL the visual: the cascade is plain global CSS in `panel-cascade.css`
* (a real recipe ships like that — no Svelte `<style>` scoping/pruning). The per-card
* stagger index is computed FROM STRUCTURE via `:nth-child` / `:nth-last-child`.
* - The cards defer their own emerge (`noEmerge`) so the container coordinates.
*
* The per-card cascade is the `--state` moment (driven by `data-state`), NOT the
* `--event` moment: the panel's own `emerge.open` firma (sema → one `data-event-*`
* stamp on the container + sound) would be a SEPARATE, single-target flourish.
*/
import { Card } from '$uix/eidos/components/card'
import './panel-cascade.css'
// SOMA role: state only. Default open so the enter cascade plays on load.
let open = $state(true)
let count = $state(6)
let rhythm = $state(70)
const dataState = $derived(open ? 'open' : 'closed')
const cards = $derived(Array.from({ length: count }, (_, i) => i))
/**
* SOMA role — LIFECYCLE retention, not visual. When a card is removed, Svelte
* would unmount it instantly (no exit). This `out:` keeps the node in the DOM for
* the duration EIDOS declares for its exit, and marks `data-leaving` so the eidos
* CSS (`[data-leaving]` → card-fall) plays. We read the duration FROM the eidos
* rule — soma never invents a timing or writes a visual var; it only retains.
*/
function leave(node: HTMLElement) {
node.setAttribute('data-leaving', '')
// retain for the FULL exit (its staggered delay + the animation), read from the
// eidos rule — soma never invents a timing, it only retains long enough.
const cs = getComputedStyle(node)
const ms = (parseFloat(cs.animationDelay) + parseFloat(cs.animationDuration)) * 1000 || 200
return { duration: ms }
}
</script>
<div class="page">
<header>
<h1>Panel → cards cascade <span class="tag">Fase 1 · modelo verificado</span></h1>
<p>
El estado y el <strong>lifecycle</strong> los lleva la página (rol de soma): cambia
<code>data-state</code> y RETIENE la card que sale (<code>out:</code>). La cascada es
<strong>CSS puro de eidos</strong> (<code>panel-cascade.css</code>); el índice por card sale
de <code>:nth-child</code> — nadie escribe una <code>--var</code> por card.
</p>
</header>
<div class="controls">
<button class="btn" onclick={() => (open = !open)}>
{open ? 'Cerrar panel' : 'Abrir panel'} ({dataState})
</button>
<label>
cards: {count}
<input type="range" min="1" max="16" bind:value={count} />
</label>
<label>
ritmo: {rhythm}ms
<input type="range" min="20" max="150" step="5" bind:value={rhythm} />
</label>
</div>
<!-- The coordinating container. SOMA sets data-state + retains leaving items; the
rhythm is one eidos token on the container (inherited). NO per-card var. -->
<div data-cascade-panel data-state={dataState} style="--card-stagger-each: {rhythm}ms">
{#each cards as i (i)}
<div data-cascade-item out:leave>
<Card noEmerge variant="soft" size="sm">
<Card.Title>Card {i + 1}</Card.Title>
<Card.Description>row index {i} · delay {i}×{rhythm}ms</Card.Description>
<!-- nested: inner content that cascades AFTER the card, composing the
inherited --row with its own --inner-row (both pure CSS / nth-child) -->
<div data-inner-list>
<div data-inner-item></div>
<div data-inner-item></div>
<div data-inner-item></div>
</div>
</Card>
</div>
{/each}
</div>
</div>
<style>
.page {
max-inline-size: 56rem;
margin-inline: auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
font-family: var(--font-ui, system-ui);
color: var(--color-content-primary, #1a1a1a);
}
h1 {
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.tag {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full, 999px);
background: var(--color-primary-element, #e0e7ff);
color: var(--color-primary-text, #3730a3);
}
p {
color: var(--color-content-muted, #555);
line-height: 1.5;
max-inline-size: 48rem;
}
code {
font-family: var(--font-mono, ui-monospace);
background: var(--color-surface-muted, #f1f1f1);
padding: 0.05rem 0.3rem;
border-radius: 4px;
font-size: 0.85em;
}
.controls {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
padding: 1rem;
border-radius: var(--radius-md, 8px);
background: var(--color-surface-muted, #f6f6f6);
}
.controls label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--color-content-muted, #555);
}
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--color-border-default, #ccc);
background: var(--color-surface-default, #fff);
font: inherit;
cursor: pointer;
}
.btn:hover {
background: var(--color-surface-raised, #fafafa);
}
</style>

@ -0,0 +1,2 @@
// Fase 1 prototype — panel→cards cascade on the verified model. Live, no prerender.
export const prerender = false

@ -0,0 +1,157 @@
/*
* Fase 1 prototype — the cascade, as a real eidos recipe would ship it: PLAIN global
* CSS in a .css file (no Svelte `<style>` scoping/pruning), selecting on data-attrs.
* EIDOS owns 100% of this. The page (soma role) only flips data-state + retains the
* leaving node (Svelte `out:`); nobody writes a per-card variable.
*/
[data-cascade-panel] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border-subtle, #e5e5e5);
border-radius: var(--radius-lg, 12px);
min-block-size: 8rem;
/* rhythm tokens, set once on the container (inherited). The demo overrides the
outer one inline. */
--card-stagger-each: 70ms;
--inner-stagger-each: 45ms;
/* inner content waits for its card to SETTLE, then cascades — so the nesting
reads as a distinct second phase instead of being masked by the card's fade. */
--inner-after: var(--duration-moderate, 240ms);
}
/* the wrapper IS the grid cell + the animated box; the Card fills it */
[data-cascade-item] {
display: flex;
}
[data-cascade-item] > [data-card] {
flex: 1;
}
@keyframes card-rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes card-fall {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(8px);
}
}
/* EIDOS computes the per-card index FROM STRUCTURE — nobody writes it.
nth-child → forward index (enter); nth-last-child → reverse (exit). */
[data-cascade-panel] > [data-cascade-item]:nth-child(1) { --row: 0; }
[data-cascade-panel] > [data-cascade-item]:nth-child(2) { --row: 1; }
[data-cascade-panel] > [data-cascade-item]:nth-child(3) { --row: 2; }
[data-cascade-panel] > [data-cascade-item]:nth-child(4) { --row: 3; }
[data-cascade-panel] > [data-cascade-item]:nth-child(5) { --row: 4; }
[data-cascade-panel] > [data-cascade-item]:nth-child(6) { --row: 5; }
[data-cascade-panel] > [data-cascade-item]:nth-child(7) { --row: 6; }
[data-cascade-panel] > [data-cascade-item]:nth-child(8) { --row: 7; }
[data-cascade-panel] > [data-cascade-item]:nth-child(9) { --row: 8; }
[data-cascade-panel] > [data-cascade-item]:nth-child(10) { --row: 9; }
[data-cascade-panel] > [data-cascade-item]:nth-child(11) { --row: 10; }
[data-cascade-panel] > [data-cascade-item]:nth-child(12) { --row: 11; }
[data-cascade-panel] > [data-cascade-item]:nth-child(13) { --row: 12; }
[data-cascade-panel] > [data-cascade-item]:nth-child(14) { --row: 13; }
[data-cascade-panel] > [data-cascade-item]:nth-child(15) { --row: 14; }
[data-cascade-panel] > [data-cascade-item]:nth-child(16) { --row: 15; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(1) { --row-rev: 0; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(2) { --row-rev: 1; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(3) { --row-rev: 2; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(4) { --row-rev: 3; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(5) { --row-rev: 4; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(6) { --row-rev: 5; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(7) { --row-rev: 6; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(8) { --row-rev: 7; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(9) { --row-rev: 8; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(10) { --row-rev: 9; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(11) { --row-rev: 10; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(12) { --row-rev: 11; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(13) { --row-rev: 12; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(14) { --row-rev: 13; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(15) { --row-rev: 14; }
[data-cascade-panel] > [data-cascade-item]:nth-last-child(16) { --row-rev: 15; }
/* ENTER: container open → each NON-leaving card rises, delayed by its structural index. */
[data-cascade-panel][data-state='open'] > [data-cascade-item]:not([data-leaving]) {
animation: card-rise var(--duration-moderate, 240ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.2, 1))
backwards;
animation-delay: calc(var(--row, 0) * var(--card-stagger-each));
}
/* EXIT (panel close): all cards reverse-cascade out, held forwards. */
[data-cascade-panel][data-state='closed'] > [data-cascade-item] {
animation: card-fall var(--duration-fast, 120ms) var(--ease-in, cubic-bezier(0.4, 0, 1, 1)) forwards;
animation-delay: calc(var(--row-rev, 0) * var(--card-stagger-each));
}
/* EXIT (cards removed, panel still open): each retained item plays card-fall, staggered
by its REVERSE structural index (--row-rev, from nth-last-child). A lone removal → the
last card has --row-rev 0 → delay 0 (immediate); a bulk removal → reverse cascade (the
last card leaves first). No `data-phase` needed — the structure gives both for free.
Soma's `out:` retains each node for delay + duration, then unmounts. */
[data-cascade-panel] > [data-cascade-item][data-leaving] {
animation: card-fall var(--duration-fast, 120ms) var(--ease-in, cubic-bezier(0.4, 0, 1, 1)) forwards;
animation-delay: calc(var(--row-rev, 0) * var(--card-stagger-each));
}
/* ── NESTED cascade: inner content inside each card ──────────────────────────
The nesting test. Each inner item composes TWO structural indices, both pure
CSS:
· `--row` — inherited from its ancestor [data-cascade-item] (the card's
OUTER position in the panel cascade);
· `--inner-row` — its OWN position among siblings (`:nth-child`, scoped to its
own [data-inner-list] parent — nesting is automatic).
delay = card_delay + inner_delay → the inner content enters AFTER its card.
Zero JS writes; inheritance + nth-child do the composition. */
[data-inner-list] {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-block-start: 0.5rem;
}
[data-inner-item] {
block-size: 0.55rem;
border-radius: 3px;
background: var(--color-content-muted, #8a8a8a);
}
[data-inner-list] > [data-inner-item]:nth-child(1) { --inner-row: 0; inline-size: 90%; }
[data-inner-list] > [data-inner-item]:nth-child(2) { --inner-row: 1; inline-size: 60%; }
[data-inner-list] > [data-inner-item]:nth-child(3) { --inner-row: 2; inline-size: 78%; }
@keyframes inner-rise {
from {
opacity: 0;
transform: translateX(-18px);
}
to {
opacity: 1;
transform: none;
}
}
[data-cascade-panel][data-state='open'] [data-inner-item] {
animation: inner-rise var(--duration-moderate, 240ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.2, 1))
backwards;
/* composed delay: card's outer index + the card's settle time + own inner index */
animation-delay: calc(
var(--row, 0) * var(--card-stagger-each) + var(--inner-after) + var(--inner-row, 0) *
var(--inner-stagger-each)
);
}

@ -1,244 +0,0 @@
<script lang="ts">
/**
* PresenceGroup — práctica.
*
* The motion service's presence coordination (RFC: eidos/MOTION_SERVICE_RFC.md
* §7–§9), shown in isolation. Each card is a coordinated list (an owner
* container + its child items) wired through the real soma `PresenceGroup` +
* `Presence`, one per `when` mode. Discovery is by Svelte context (§7.1); the
* visual stagger is pure CSS; soma only sequences WHEN each surface enters /
* exits / unmounts — never the look. Inherits the `ActiveUix + Soma + Eidos`
* scope from `../+layout.svelte`.
*/
import CoordList from './coord-list.svelte';
type Phase = 'before' | 'after' | 'together';
type Mode = { id: string; enter: Phase; exit: Phase; badge: string; desc: string };
const MODES: Mode[] = [
{
id: 'bracket',
enter: 'before',
exit: 'after',
badge: "enter:'before' · exit:'after'",
desc: 'El contenedor BRACKETEA a sus hijos: entra antes que ellos y sale después (reteniendo su DOM hasta que terminan). El caso natural de un contenedor — justo lo que un when simétrico no podía expresar.'
},
{
id: 'exit-heavy',
enter: 'together',
exit: 'after',
badge: "enter:'together' · exit:'after'",
desc: 'Entran a la vez; al cerrar, los hijos salen en stagger y el contenedor aguanta hasta que su salida agrega. La lista exit-heavy de la RFC §13.'
},
{
id: 'together',
enter: 'together',
exit: 'together',
badge: "enter/exit:'together'",
desc: 'Todo en paralelo; el escalonado visual lo pone solo el CSS (animation-delay × índice). soma agrega el finished del árbol.'
}
];
const open = $state<Record<string, boolean>>({
bracket: false,
'exit-heavy': false,
together: false
});
const toggleAll = () => {
const next = !MODES.every((m) => open[m.id]);
for (const m of MODES) open[m.id] = next;
};
// Reversa demo (RFC §8.3): flip the card, then flip it BACK mid-animation (the
// transitions are ~300ms). The group's generation token must keep the surfaces
// mounted through the reversal — re-opening mid-exit must NOT unmount→remount.
const reversa = (id: string) => {
const back = open[id];
open[id] = !back;
setTimeout(() => (open[id] = back), 150);
};
</script>
<svelte:head>
<title>PresenceGroup · práctica</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>PresenceGroup <small>coordinación de presencia</small></h1>
<p class="lede">
La capa de coordinación del servicio de motion, en aislado. Cada tarjeta es una lista
coordinada —un contenedor <strong>owner</strong> y sus ítems <strong>child</strong>— cableada
por el <code>PresenceGroup</code> + <code>Presence</code> reales de soma, uno por modo
<code>when</code>. Los ítems descubren el grupo por <strong>context</strong> (§7.1); el
stagger es CSS puro; soma solo decide <strong>cuándo</strong> entra / sale / se desmonta cada
superficie. Fíjate en <code>after</code>: al cerrar, los ítems salen y el contenedor
<strong>aguanta</strong> hasta que terminan. Y <strong>↻ Reversa</strong> reabre a media
salida (§8.3): el token de generación del grupo no desmonta lo que vuelve —
<strong>sin parpadeo</strong>.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={toggleAll}>Abrir / cerrar todas</button>
<span class="hint"
>Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el
rAF se throttlea).</span
>
</div>
</header>
<div class="grid">
{#each MODES as m (m.id)}
<section class="card">
<div class="card-head">
<code class="badge">{m.badge}</code>
<div class="card-actions">
<button
class="btn ghost"
type="button"
aria-label="Reversa"
title="Reversa: flip + flip back mid-animation (§8.3)"
onclick={() => reversa(m.id)}
>
↻
</button>
<button class="btn" type="button" onclick={() => (open[m.id] = !open[m.id])}>
{open[m.id] ? 'Cerrar' : 'Abrir'}
</button>
</div>
</div>
<p class="desc">{m.desc}</p>
<div class="stage">
<CoordList enter={m.enter} exit={m.exit} open={open[m.id]} count={5} />
</div>
</section>
{/each}
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0;
max-width: 64ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.hint {
font-size: 0.78rem;
color: var(--color-content-muted, #888);
max-width: 42ch;
}
.grid {
max-width: 880px;
margin-inline: auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 16px;
background: var(--color-surface-raised, #f7f7f8);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.card-actions {
display: flex;
gap: 0.4rem;
}
.btn.ghost {
background: transparent;
color: var(--color-content-muted, #666);
font-size: 0.95rem;
line-height: 1;
padding: 0.4rem 0.55rem;
}
.badge {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: 0.78rem;
padding: 0.15em 0.5em;
border-radius: 6px;
background: color-mix(in srgb, var(--color-primary-solid, #4f46e5) 12%, transparent);
color: var(--color-primary-text, var(--color-primary-solid, #4f46e5));
}
.desc {
margin: 0;
font-size: 0.82rem;
line-height: 1.5;
color: var(--color-content-secondary, #555);
}
.stage {
min-height: 220px;
display: flex;
align-items: flex-start;
padding: 0.75rem;
border-radius: 12px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-default, #fff);
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.primary {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
border-color: transparent;
}
.btn:hover {
border-color: var(--color-primary-solid, #4f46e5);
}
</style>

@ -1,62 +0,0 @@
<script lang="ts">
/**
* One coordinated child surface. It DISCOVERS its group via Svelte context
* (RFC §7.1) — the parent `CoordList` published it; this item never receives
* the group explicitly. Pure CSS handles the visual (stagger via --i); the
* group only decides WHEN this surface is released / unmounted.
*/
import { Presence } from '$soma/layers/presence.svelte'
import { PresenceGroup } from '$soma/layers/presence-group'
import { readableActive } from '$libs/reactive'
import { ActiveEidos } from '$uix/eidos'
let { open, index }: { open: boolean; index: number } = $props()
const eidos = ActiveEidos.require()
const group = PresenceGroup.get()
let el = $state<HTMLElement | null>(null)
const presence = new Presence({
dom: eidos.dom,
open: readableActive(() => open),
ref: readableActive(() => el),
group,
groupRole: 'child'
})
</script>
{#if presence.isPresent}
<div class="coord-item" bind:this={el} style="--i: {index}" {...presence.transitionAttrs}>
ítem {index + 1}
</div>
{/if}
<style>
.coord-item {
padding: 0.5rem 0.85rem;
border-radius: 8px;
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
font-size: 0.85rem;
font-weight: 600;
opacity: 1;
transform: translateY(0);
transition:
opacity 0.4s ease,
transform 0.4s ease;
transition-delay: calc(var(--i) * 0.08s);
}
.coord-item[data-starting-style] {
opacity: 0;
transform: translateY(12px);
}
.coord-item[data-ending-style] {
animation: coord-item-out 0.4s ease forwards;
animation-delay: calc(var(--i) * 0.1s);
}
@keyframes coord-item-out {
to {
opacity: 0;
transform: translateY(12px);
}
}
</style>

@ -1,75 +0,0 @@
<script lang="ts">
/**
* The owner surface of a coordinated list. It creates the `PresenceGroup`
* (with the `when` that a real morfo would declare) and publishes it in
* context, then renders its child items — which discover the group via
* context (RFC §7.1) and register. The group sequences enter/exit per `when`
* and, on exit, RETAINS this container's DOM until the items have left (§8.1).
*/
import { untrack } from 'svelte';
import { Presence } from '$soma/layers/presence.svelte';
import { PresenceGroup, type PresenceWhen } from '$soma/layers/presence-group';
import { readableActive } from '$libs/reactive';
import { ActiveEidos } from '$uix/eidos';
import CoordItem from './coord-item.svelte';
let {
enter,
exit,
open,
count = 5
}: { enter: PresenceWhen; exit: PresenceWhen; open: boolean; count?: number } = $props();
const eidos = ActiveEidos.require();
// `enter`/`exit` are fixed per instance (a real morfo declares them once); read
// them untracked so creating the group doesn't pretend to react to a changing prop.
const group = untrack(() => PresenceGroup.create({ when: { enter, exit }, dom: eidos.dom }));
let containerEl = $state<HTMLElement | null>(null);
const container = new Presence({
dom: eidos.dom,
open: readableActive(() => open),
ref: readableActive(() => containerEl),
group,
groupRole: 'owner'
});
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#if container.isPresent}
<div class="coord-container" bind:this={containerEl} {...container.transitionAttrs}>
{#each items as i (i)}
<CoordItem {open} index={i} />
{/each}
</div>
{/if}
<style>
.coord-container {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
opacity: 1;
transform: scale(1);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.coord-container[data-starting-style] {
opacity: 0;
transform: scale(0.97);
}
.coord-container[data-ending-style] {
animation: coord-container-out 0.35s ease forwards;
}
@keyframes coord-container-out {
to {
opacity: 0;
transform: scale(0.96);
}
}
</style>

@ -1,252 +0,0 @@
<script lang="ts">
/**
* Rail — the SECOND consumer of the coordinated-motion helper
* (`soma/layers/coordination.ts`). Same coordination as `Reveal`, DIFFERENT shape:
* the root IS the owner surface, the relation is `together` (all parallel, CSS
* stagger), it is PARENT-CONTROLLED via `open` (no trigger), and has NO sema events.
* Proves the helper is reusable — building this was a handful of lines.
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import * as Rail from '$soma/components/rail';
const ITEMS = ['Inicio', 'Buscar', 'Mensajes', 'Perfil', 'Ajustes'];
const STYLES = [
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' }
];
let open = $state(false);
let animStyle = $state('cascade-slide');
let duration = $state(300); // ms — per-surface transition speed (exposed as a CSS var)
let stagger = $state(45); // ms — the rhythm between items (`--motion-stagger-each`)
</script>
<svelte:head>
<title>Rail · 2º consumidor coordinado</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>Rail <small>2º consumidor del helper coordinado</small></h1>
<p class="lede">
Misma coordinación que <a href="/temas/animations/reveal">Reveal</a>, otra forma: la raíz
<strong>ES</strong> la superficie owner, la relación es <code>together</code> (todo en
paralelo, el escalonado lo pone el CSS), es <strong>controlada por el padre</strong>
(<code>bind:open</code>, sin trigger) y <strong>no tiene eventos sema</strong>. Todo el wiring
—PresenceGroup + routing + auto-stagger— sale del helper compartido
<code>soma/layers/coordination.ts</code>; construir este componente fueron cuatro líneas.
</p>
<div class="actions">
<button class="btn primary" type="button" onclick={() => (open = !open)}>
{open ? 'Ocultar' : 'Mostrar'} rail
</button>
<div class="picker" role="group" aria-label="Estilo de animación">
{#each STYLES as s (s.value)}
<button
class="chip-btn"
class:on={animStyle === s.value}
type="button"
onclick={() => (animStyle = s.value)}
>
{s.label}
</button>
{/each}
</div>
</div>
<div class="sliders">
<label>
duración
<input type="range" min="80" max="700" step="20" bind:value={duration} />
<span class="val">{duration}ms</span>
</label>
<label>
stagger
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
<span class="val">{stagger}ms</span>
</label>
</div>
</header>
<div
class="stage"
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
>
<Rail.Provider bind:open animation={animStyle}>
{#each ITEMS as label (label)}
<Rail.Item class="chip">{label}</Rail.Item>
{/each}
</Rail.Provider>
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0;
max-width: 64ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede a {
color: var(--color-primary-solid, #4f46e5);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.primary {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
border-color: transparent;
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip-btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip-btn.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 1.5rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 140px;
display: flex;
align-items: center;
}
.sliders {
display: flex;
gap: 1.5rem;
margin-block-start: 1rem;
flex-wrap: wrap;
}
.sliders label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--color-content-secondary, #555);
}
.sliders input[type='range'] {
accent-color: var(--color-primary-solid, #4f46e5);
}
.sliders .val {
min-width: 3.5ch;
font-variant-numeric: tabular-nums;
color: var(--color-content-muted, #888);
}
/* The Rail surfaces — global (rendered by the soma component). HORIZONTAL row;
soma stamps data-starting/ending-style, the CSS only reacts. */
:global([data-rail]) {
display: flex;
flex-direction: row;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease;
}
:global([data-rail-item]) {
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: var(--color-surface-default, #fff);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
font-size: 0.85rem;
white-space: nowrap;
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease;
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
/* Close = open reversed (last chip leaves first). */
:global([data-rail-item][data-ending-style]) {
transition-delay: calc(
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
var(--motion-stagger-each, 0ms)
);
}
/* `cascade-*` (non-eidos-preset names) — react to the Presence attrs, not data-state. */
:global([data-animation-style='cascade-slide'][data-starting-style]),
:global([data-animation-style='cascade-slide'][data-ending-style]) {
opacity: 0;
transform: translateX(-12px);
}
:global([data-animation-style='cascade-fade'][data-starting-style]),
:global([data-animation-style='cascade-fade'][data-ending-style]) {
opacity: 0;
}
:global([data-animation-style='cascade-scale'][data-starting-style]),
:global([data-animation-style='cascade-scale'][data-ending-style]) {
opacity: 0;
transform: scale(0.85);
}
</style>

@ -1,343 +0,0 @@
<script lang="ts">
/**
* Reveal — the FIRST REAL consumer of the motion-coordination contract.
*
* Unlike the synthetic `/presence-group` demo (which hand-constructs `Presence`
* + `PresenceGroup`), this drives the REAL soma `<Reveal>` component: its morfo
* declares `panel.animation.children = { enter: 'before', exit: 'after' }`, and
* the provider reads that COMPILED contract to build the group (RFC §4). The
* panel enters, then the items cascade in; on close the items leave first while
* the panel RETAINS its DOM (exit-heavy §8.1), then the panel goes. The visual
* stagger is pure CSS here (transition-delay × index); soma only sequences WHEN.
*
* M5 (RFC §5): the `animation` prop is named ONCE on the root and routed to the
* declared surfaces (Panel + Items) as `data-animation-style` — "el sistema sabe
* dónde". The picker changes it live; the CSS below reacts to the attr.
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
*/
import * as Reveal from '$soma/components/reveal';
const ITEMS = ['Perfil', 'Ajustes', 'Notificaciones', 'Equipo', 'Facturación', 'Salir'];
// Coordinated-surface styles — deliberately NOT the eidos built-in preset names
// (`fade`, `slide-fade`, `scale-fade`…). Those presets generate
// `[data-animation-style][data-state]` CSS that fires on MOUNT (keyframe animation),
// which collides with and fights the coordinated cascade — the §5 finding. These
// `cascade-*` names route to NO eidos preset, so only this page's own
// data-starting-style-gated transitions apply (the correct gating for coordination).
const STYLES = [
{ value: 'cascade-slide', label: 'slide' },
{ value: 'cascade-fade', label: 'fade' },
{ value: 'cascade-scale', label: 'scale' },
{ value: 'cascade-drop', label: 'drop' }, // pure transform — 1 CSS rule, nothing else
{ value: 'cascade-blur', label: 'blur' } // adds `filter` → also extend the base transition
];
let open = $state(false);
let animStyle = $state('cascade-slide');
let duration = $state(300); // ms — the per-surface transition speed (exposed as a CSS var)
let stagger = $state(55); // ms — the rhythm between items (`--motion-stagger-each`)
// Reversa (RFC §8.3): re-open mid-exit — the group's generation token keeps the
// surfaces mounted (no unmount→remount flash). Flip, then flip back mid-animation.
const reversa = () => {
const back = open;
open = !back;
setTimeout(() => (open = back), 150);
};
</script>
<svelte:head>
<title>Reveal · primer consumidor real</title>
</svelte:head>
<div class="root">
<header>
<a class="back" href="/temas/animations">← Motion</a>
<h1>Reveal <small>primer consumidor real</small></h1>
<p class="lede">
A diferencia del demo <a href="/temas/animations/presence-group">presence-group</a> —que
construye <code>Presence</code> + <code>PresenceGroup</code> a mano— esto usa el componente
<strong>real</strong> <code>&lt;Reveal&gt;</code>: su <strong>morfo</strong> declara
<code>panel.animation.children = &#123; enter: 'before', exit: 'after' &#125;</code>, y el
<strong>provider lee ese contrato compilado</strong> para construir el grupo (RFC §4). El
panel entra, luego los ítems <strong>entran en cascada</strong>; al cerrar los ítems salen
primero mientras el panel <strong>retiene su DOM</strong> (exit-heavy §8.1), y solo después se
va el panel. El escalonado es CSS puro; soma solo decide el <strong>cuándo</strong>. El
selector cambia el prop <code>animation</code>, que el componente
<strong>enruta a las superficies declaradas</strong>
(Panel + Items) como <code>data-animation-style</code> — se nombra una vez, el sistema sabe dónde
(RFC §5).
</p>
<p class="lede">
<strong>Modo (c) — compone sema + motion</strong> (RFC Apéndice B): al <strong>abrir y al
cerrar</strong>, los eventos declaran <code>channels: ['sound']</code> y suena un tono
<code>emerge</code> <strong>a la vez</strong> que corre la cascada. No pelean porque
<strong>eidos neutraliza su propia firma visual</strong> sobre las superficies coordinadas
(<code>animation: none</code> donde hay <code>data-animation-style</code> +
<code>data-event-phase</code>); la cascada es <code>transition</code> y sobrevive. El opt-out vive
en <strong>motion</strong>, sema intacto. (Sube el volumen; el audio arranca con el primer click.)
</p>
<div class="actions">
<div class="picker" role="group" aria-label="Estilo de animación">
{#each STYLES as s (s.value)}
<button
class="chip"
class:on={animStyle === s.value}
type="button"
onclick={() => (animStyle = s.value)}
>
{s.label}
</button>
{/each}
</div>
<button class="btn ghost" type="button" onclick={reversa}>↻ Reversa</button>
<span class="hint"
>Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el
rAF se throttlea).</span
>
</div>
<div class="sliders">
<label>
duración
<input type="range" min="80" max="700" step="20" bind:value={duration} />
<span class="val">{duration}ms</span>
</label>
<label>
stagger
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
<span class="val">{stagger}ms</span>
</label>
</div>
</header>
<!-- The slider-controlled vars live on the stage and INHERIT to the surfaces:
`--cascade-duration` (the transition speed) + `--motion-stagger-each` (rhythm)
+ `--motion-stagger-count` (for the reversed exit). Each item still auto-derives
its own `--motion-stagger-index` (M6). -->
<div
class="stage"
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
>
<Reveal.Provider bind:open animation={animStyle}>
<Reveal.Trigger>
{open ? '▾' : '▸'} Menú de cuenta
</Reveal.Trigger>
<Reveal.Panel class="panel">
{#each ITEMS as label (label)}
<Reveal.Item class="item">{label}</Reveal.Item>
{/each}
</Reveal.Panel>
</Reveal.Provider>
</div>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
padding: 2.5rem 1.5rem 4rem;
}
header {
max-width: 880px;
margin-inline: auto;
margin-block-end: 2rem;
}
.back {
font-size: 0.85rem;
color: var(--color-content-muted, #666);
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0.5rem 0 0.75rem;
font-size: 1.9rem;
font-weight: 700;
}
h1 small {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-content-muted, #666);
}
.lede {
margin: 0;
max-width: 64ch;
line-height: 1.6;
color: var(--color-content-secondary, #444);
}
.lede a {
color: var(--color-primary-solid, #4f46e5);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-block-start: 1.25rem;
flex-wrap: wrap;
}
.hint {
font-size: 0.78rem;
color: var(--color-content-muted, #888);
max-width: 42ch;
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
}
.btn.ghost {
background: transparent;
color: var(--color-content-muted, #666);
}
.picker {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.chip {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-content-secondary, #555);
}
.chip.on {
background: var(--color-primary-solid, #4f46e5);
color: var(--color-primary-contrast, #fff);
}
.sliders {
display: flex;
gap: 1.5rem;
margin-block-start: 1rem;
flex-wrap: wrap;
}
.sliders label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--color-content-secondary, #555);
}
.sliders input[type='range'] {
accent-color: var(--color-primary-solid, #4f46e5);
}
.sliders .val {
min-width: 3.5ch;
font-variant-numeric: tabular-nums;
color: var(--color-content-muted, #888);
}
.stage {
max-width: 880px;
margin-inline: auto;
padding: 1.5rem;
border-radius: 16px;
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
background: var(--color-surface-raised, #f7f7f8);
min-height: 360px;
}
/* The Reveal surfaces — global because the elements are rendered by the soma
component, not this page's own markup. soma stamps data-starting-style /
data-ending-style; the CSS only reacts (it never decides WHEN). */
:global([data-reveal-trigger]) {
appearance: none;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 0.5rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.18));
background: var(--color-surface-default, #fff);
color: var(--color-content-primary, #111);
margin-block-end: 0.6rem;
}
:global([data-reveal-panel]) {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: min(280px, 100%);
padding: 0.5rem;
border-radius: 12px;
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease,
filter var(--cascade-duration, 0.3s) ease;
}
:global([data-reveal-item]) {
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--color-surface-default, #fff);
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
font-size: 0.9rem;
transition:
opacity var(--cascade-duration, 0.3s) ease,
transform var(--cascade-duration, 0.3s) ease,
filter var(--cascade-duration, 0.3s) ease;
/* Canonical eidos stagger tokens (M6): index (per item, auto-derived from the
group order) × each (the rhythm, inherited from the panel). Enter counts UP. */
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
}
/* The CLOSE is the OPEN played in reverse: the exit stagger counts DOWN
(count − 1 − index), so the last item leaves first and the choreography mirrors
the entrance — the inverse the user expects. */
:global([data-reveal-item][data-ending-style]) {
transition-delay: calc(
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
var(--motion-stagger-each, 0ms)
);
}
/* The DX `animation` prop → `data-animation-style` on the surfaces drives the
LOOK; the Presence's data-starting-style / data-ending-style gate WHEN (so the
coordinated cascade is preserved — unlike the built-in data-state presets,
which would fire on mount and break the sequencing). */
:global([data-animation-style='cascade-slide'][data-starting-style]),
:global([data-animation-style='cascade-slide'][data-ending-style]) {
opacity: 0;
transform: translateX(-16px);
}
:global([data-animation-style='cascade-fade'][data-starting-style]),
:global([data-animation-style='cascade-fade'][data-ending-style]) {
opacity: 0;
}
:global([data-animation-style='cascade-scale'][data-starting-style]),
:global([data-animation-style='cascade-scale'][data-ending-style]) {
opacity: 0;
transform: scale(0.9);
}
/* `drop` — vertical, distinct from slide's horizontal. Pure transform: nothing
else to wire (stagger + reverse-on-exit are already generic). */
:global([data-animation-style='cascade-drop'][data-starting-style]),
:global([data-animation-style='cascade-drop'][data-ending-style]) {
opacity: 0;
transform: translateY(-14px);
}
/* `blur` — uses `filter`, a property beyond opacity/transform, so it ALSO needs
`filter` in the base transition above (panel + item). */
:global([data-animation-style='cascade-blur'][data-starting-style]),
:global([data-animation-style='cascade-blur'][data-ending-style]) {
opacity: 0;
transform: scale(1.03);
filter: blur(6px);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.