feat(motion): M5 naming type-safe + reversa fluida JS + demo de presets + docs de convivencia

Continuación del servicio de motion-coordination (sobre M1–M6 ya commiteado).

- M5 — naming `animation` / `motion` SEPARADAS por rol (no unificar: tras M6
  son sistemas distintos — `motion`=momento `--state`/`data-state`/eidos;
  `animation`=coordinado/`data-starting-ending-style`/soma). `animation` gana
  type-safety vía el registry augmentable `MotionCoordinatedPresets` +
  `CoordinatedPresetName` en `$motion` (la capa compartida, para que soma lo
  tipe sin importar eidos); eidos lo puebla con `cascade-slide/-fade/-scale`
  por declaration-merging — espejo de `EidosMotionPresets`.

- Reversa fluida — JS handoff (§8.3). El motor expone `MotionHandle.peek()`
  (posición + velocidad por propiedad) y la reinyecta en la fase inversa vía
  `MotionContext.handoff`: el `spring` continúa desde la posición/velocidad
  actual en vez de reiniciar desde el `from`. Orquestado enteramente en el
  engine (`takeHandoff` — captura en `cancel`, consume en `run`, y cancela un
  run en vuelo cuando el caller no pre-cancela): cubre el camino island
  (overlay-spring) y elimina un apilamiento preexistente. La reversa de los
  coordinados (transiciones CSS) ya era fluida.

- Demo `/temas/animations/compuesto` — un `Rail` de `<Button>`s que usa los
  presets coordinados PREDEFINIDOS de eidos (cero CSS de animación en la
  página); el selector cambia `animation` en vivo y cada Button compone su
  press (firma sema) con la cascada (motion coordinado).

- Docs — RFC Apéndice B (convivencia de los tres sistemas visuales: firma
  `--event` / state-preset `--state` / coordinado, con diagrama + ejemplo
  Button-con-tokens), §5 reescrito (separadas por rol), §8.3/§15 al día, y
  cross-link del tercer eje desde eidos-motion.md.

Tests: arts/motion 9/9 (handoff) + eidos/motion 24/24 (M5 paridad).
svelte-check: 0 errores en mis archivos.

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

@ -14,7 +14,7 @@
* - `rect` is FLIP: measures first/last rects and animates the inverse delta.
*/
import type { MotionHandle, MotionRun } from './types'
import type { MotionHandle, MotionRun, MotionState } from './types'
export interface SpringPhysics {
/** Higher = snappier pull toward the target. @default 170 */
@ -53,13 +53,19 @@ export function spring(config: SpringConfig): MotionRun {
const restSpeed = config.restSpeed ?? 0.01
return (ctx): MotionHandle => {
const tracks = Object.entries(config.values).map(([prop, [from, to]]) => ({
prop,
to,
x: from,
v: 0,
unit: config.unit?.[prop] ?? ''
}))
// A reversal hands off the prior run's physics state (§8.3): start each
// property from `ctx.handoff[prop]` (current position + velocity) instead of
// the declared `from`, so the reversed spring continues from where it was.
const tracks = Object.entries(config.values).map(([prop, [from, to]]) => {
const h = ctx.handoff?.[prop]
return {
prop,
to,
x: h?.x ?? from,
v: h?.v ?? 0,
unit: config.unit?.[prop] ?? ''
}
})
let resolve!: () => void
const finished = new Promise<void>((r) => (resolve = r))
@ -105,11 +111,16 @@ export function spring(config: SpringConfig): MotionRun {
frame = ctx.dom.requestFrame(step, ctx.el)
}
tracks.forEach(write) // initial paint at `from`
tracks.forEach(write) // initial paint at `from` (or the handoff position)
ctx.signal.addEventListener('abort', settle, { once: true })
frame = ctx.dom.requestFrame(step, ctx.el)
return { finished, cancel: settle }
// `peek` snapshots the live physics so the engine can hand it to a reversal
// run (§8.3) — the per-property current position + velocity.
const peek = (): MotionState =>
Object.fromEntries(tracks.map((t) => [t.prop, { x: t.x, v: t.v }]))
return { finished, cancel: settle, peek }
}
}

@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createEngineMotion } from './engine-motion';
import type { MotionDom, MotionHandle, StatePreset } from './types';
import { spring } from './drivers';
import type { MotionContext, MotionDom, MotionHandle, MotionState, StatePreset } from './types';
// Minimal MotionDom. The JS presets below return a MotionHandle directly, so the
// frame scheduler is never exercised; `dom` only needs to be non-null so `runJs`
@ -77,3 +78,115 @@ describe('EngineMotion — JS handle tracking + cancel semantics', () => {
expect(cancelled).toBe(true);
});
});
describe('EngineMotion — velocity-preserving handoff on reversal (§8.3)', () => {
it('captures a cancelled run physics and injects it into the next run as ctx.handoff', () => {
let captured: MotionState | undefined;
const preset: StatePreset = {
driver: 'spring',
enter: (): MotionHandle => ({
finished: new Promise<void>(() => {}),
cancel() {},
peek: () => ({ '--ty': { x: 5, v: -120 } })
}),
exit: (ctx): MotionHandle => {
captured = ctx.handoff;
return { finished: Promise.resolve(), cancel() {} };
}
};
const motion = createEngineMotion({ dom, presets: { p: preset } });
const node = el('p');
motion.run(node, 'enter'); // enter in flight (peek = {x:5, v:-120})
motion.cancel(node); // captures the peek → handoff
motion.run(node, 'exit'); // consumes it as ctx.handoff
expect(captured).toEqual({ '--ty': { x: 5, v: -120 } });
});
it('records no handoff when the cancelled driver exposes no peek (WAAPI/rect)', () => {
let captured: MotionState | undefined = { sentinel: { x: 1, v: 1 } };
const preset: StatePreset = {
driver: 'waapi',
enter: (): MotionHandle => ({ finished: new Promise<void>(() => {}), cancel() {} }),
exit: (ctx): MotionHandle => {
captured = ctx.handoff;
return { finished: Promise.resolve(), cancel() {} };
}
};
const motion = createEngineMotion({ dom, presets: { p: preset } });
const node = el('p');
motion.run(node, 'enter');
motion.cancel(node);
motion.run(node, 'exit');
expect(captured).toBeUndefined();
});
it('hands off from an in-flight run even without an explicit cancel (island path)', () => {
let captured: MotionState | undefined;
let enterCancelled = false;
const preset: StatePreset = {
driver: 'spring',
enter: (): MotionHandle => ({
finished: new Promise<void>(() => {}),
cancel() {
enterCancelled = true;
},
peek: () => ({ '--ty': { x: 9, v: 200 } })
}),
exit: (ctx): MotionHandle => {
captured = ctx.handoff;
return { finished: Promise.resolve(), cancel() {} };
}
};
const motion = createEngineMotion({ dom, presets: { p: preset } });
const node = el('p');
motion.run(node, 'enter'); // enter in flight, NO explicit cancel
motion.run(node, 'exit'); // run must cancel the enter AND hand off its state
expect(enterCancelled).toBe(true);
expect(captured).toEqual({ '--ty': { x: 9, v: 200 } });
});
});
describe('spring driver — handoff + peek (§8.3)', () => {
const makeDom = () => {
const frames: FrameRequestCallback[] = [];
const fdom: MotionDom = {
requestFrame: (cb) => (frames.push(cb), frames.length),
cancelFrame: () => {},
prefersReducedMotion: { matches: false }
};
return { fdom, tick: () => frames.shift()?.(0) };
};
const makeCtx = (fdom: MotionDom, handoff?: MotionState): MotionContext =>
({
el: { style: { setProperty() {} } },
dom: fdom,
signal: new AbortController().signal,
handoff
}) as unknown as MotionContext;
it('starts from the handoff position/velocity instead of the declared from', () => {
const { fdom } = makeDom();
const handle = spring({ values: { '--ty': [0, 100] }, unit: { '--ty': 'px' } })(
makeCtx(fdom, { '--ty': { x: 7, v: 50 } })
) as MotionHandle;
expect(handle.peek?.()).toEqual({ '--ty': { x: 7, v: 50 } });
});
it('without a handoff, starts from the declared from with zero velocity', () => {
const { fdom } = makeDom();
const handle = spring({ values: { '--ty': [3, 100] } })(makeCtx(fdom)) as MotionHandle;
expect(handle.peek?.()).toEqual({ '--ty': { x: 3, v: 0 } });
});
it('peek reflects the live state as the spring steps toward the target', () => {
const { fdom, tick } = makeDom();
const handle = spring({ values: { '--ty': [0, 100] } })(
makeCtx(fdom, { '--ty': { x: 0, v: 0 } })
) as MotionHandle;
tick(); // one integration step
const s = handle.peek?.()?.['--ty'];
expect(s?.x).toBeGreaterThan(0); // moved toward 100
expect(s?.v).toBeGreaterThan(0); // gained velocity
});
});

@ -20,6 +20,7 @@ import type {
MotionDom,
MotionHandle,
MotionSide,
MotionState,
StatePreset
} from './types'
import { isCssStatePreset } from './types'
@ -84,6 +85,11 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
// Active JS-driven handles, per element. A plain Map (not WeakMap) so dispose
// can iterate; emptied sets are deleted so it doesn't leak detached elements.
const active = new Map<HTMLElement, Set<MotionHandle>>()
// Physics state captured when a tracked run is cancelled, keyed by element, for a
// velocity-preserving handoff on reversal (§8.3): `cancel(el)` snapshots it, the
// NEXT `run(el, …)` consumes it as `ctx.handoff`. Only set when a cancel finds an
// in-flight handle that exposes `peek()` (i.e. a real interruption).
const handoffState = new Map<HTMLElement, MotionState>()
let disposed = false
function track(el: HTMLElement, handle: MotionHandle): void {
@ -98,6 +104,22 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
})
}
// Take the physics state for a reversal handoff (§8.3): merge any state a preceding
// explicit `cancel(el)` recorded (the grouped/reversa path) with the live state of a
// run STILL in flight on this element (the island path — e.g. Dialog spring — which
// does not pre-cancel). Cancelling that in-flight run here also stops a second
// animation from stacking on the node.
function takeHandoff(el: HTMLElement): MotionState | undefined {
const pending = handoffState.get(el)
handoffState.delete(el)
const set = active.get(el)
if (!set || set.size === 0) return pending
const captured = captureHandoff(set)
for (const handle of [...set]) handle.cancel()
active.delete(el)
return captured ? { ...pending, ...captured } : pending
}
function runJs(
el: HTMLElement,
preset: JsStatePreset,
@ -116,6 +138,11 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
if (!motionRun || !dom) return SETTLED_HANDLE
const controller = new AbortController()
// Velocity-preserving handoff (§8.3): take any physics state to continue from —
// from a preceding explicit cancel() (the grouped/reversa path) AND/OR from a run
// still in flight on this element (the island path, which does not pre-cancel).
// Cancelling the in-flight run here also prevents a second animation stacking.
const handoff = takeHandoff(el)
const ctx: MotionContext = {
moment: 'state',
phase,
@ -127,6 +154,7 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
side: opts?.side,
sourceRect: opts?.sourceRect,
targetRect: opts?.targetRect,
handoff,
signal: controller.signal
}
const handle = toHandle(motionRun(ctx), controller)
@ -173,6 +201,11 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
cancel(el) {
const set = active.get(el)
if (!set) return
// Snapshot the in-flight physics BEFORE cancelling, so the next run on this
// element can continue from the current position/velocity (§8.3). Only
// physics drivers expose `peek()`; if none do, no handoff is recorded.
const captured = captureHandoff(set)
if (captured) handoffState.set(el, captured)
for (const handle of [...set]) handle.cancel()
active.delete(el)
},
@ -188,11 +221,29 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
for (const handle of [...set]) handle.cancel()
}
active.clear()
handoffState.clear()
presets.clear()
}
}
}
/**
* Merge the live physics (`peek()`) of every handle that exposes it, for a
* velocity-preserving handoff on reversal (§8.3). Returns `undefined` when no
* tracked handle is a physics driver (WAAPI / rect expose no state) — the engine
* then lets the next run restart cleanly from the preset's declared `from`.
*/
function captureHandoff(set: Set<MotionHandle>): MotionState | undefined {
let merged: Record<string, { x: number; v: number }> | undefined
for (const handle of set) {
const state = handle.peek?.()
if (!state) continue
merged ??= {}
Object.assign(merged, state)
}
return merged
}
/**
* Normalise a `MotionRun` result to a single `MotionHandle`. An `Animation`
* (or `Animation[]`) and a `MotionHandle` both expose `finished` + `cancel`;
@ -222,7 +273,11 @@ function toHandle(
}
}
}
const single = result as { finished: Promise<unknown>; cancel: () => void }
const single = result as {
finished: Promise<unknown>
cancel: () => void
peek?: () => MotionState | undefined
}
return {
finished: Promise.resolve(single.finished).then(
() => {},
@ -231,7 +286,10 @@ function toHandle(
cancel() {
controller.abort()
safeCancel(single)
}
},
// Forward the driver's `peek` (only `spring` has one) so the engine can capture
// its state on cancel for a reversal handoff (§8.3).
peek: single.peek ? () => single.peek!() : undefined
}
}

@ -10,6 +10,7 @@ export type { SpringConfig, SpringPhysics } from './drivers'
export { isCssStatePreset } from './types'
export type {
CoordinatedPreset,
CoordinatedPresetName,
CssPhase,
CssStatePreset,
EventSignature,
@ -18,12 +19,14 @@ export type {
KeyframeName,
KeyframeStops,
MotionConfig,
MotionCoordinatedPresets,
MotionContext,
MotionDom,
MotionHandle,
MotionPresetName,
MotionRun,
MotionSide,
MotionState,
ReducePolicy,
StatePreset
} from './types'

@ -97,6 +97,14 @@ export interface CssStatePreset {
readonly reduce?: ReducePolicy
}
/**
* Per-property physics state captured from a running driver, for a velocity-
* preserving handoff on reversal (RFC: eidos/MOTION_SERVICE_RFC.md §8.3). `x` is
* the current interpolated position, `v` the current velocity. Only physics
* drivers (`spring`) produce it; WAAPI / rect do not (they expose no velocity).
*/
export type MotionState = Readonly<Record<string, { readonly x: number; readonly v: number }>>
export interface MotionContext {
readonly moment: 'event' | 'state'
readonly phase: 'enter' | 'exit'
@ -108,12 +116,27 @@ export interface MotionContext {
readonly side?: MotionSide
readonly sourceRect?: DOMRect
readonly targetRect?: DOMRect
/**
* Physics state handed off from a just-cancelled run on the SAME element (a
* reversal — §8.3). A driver that supports it starts each property from
* `handoff[prop]` (position + velocity) instead of its declared `from`, so the
* reversal continues from the current position/velocity rather than snapping.
* The engine captures it (via the cancelled handle's `peek()`) and injects it
* into the next run on that element.
*/
readonly handoff?: MotionState
readonly signal: AbortSignal
}
export interface MotionHandle {
readonly finished: Promise<void>
cancel(): void
/**
* Snapshot the driver's current physics state for a velocity-preserving handoff
* (§8.3). Optional: only `spring` implements it; WAAPI / rect omit it (the engine
* then falls back to a clean restart from the preset's declared `from`).
*/
peek?(): MotionState | undefined
}
export type MotionRun = (ctx: MotionContext) => Animation | readonly Animation[] | MotionHandle
@ -151,6 +174,35 @@ export interface CoordinatedPreset {
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 +

@ -47,6 +47,7 @@
- [14. Impacto sobre el código actual + migración](#14-impacto-sobre-el-código-actual--migración)
- [15. Roadmap de implementación por fases](#15-roadmap-de-implementación-por-fases)
- [Apéndice A — decisiones resueltas vs abiertas](#apéndice-a--decisiones-resueltas-vs-abiertas)
- [Apéndice B — Convivencia con la firma sema (los tres sistemas visuales)](#apéndice-b--convivencia-con-la-firma-sema-los-tres-sistemas-visuales)
---
@ -211,19 +212,26 @@ Esto convierte un variant en **separable y reutilizable**: `scale-fade` no perte
Dialog; pertenece al catálogo y se aplica a cualquier superficie. Es el momento-estado de
`eidos-motion.md`, elevado de "prop de un wrapper concreto" a "prop transversal enrutado".
**Decisión-prerrequisito (no una nota): `animation` ↔ `motion`/`data-animation-style`.**
Tener dos props que nombran animación es deuda cara. La ruta recomendada es **estratificar,
no duplicar**:
| Capa | Identificador | Rol |
| ---------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| **Contrato de bajo nivel (motor)** | `data-animation-style` (atributo) | lo que `EngineMotion.run` lee del nodo; **se conserva** |
| **DX de alto nivel (autor)** | `animation` (prop transversal) | azúcar enrutado: el wrapper lo resuelve a la superficie declarada y emite `data-animation-style` allí |
Así `data-animation-style` sigue siendo el contrato estable (cero ruptura para eidos/motor) y
`animation` es la cara ergonómica. La prop `motion` actual de los wrappers de eidos queda como
**alias** de `animation` (o se deprecia en una fase posterior; decisión del roadmap §15), sin
romper consumidores.
**Decisión M5 (TOMADA): `animation` y `motion` quedan SEPARADAS por rol.**
La versión original de este RFC recomendaba aquí _unificar_ (`animation` como cara DX y `motion`
como alias deprecable), asumiendo que ambas nombraban el MISMO eje (el momento-estado elevado a
transversal). **El hallazgo M6 lo invalidó** (el «hallazgo gemelo» de más abajo + Apéndice B):
`motion` y `animation` gobiernan sistemas visuales **distintos** — un state-preset (`data-state`,
dispara al MONTAR) metido en un coordinado ROMPE la cascada. Es **distinción, no duplicación**; y
lo "caro" era la duplicación, no la distinción. Se mantienen separadas porque el **nombre de la
prop comunica el sistema** a nivel de propiedad, sin leer docs:
| Prop | Sistema | Reacciona a | Capa dueña | Catálogo type-safe |
| --------------- | ----------------- | ------------------------------ | ---------- | ---------------------------------------- |
| **`motion`** | momento `--state` | `[data-state]` | eidos | `EidosMotionPresets` (`scale-fade`…) |
| **`animation`** | coordinado | `[data-starting/ending-style]` | soma | `MotionCoordinatedPresets` (`cascade-*`) |
Ambas emiten `data-animation-style` (el contrato de bajo nivel del motor, que **se conserva**); lo
que difiere es A QUÉ reacciona el CSS y QUIÉN la tipa. El **type-safety de `animation`** vive en un
registry augmentable `MotionCoordinatedPresets` en **`$motion`** (la capa compartida, para que soma
lo tipe sin importar eidos — una capa inferior no depende de una superior); **eidos lo puebla** con
sus `cascade-*` por declaration-merging — espejo exacto de `EidosMotionPresets`. Esto **cierra** la
línea abierta del Apéndice A ("destino de `motion`": se queda, con rol propio).
> **Estado — incremento M5 (hecho, sobre `Reveal`).** El enrutado existe: `<Reveal.Provider
animation="X">` se nombra una vez en la raíz y el provider lo enruta a las parts `surface: true`
@ -401,7 +409,7 @@ con saltos, §7.1, el DOM no sirve para contar):
> contar por estructura DOM es imposible; el count tiene que venir de una fuente que lo
> conozca declarativamente (morfo) o de runtime (provider de la colección).
### 8.3. Interrupción y reversa (máquina de estados) — IMPLEMENTADO (M4)
### 8.3. Interrupción y reversa (máquina de estados) — IMPLEMENTADO (M4 + M6 handoff JS)
Un usuario abre y cierra un Dialog rápido. El sistema debe transicionar de un `enter`
incompleto a un `exit` (o de vuelta) sin saltos ni huérfanos.
@ -425,24 +433,32 @@ incompleto a un `exit` (o de vuelta) sin saltos ni huérfanos.
filtraría un _unhandled rejection_ por `track`/`pending`. Unifica todos los drivers a la
semántica "stop-in-place, resolve" del `spring`.
**Lo que M4 NO entrega todavía — la precisión honesta:** la reversa **fluida desde la
posición/velocidad actual** NO es gratis y queda para **M6**:
- **JS (`spring`/`waapi`):** hoy M4 hace **cancel-del-viejo + arranca-el-nuevo** — un reinicio
limpio, pero el nuevo run parte del `from` declarado por el preset, NO de la posición
interpolada actual (el motor no expone lectura de valor/velocidad). Un _handoff_ que preserve
velocidad exige que el motor exponga el estado actual del driver → M6.
- **CSS `@keyframes`:** **salta** al `from` del keyframe de salida al invertir. "Sin saltos"
exige una de tres políticas (decisión de M6, por preset): (1) keyframes interrumpibles
(`from` ≈ reposo); (2) leer el computed value y reinyectarlo como `from` vía WAAPI; (3)
aceptar el salto donde sea imperceptible (fades). El prototipo
`/temas/animations/presence-group` usa **transiciones CSS** (no `@keyframes`), que SÍ
interrumpen desde el valor actual — por eso ahí la reversa se ve fluida sin esfuerzo (el caso
(1) implícito).
No prometer fluidez universal es parte del rigor: M4 garantiza la **corrección** (sin unmount
espurio, sin animaciones apiladas, sin rejection filtrado); la **continuidad perceptual fina**
del path JS/`@keyframes` es trabajo de M6.
**Reversa fluida — el estado por sustrato:**
- **CSS transiciones (los coordinados `cascade-*`):** fluida **sin esfuerzo**. Una `transition`
interpola desde el valor computado actual al invertir el target → el prototipo
`/temas/animations/presence-group` y los presets coordinados (que usan `transition`, NO
`@keyframes`) revierten desde la posición actual gratis (el "caso (1)" de abajo, implícito). Es el
sustrato del servicio de coordinación → **su reversa ya es fluida**.
- **JS (`spring`) — HECHO (M6):** el motor ahora **expone el estado del driver**
(`MotionHandle.peek()` → posición + velocidad por propiedad, sólo física) y **lo reinyecta** en la
fase inversa vía `MotionContext.handoff`: el `spring` arranca cada propiedad desde el
`handoff[prop]` (x, v) en vez del `from` declarado, así la reversa **continúa desde la
posición/velocidad actual** sin saltar ni reiniciar el overshoot. El _handoff_ vive en el engine
(`cancel` captura el `peek`; `run` lo consume vía `takeHandoff`, que **además** cancela un run en
vuelo si el caller no pre-canceló — cubre el camino _island_ de un overlay-spring y elimina el
apilamiento). `waapi`/`rect` no exponen velocidad → reinicio limpio (sin `peek`). Tests en
`engine-motion.test.ts`.
- **CSS `@keyframes` — pendiente (decisión por preset):** un `@keyframes` de salida **salta** a su
`from` al invertir. "Sin saltos" exige una de tres políticas: (1) keyframes interrumpibles
(`from` ≈ reposo); (2) leer el computed value y reinyectarlo como `from` vía WAAPI; (3) aceptar el
salto donde sea imperceptible (fades). Aplica a los state-presets que usan `@keyframes`
(`scale-fade`…), NO a los coordinados (transiciones). Sin consumidor que lo fuerce hoy.
Rigor sostenido: M4 garantiza la **corrección** (sin unmount espurio, sin animaciones apiladas, sin
rejection filtrado); M6 añade la **continuidad fina del path JS** (handoff con velocidad). El único
cabo es la política de `@keyframes`, acotada a los state-presets y diferida hasta que un preset la
necesite.
---
@ -597,14 +613,16 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni
flip, y `toHandle` resuelve `finished` en cancel (sin _unhandled rejection_). La reversa fluida
desde la posición actual (JS y `@keyframes`) queda para **M6**. _Verificación:_ tests de
`presence-group`/`presence`/`engine-motion` + prototipo `/temas/animations/presence-group`.
- ◐ **M5 — DX `animation` enrutado (incremento hecho) + naming.** Prop transversal `animation` →
- ✅ **M5 — DX `animation` enrutado + naming (hecho).** Prop transversal `animation` →
enrutado a las superficies `surface: true` del morfo compilado (`routeAnimation`), emitido como
`data-animation-style` por los wrappers de superficie. **Generalizado**: el wiring (PresenceGroup +
routing + auto-stagger) se extrajo al helper `soma/layers/coordination.ts` (`Coordination` +
`CoordinatedSurface`), validado por DOS consumidores — `Reveal` (raíz virtual + Panel owner,
bracket) y `Rail` (raíz=owner, `together`, sin eventos). Un componente coordinado nuevo es ahora un
puñado de líneas. Pendiente: el alias de `motion`/estratificación formal (§5). El gating
data-state↔data-starting-style va a M6.
puñado de líneas. **Naming resuelto (§5): `animation` y `motion` SEPARADAS por rol** — no se
unifican (tras M6 son sistemas distintos); `animation` gana type-safety vía el registry
augmentable `MotionCoordinatedPresets` en `$motion` (eidos lo puebla con `cascade-*`), espejo de
`EidosMotionPresets`. El gating data-state↔data-starting-style se resolvió en M6.
- ◐ **M6 — Stagger expresivo + presets coordinados (hechos) + reversa interrumpible (pendiente).**
(1) Stagger: tokens canónicos `--motion-stagger-index` (por ítem, **auto-derivado del orden de
registro del grupo** vía `PresenceGroup.childIndex`) × `--motion-stagger-each` (ritmo, heredado).
@ -612,8 +630,13 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni
genera (render-css.ts) la transición + off-state + stagger reversible sobre `data-starting/ending-style`;
built-ins `cascade-slide`/`-fade`/`-scale` (presets/css.ts), en `generated/base.css`. Un coordinado
hace `animation="cascade-slide"` sin CSS de animación propio — cierra el hallazgo §5. Generation-test
en `eidos/motion.test.ts`. Pendiente: migrar los demos a los presets (visual) + reversa fluida desde
la posición actual (§8.3).
en `eidos/motion.test.ts`. La demo `/temas/animations/compuesto` consume ya los presets de la librería
en un compuesto real (un `Rail` de `<Button>`s; el selector cambia `animation` en vivo) — ver
**Apéndice B.4**; `reveal`/`rail` conservan hand-CSS a propósito como ejemplo _custom_. **Reversa fluida
(§8.3): los coordinados (transiciones CSS) ya revierten desde la posición actual; el handoff JS con
velocidad (`spring`) está HECHO — `MotionHandle.peek()` + `MotionContext.handoff`, orquestado en el
engine (`takeHandoff`). Único cabo: la política de `@keyframes` interrumpibles para los state-presets
(acotada, diferida).**
- **M7 — Reduced-motion + SSR/hydration hardening** (§10).
- **M8 — text-effects como variants de contenido** (§11).
- **M9 — Migración de los pilotos** (Dialog + lista/menú) y documentación de patrón.
@ -641,7 +664,7 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni
- **morfo declara / soma coordina** (no "soma orquesta animaciones": eso sonaba a invasión
visual; el lifecycle de presencia ya es soma por doctrina — `Presence`). (§7)
- **Routing automático + choreography opt-in-por-declaración.** (§6)
- **Naming: estratificar** `animation` (DX) sobre `data-animation-style` (contrato). (§5)
- **Naming: `animation` y `motion` separadas por rol** (M5) — NO se unifican; tras M6 son sistemas distintos (`--state`/eidos vs coordinado/soma), cada una con su registry type-safe. El nombre de la prop comunica el sistema. (§5)
- **Layout animations = no-goal**; `rect` mide un solo nodo. (§2)
- **Scoping = topología de registro**, no `subtree:true` ciego. (§9)
- **Cierre de registración** — fija=morfo / colección=provider. (§8.2)
@ -656,7 +679,145 @@ items (colección, stagger de salida = el prototipo §13). Los dos casos canóni
- **Política de late-joiners** (un hijo que monta tras cerrarse la ventana de registro: ¿se une
a la coreografía en curso o arranca autónomo?).
- **Política de keyframes interrumpibles** (cuál de las tres vías de §8.3 por preset).
- **Destino final de la prop `motion`** (alias permanente vs deprecación; decidir en M5).
---
## Apéndice B — Convivencia con la firma sema (los tres sistemas visuales)
> **Guía de composición.** Consolida cómo el motion coordinado de este servicio **convive** con
> los otros dos sistemas visuales de eidos. Es material de _cómo se componen_ (con diagrama y un
> ejemplo con tokens); el _por qué_ está en §5 (el hallazgo) y §10 (la firma cross-modal). Para el
> modelo base de **dos** momentos (`--event` / `--state`) ver
> [`eidos-motion.md`](./eidos-motion.md); este apéndice añade el **tercer eje** —el coordinado— y
> la regla que evita que pelee con la firma.
### B.1 — Los tres sistemas visuales
eidos reacciona en CSS a **tres** familias de atributos distintas, sobre el **mismo nodo**. Las
tres las emite `lib/render-css.ts` con funciones hermanas:
| Sistema | Selector | Lo escribe | Generador / mapa | Qué expresa |
| -------------------------------- | --------------------------------------------------- | -------------------------------- | -------------------------------------- | ------------------------------------------------- |
| **Firma** (momento `--event`) | `[data-event-family][data-event-phase='active']` | sema (durante el hold) | `renderSignatureRules` (`signatures`) | _qué SIGNIFICA_ el acto (commit/threat/contact…) |
| **State-preset** (`--state`) | `[data-animation-style][data-state]` | soma (effects) | `renderCssPresetRules` (`presets`) | la transición a/desde un estado persistente |
| **Coordinado** (este servicio) | `[data-animation-style][data-starting/ending-style]`| soma (`PresenceGroup`/`Presence`)| `renderCoordinatedPresetRules` (`coordinated`) | _cómo APARECE/SALE_ una superficie en el árbol |
Son **ejes ortogonales de atributo**: sólo entran en conflicto cuando dos animan la **misma
propiedad** (`transform`/`opacity`) del **mismo nodo a la vez**. La firma sema y el motion
coordinado son, además, **dos sistemas de feedback que responden a preguntas distintas** del
mismo evento — _qué significa_ vs _cómo aparece_:
```
EVENTO ( open · close · activate )
│
┌───────────────────────┴───────────────────────┐
SEMA · ¿qué SIGNIFICA? MOTION · ¿cómo APARECE?
familia / intent soma · PresenceGroup
sound · haptic (canales runtime) (lifecycle del árbol)
estampa data-event-* pone data-starting/ending-style
│ │
└───────────────────────┬───────────────────────┘
▼
EIDOS · capas visuales (CSS, mismo nodo)
┌─────────────────────────┬─────────────────────────┬─────────────────────────┐
│ firma [data-event-*] │ state [data-state] │ coordinado │
│ momento --event │ momento --state │ [data-starting/ │
│ (signatures) │ (presets) │ ending-style] │
│ ✕ MUDA en coordinados │ transición de estado │ (coordinated) │
│ (channels: []) │ │ ✓ gana el eje visual │
└─────────────────────────┴─────────────────────────┴─────────────────────────┘
▼
DOM · una sola animación visual
```
### B.2 — La regla: el coordinado gana el eje visual, la firma genérica se silencia
Si un componente coordinado deja salir además su **firma genérica**, la familia `emerge`
materializa su `present-rise`/`dismiss-fade` — _otra_ transición de `transform`/`opacity` sobre
el **mismo nodo** a la vez que la cascada coordinada. Dos animaciones peleando por la misma
propiedad: el bug que pegó el prototipo ("aparece otra animación con un ligero desplazamiento").
La doctrina: un componente coordinado **silencia su firma genérica** pero **mantiene el evento en
la capa sema**. En `reveal.ts` son tres piezas:
```ts
scope: ['soma', 'sema'], // el evento SIGUE en sema (telemetría / a11y / cascade per-app)
expression: 'none', // declara: no materializo firma perceptual por defecto
events: [{ name: 'open', semantic: { family: 'emerge', /* … */ channels: [] } }]
```
`channels: []` corta en seco en el engine (`sema/engine.ts` — el early-return antes de proyectar):
sin `data-event-*` estampado → la capa de firma queda muda → sólo corre el coordinado. La
percepción del componente **es** la cascada, no una firma genérica encima.
**No es exclusión, es composición selectiva.** Tres modos, todos válidos:
| Modo | Ejemplo | Feedback |
| -------------------------- | ------------------------------------- | ------------------------------------------------------------ |
| **(a) sema sola** | Button, Checkbox | la firma (`data-event-*`); sin coordinado |
| **(b) coordinado solo** | Reveal, Rail | la cascada; firma genérica silenciada (`channels: []`) |
| **(c) ambos compuestos** | un coordinado con `channels:['haptic']` | la cascada (visual) + un tap háptico/sonido (otra modalidad) |
El conflicto sólo existía en el **eje visual**; silenciar `channels: []` lo elimina, pero
sound/haptic siguen disponibles (modo c). Es ortogonalidad **por modalidad**, no apagar sema —
coherente con §10 (la firma cross-modal compone con la coordinación; el servicio no toca el engine
de sema).
### B.3 — Ejemplo con tokens: `Button` (modo a) y su composición (modo c)
`Button` es el caso limpio del **modo (a)**: su morfo declara `expression: 'family-default'` y un
único evento `contact-activate` (`family: 'contact'`, **sin intent**) — su feedback es la **firma
de la familia `contact`**, no motion coordinado. Los "tokens" viven en **dos planos** que no se
pisan:
| Plano | Chrome / estado (locales) | Firma perceptual (global) |
| ------------------------------ | ---------------------------------------------------------- | ----------------------------------------------------- |
| **Dónde** | `button.css` + recipe `--button-*` | `BUILTIN_SIGNATURES` → `generated/base.css` |
| **Tokens** | `--button-palette-solid`, `--button-height-md`, `--button-transition-*` | `press-squeeze` + `--duration-fast` + `--ease-default` |
| **Reacciona a** | `data-variant` / `data-size` / `data-color` / `:hover` / `:active` | `data-event-family='contact'` (el hold de sema) |
| **Alcance** | **per-instancia** (cada button elige variant/size/color) | **canon global** (un press se siente igual en todo el sistema) |
El recorrido del press (el plano de la firma):
```
click → soma: runtime.trigger('contact-activate') (button-provider.svelte.ts)
→ engine sema estampa data-event-family="contact" data-event-phase="active" durante el hold
→ la firma 'press' matchea → animation: press-squeeze var(--duration-fast) var(--ease-default)
(press-squeeze: scale + shadow se aplana + esquina se cuadra)
→ + sound · haptic (canales runtime de contact, si on) → fin del hold → reposo
```
Reparto honesto: **el chrome lo tokeniza el componente; la firma es canon** (el libro pide que un
`contact` se sienta igual en todo el sistema, así que NO hay un `--button-press-duration` por
instancia — la firma vive en `--duration-fast` global). El tinte de intención (`data-color`) es un
eje aparte que NO carga el press: el press es genérico `contact` sin intent (libro cap. 22 §11).
**Modo (c) — el mismo Button componiendo con la cascada.** Metido en un `Rail` que aparece
coordinado, tiene **los dos ejes a la vez, con sus propios tokens, sin pelearse**:
```svelte
<Rail.Provider animation="cascade-slide">
<Rail.Item><Button variant="solid">Guardar</Button></Rail.Item>
</Rail.Provider>
```
- **entrada del item** → `cascade-slide`, eje `[data-starting-style]`, tokens `--motion-cascade-*` _(coordinado)_
- **press del button** → `press-squeeze`, eje `[data-event-*]`, tokens `--duration-fast`/`--ease-default` _(firma)_
Son **dos nodos** (el `Rail.Item` que se anima vs el `<Button>` dentro) y **dos momentos**
(aparecer ≠ pulsar): por eso componen sin conflicto.
### B.4 — Demos de referencia
- [`/temas/animations/compuesto`](../../../web/routes/temas/animations/compuesto/+page.svelte) — la
cara **"librería"**: un `Rail` de `<Button>`s que usa los presets coordinados **predefinidos** de
eidos (`cascade-slide/-fade/-scale`) con **cero CSS de animación** en la página; el selector
cambia `animation` en vivo, y cada Button compone su press (modo c).
- [`/temas/animations/reveal`](../../../web/routes/temas/animations/reveal/+page.svelte) ·
[`/rail`](../../../web/routes/temas/animations/rail/+page.svelte) — la cara **"custom"**: hand-CSS
para presets propios (`cascade-drop`/`-blur`), demuestra cómo extender más allá de la librería.
- [`/temas/animations/presence-group`](../../../web/routes/temas/animations/presence-group/+page.svelte)
— el mecanismo sintético (`Presence` + `PresenceGroup` construidos a mano).
---

@ -157,6 +157,15 @@ 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).
---
## 3. Motion en las 4 capas

@ -38,3 +38,21 @@ 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
}
}

@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest';
import { validateEidosConfig } from './lib/config';
import { createEngineMotion } from '$motion';
import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES } from './lib/motion/presets/css';
import {
BUILTIN_COORDINATED_PRESETS,
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());
@ -278,4 +283,17 @@ 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());
});
});

@ -1,6 +1,7 @@
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. */
@ -9,8 +10,8 @@ export type RailProps = WithChild<{
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Motion preset name, routed to the declared surfaces (RFC §5). */
animation?: string;
/** Coordinated-motion preset, routed to the declared surfaces (RFC §5). */
animation?: CoordinatedPresetName;
}> &
Without<PrimitiveDivAttributes, { open: boolean }>;

@ -1,6 +1,7 @@
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. */
@ -14,7 +15,7 @@ export type RevealProps = {
* as `data-animation-style` — name it once here, the system applies it where the
* morfo says (RFC §5). `undefined` = no preset.
*/
animation?: string;
animation?: CoordinatedPresetName;
children?: Snippet;
};

@ -0,0 +1,296 @@
<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>
Loading…
Cancel
Save

Powered by TurnKey Linux.