You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/temas/animations/+page.svelte

1224 lines
42 KiB

<script lang="ts">
/**
* Motion demo — the Eidos two-moment motion system.
*
* Shows every surface the system supports today:
* - momento `--state`: the 5 CSS presets (fade / scale-fade / slide-fade /
* slide-full / collapse), driven by `data-animation-style` × `data-state`;
* - momento `--event`: the perceptual FIRMA on `data-event-*` (generic by
* family / intent / event) — the motion channel of a cross-modal signal;
* - the JS `spring` driver (real physics) run via `eidos.motion`;
* - real components (Dialog / Popover / Drawer) now animating THROUGH the
* preset system (their bespoke recipe animations were migrated to it).
*
* Foundation CSS (generated from `EidosConfig.motion`) is imported by the
* layout. Theme + reduced-motion are projected to `documentElement` so they
* also cover the portaled overlays.
*/
import { tick } from 'svelte'
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'
import { Drawer } from '$uix/eidos/components/drawer'
import { Toaster, createToaster } from '$uix/eidos/components/toast'
type Side = 'top' | 'right' | 'bottom' | 'left'
const SIDES: Side[] = ['top', 'right', 'bottom', 'left']
let theme = $state<'light' | 'dark'>('light')
let reduced = $state(false)
// F6 — Carbon's productive/expressive motion sets (a `[data-motion-set]` scope).
let motionSet = $state<'productive' | 'expressive'>('productive')
// Per-preview open state (default: open, so each box plays its enter on load).
const opened = $state<Record<string, boolean>>({})
const isOpen = (id: string): boolean => opened[id] !== false
const dataState = (id: string): 'open' | 'closed' => (isOpen(id) ? 'open' : 'closed')
const toggle = (id: string): void => {
opened[id] = !isOpen(id)
}
const toggleMany = (ids: string[]): void => {
const next = !ids.every((id) => isOpen(id))
for (const id of ids) opened[id] = next
}
// Collapse animates to a MEASURED height — the real pattern a component uses.
let collapseHeight = $state(0)
const slideFadeIds = SIDES.map((s) => `slide-fade-${s}`)
const slideFullIds = SIDES.map((s) => `slide-full-${s}`)
const materialPresets = ['shared-axis-x', 'shared-axis-y', 'fade-through']
// P2 — real components driven by the `motion` prop.
const toaster = createToaster({ duration: 4000, max: 4 })
function fireToast(): void {
toaster.create({
title: 'Guardado',
description: 'Tus cambios se han escrito.',
intent: 'neutral'
})
}
const eidos = ActiveEidos.require()
// JS `spring` driver — live physics (the mechanical engine). Re-registers with
// the current sliders on each run, then the runtime runs + tracks the handle.
let springBox = $state<HTMLElement>()
let stiffness = $state(180)
let damping = $state(16)
function runSpring(phase: 'enter' | 'exit'): void {
if (!springBox) return
eidos.motion.cancel(springBox)
eidos.motion.register('demo-spring', {
driver: 'spring',
enter: spring({ values: { opacity: [0, 1], scale: [0.4, 1] }, stiffness, damping }),
exit: spring({ values: { opacity: [1, 0], scale: [1, 0.4] }, stiffness, damping })
})
if (phase === 'enter') eidos.motion.enter(springBox, 'demo-spring', { dom: eidos.dom })
else eidos.motion.exit(springBox, 'demo-spring', { dom: eidos.dom })
}
// Event-moment FIRMA — stamp `data-event-*` for a hold to replay the MOTION
// channel of a perceptual signature. Sema fires sound + haptic from the same
// event (cross-modal); they're off here, so this shows the motion channel.
let firmaBox = $state<HTMLElement>()
let firmaFrame: (() => void) | undefined
let firmaDetach: (() => void) | undefined
const FIRMA_ATTRS = ['data-event', 'data-event-family', 'data-event-intent', 'data-event-phase']
const FIRMAS: { label: string; attrs: Record<string, string> }[] = [
{ label: 'present', attrs: { 'data-event': 'present' } },
{ label: 'dismiss', attrs: { 'data-event': 'dismiss' } },
{ label: 'commit', attrs: { 'data-event-family': 'commit' } },
{ label: 'press', attrs: { 'data-event-family': 'contact' } },
{ label: 'announce · neutral', attrs: { 'data-event': 'announce', 'data-event-intent': 'neutral' } },
{ label: 'announce · affirm', attrs: { 'data-event': 'announce', 'data-event-intent': 'affirm' } },
{ label: 'announce · fulfill', attrs: { 'data-event': 'announce', 'data-event-intent': 'fulfill' } },
{ label: 'announce · risk', attrs: { 'data-event': 'announce', 'data-event-intent': 'risk' } },
{ label: 'announce · threat', attrs: { 'data-event': 'announce', 'data-event-intent': 'threat' } }
]
function fireFirma(attrs: Record<string, string>): void {
const el = firmaBox
if (!el) return
firmaFrame?.() // cancel a pending re-trigger frame
firmaDetach?.() // detach a pending animationend listener
for (const k of FIRMA_ATTRS) el.removeAttribute(k)
// Canonical timer surfaces — NOT raw rAF / setTimeout: `dom.raf` re-triggers
// on the next frame (returns a disposer), and `dom.listen('animationend')`
// cleans up when the firma animation ENDS (no magic 1200ms). An app-level
// fixed delay would use the `timers` service, never `window.setTimeout`.
firmaFrame = eidos.dom.raf(() => {
firmaFrame = undefined
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v)
el.setAttribute('data-event-phase', 'active')
firmaDetach = eidos.dom.listen(el, 'animationend', () => {
firmaDetach?.()
firmaDetach = undefined
for (const k of FIRMA_ATTRS) el.removeAttribute(k)
})
}, el)
}
// Spring-gated Presence — the spring driving a REAL mount/unmount cycle via
// soma's `Presence` (the mechanism every overlay uses internally), wired by
// `motion: eidos.motion`: on close, Presence holds the node until the exit
// spring's `finished` resolves, then unmounts.
eidos.motion.register('panel-spring', {
driver: 'spring',
enter: spring({ values: { opacity: [0, 1], scale: [0.7, 1] }, stiffness: 220, damping: 16 }),
exit: spring({ values: { opacity: [1, 0], scale: [1, 0.7] }, stiffness: 260, damping: 26 })
})
// A JS preset for the REAL Dialog below — its content springs via
// `Presence.motion` (the Dialog provider wires `soma.motion` = `uix.motion`).
eidos.motion.register('dialog-spring', {
driver: 'spring',
enter: spring({ values: { opacity: [0, 1], scale: [0.85, 1] }, stiffness: 240, damping: 20 }),
exit: spring({ values: { opacity: [1, 0], scale: [1, 0.92] }, stiffness: 300, damping: 30 })
})
// Expressive JS presets — dramatic, physical entrances at the genie level.
// `waapi` keyframes (transform/filter) need no per-box CSS; `spring` (elastic)
// overshoots. `eidos.motion.enter(el, name)` plays each on demand; all end at
// the rest state so they settle in place.
eidos.motion.register('genie', {
driver: 'waapi',
enter: waapi(
[
{ transform: 'translateY(70px) scale(0.18)', opacity: 0, filter: 'blur(6px)' },
{ transform: 'none', opacity: 1, filter: 'blur(0)' }
],
{ duration: 650, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }
)
})
eidos.motion.register('flip-3d', {
driver: 'waapi',
enter: waapi(
[
{ transform: 'perspective(700px) rotateY(85deg)', opacity: 0 },
{ transform: 'perspective(700px) rotateY(0deg)', opacity: 1 }
],
{ duration: 520, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
)
})
eidos.motion.register('swing', {
driver: 'waapi',
enter: waapi(
[
{ transform: 'rotate(-14deg) translateY(-28px)', opacity: 0, transformOrigin: 'top center' },
{ transform: 'rotate(9deg)', opacity: 1, offset: 0.55, transformOrigin: 'top center' },
{ transform: 'rotate(0deg)', opacity: 1, transformOrigin: 'top center' }
],
{ duration: 820, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
)
})
eidos.motion.register('elastic', {
driver: 'spring',
enter: spring({ values: { scale: [0.3, 1], opacity: [0, 1] }, stiffness: 700, damping: 13 })
})
const jsPresets = [
{ name: 'genie', label: 'genie' },
{ name: 'flip-3d', label: 'flip 3D' },
{ name: 'swing', label: 'swing' },
{ name: 'elastic', label: 'elastic' }
]
const jsBoxes = $state<Record<string, HTMLElement | null>>({})
function playJs(name: string): void {
const el = jsBoxes[name]
if (el) eidos.motion.enter(el, name)
}
let panelOpen = $state(false)
let panelEl = $state<HTMLElement | null>(null)
const panelPresence = new Presence({
dom: eidos.dom,
open: readableActive(() => panelOpen),
ref: readableActive(() => panelEl),
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.
let flipEl = $state<HTMLElement | null>(null)
let flipBig = $state(false)
let staggerKey = $state(0)
function doFlip(): void {
const el = flipEl
if (!el) return
const first = el.getBoundingClientRect()
flipBig = !flipBig
void tick().then(() => {
eidos.motion.cancel(el)
eidos.motion.register('flip', { driver: 'rect', enter: rect() })
eidos.motion.enter(el, 'flip', { dom: eidos.dom, sourceRect: first })
})
}
// Project theme + reduced-motion onto documentElement so they also cover
// the PORTALED overlays (Dialog / Popover / Drawer / Toast render outside
// `.root`, so a local wrapper attr would not reach them).
$effect(() => {
const el = document.documentElement
el.setAttribute('data-theme', theme)
if (reduced) el.setAttribute('data-motion', 'reduce')
else el.removeAttribute('data-motion')
return () => {
el.removeAttribute('data-theme')
el.removeAttribute('data-motion')
}
})
</script>
<svelte:head>
<title>Motion · Eidos</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
/>
</svelte:head>
<div class="root">
<header class="topbar">
<div class="brand">
<strong>Motion</strong>
<small>Eidos · sistema de dos momentos</small>
</div>
<div class="controls">
<div class="seg" role="group" aria-label="Tema">
<button type="button" data-active={theme === 'light'} onclick={() => (theme = 'light')}>
Light
</button>
<button type="button" data-active={theme === 'dark'} onclick={() => (theme = 'dark')}>
Dark
</button>
</div>
<label class="switch">
<input type="checkbox" bind:checked={reduced} />
<span>Reduced motion</span>
</label>
</div>
</header>
<main class="canvas">
<p class="lede">
Motion ocurre en <strong>dos momentos</strong>: la transición de <strong>estado</strong>
(<code>data-state</code>, los presets de abajo) y la <strong>firma del evento</strong>
(<code>data-event-*</code>, transitoria y cross-modal). Cada uno puede llevar animación —
uno, el otro o ambos. Todo sale generado desde <code>EidosConfig.motion</code>. Abajo: los 5
presets CSS, la firma del evento, el driver <code>spring</code> (física real) y componentes
reales animando por el sistema.
</p>
<!-- fade -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-animation-style='fade'</span>
<h2>fade</h2>
<p>Opacidad pura. Bajo <em>reduced-motion</em> corre igual (es seguro).</p>
</div>
<div class="demo">
<div class="stage">
<div class="box" data-animation-style="fade" data-state={dataState('fade')}>fade</div>
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => toggle('fade')}>Toggle</button>
</div>
</div>
<pre class="code">{`<div data-animation-style="fade" data-state="open|closed" />`}</pre>
</section>
<!-- scale-fade -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-animation-style='scale-fade'</span>
<h2>scale-fade</h2>
<p>
Escala (<code>var(--motion-scale-enter)</code>) + opacidad, con
<code>transform-origin</code>. Bajo <em>reduced-motion</em>: solo fade.
</p>
</div>
<div class="demo">
<div class="stage">
<div class="box" data-animation-style="scale-fade" data-state={dataState('scale-fade')}>
scale-fade
</div>
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => toggle('scale-fade')}>Toggle</button>
</div>
</div>
<pre class="code">{`<div data-animation-style="scale-fade" data-state="open|closed" />`}</pre>
</section>
<!-- slide-fade (side-aware) -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-animation-style='slide-fade' · data-side</span>
<h2>slide-fade <small>(side-aware)</small></h2>
<p>
Deslizamiento corto + opacidad según <code>data-side</code> (lo emite el motor de
posicionamiento en un Popover real). Bajo <em>reduced-motion</em>: solo fade.
</p>
</div>
<div class="demo">
<div class="grid">
{#each SIDES as side}
<div class="stage small">
<div
class="box"
data-animation-style="slide-fade"
data-side={side}
data-state={dataState(`slide-fade-${side}`)}
>
{side}
</div>
</div>
{/each}
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => toggleMany(slideFadeIds)}>
Toggle todos
</button>
</div>
</div>
<pre class="code">{`<div data-animation-style="slide-fade" data-side="top" data-state="open|closed" />`}</pre>
</section>
<!-- slide-full (edge, drawer-like) -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-animation-style='slide-full' · data-side</span>
<h2>slide-full <small>(drawer)</small></h2>
<p>
Deslizamiento completo (100%) por borde — para drawers / sheets. Bajo
<em>reduced-motion</em>: instantáneo.
</p>
</div>
<div class="demo">
<div class="grid">
{#each SIDES as side}
<div class="stage small clip">
<div
class="panel"
data-animation-style="slide-full"
data-side={side}
data-state={dataState(`slide-full-${side}`)}
>
{side}
</div>
</div>
{/each}
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => toggleMany(slideFullIds)}>
Toggle todos
</button>
</div>
</div>
<pre class="code">{`<div data-animation-style="slide-full" data-side="left" data-state="open|closed" />`}</pre>
</section>
<!-- collapse (measured height) -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-animation-style='collapse'</span>
<h2>collapse</h2>
<p>
Altura medida (<code>--height</code>, que pondría el componente). Bajo
<em>reduced-motion</em>: instantáneo.
</p>
</div>
<div class="demo">
<div class="stage auto">
<div
class="collapse"
data-animation-style="collapse"
data-state={dataState('collapse')}
style="--height: {collapseHeight}px"
>
<div class="collapse-content" bind:clientHeight={collapseHeight}>
<p>Contenido plegable.</p>
<p>La altura se anima entre 0 y la altura medida del contenido.</p>
<p>El componente (P2) mediría esto y pondría <code>--height</code>.</p>
</div>
</div>
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => toggle('collapse')}>Toggle</button>
</div>
</div>
<pre class="code">{`<div data-animation-style="collapse" data-state="open|closed" style="--height: 160px" />`}</pre>
</section>
<!-- Event-moment firma — the OTHER moment -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-event-* · firma perceptiva (momento --event)</span>
<h2>firma del evento <small>(transitoria · cross-modal)</small></h2>
<p>
El <strong>otro momento</strong>: el flourish durante el hold de una señal de sema,
genérico por <code>family</code> / <code>intent</code> / <code>event</code> (un
<code>commit</code> asienta igual en todo el sistema). Aquí ves el canal
<strong>motion</strong>; sema dispara además <strong>sonido + háptica</strong> desde el
mismo evento. Bajo <em>reduced-motion</em> el motion se silencia, pero sonido/háptica
siguen comunicando — esa es la ventaja cross-modal.
</p>
</div>
<div class="demo">
<div class="stage">
<div class="box firma-box" bind:this={firmaBox}>firma</div>
</div>
<div class="firma-grid">
{#each FIRMAS as f}
<button class="btn sm" type="button" onclick={() => fireFirma(f.attrs)}>{f.label}</button>
{/each}
</div>
</div>
<pre class="code">{`<div data-event="announce" data-event-intent="threat" data-event-phase="active" />`}</pre>
</section>
<!-- JS spring driver — real physics -->
<section class="preset">
<div class="head">
<span class="eyebrow">eidos.motion · driver 'spring'</span>
<h2>spring <small>(física real · driver JS)</small></h2>
<p>
Un muelle independiente por propiedad (no una cubic-bezier): <strong>overshoot</strong> y
asentamiento que ninguna curva CSS expresa. Ajusta rigidez / amortiguación y reprodúcelo —
el motor lo corre vía <code>eidos.motion.enter/exit</code> y lo trackea por elemento
(cancelable). Hoy el demo es el consumidor directo; cablearlo a la <code>Presence</code>
de los componentes es el siguiente paso.
</p>
</div>
<div class="demo">
<div class="stage">
<div class="box spring-box" bind:this={springBox}>spring</div>
</div>
<div class="sliders">
<label>
Rigidez
<input type="range" min="40" max="400" step="10" bind:value={stiffness} />
<code>{stiffness}</code>
</label>
<label>
Amortiguación
<input type="range" min="4" max="40" step="1" bind:value={damping} />
<code>{damping}</code>
</label>
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => runSpring('enter')}>Enter</button>
<button class="btn" type="button" onclick={() => runSpring('exit')}>Exit</button>
</div>
</div>
<pre class="code">{`eidos.motion.register('pop', {
driver: 'spring',
enter: spring({ values: { opacity: [0, 1], scale: [0.4, 1] }, stiffness: ${stiffness}, damping: ${damping} })
})
eidos.motion.enter(el, 'pop', { dom: eidos.dom })`}</pre>
</section>
<!-- Spring-gated Presence — spring driving a real mount/unmount -->
<section class="preset">
<div class="head">
<span class="eyebrow">soma Presence · motion → spring</span>
<h2>presence con spring <small>(end-to-end)</small></h2>
<p>
El spring conduciendo un ciclo real de <strong>montaje/desmontaje</strong>. El panel usa
una <code>Presence</code> de soma (el mismo primitivo que todo overlay) con
<code>motion = eidos.motion</code>: al abrir entra con spring; al cerrar, Presence
<strong>retiene el nodo</strong> hasta que el spring de salida termina, y entonces lo
desmonta. El wrapper escribe <code>data-animation-style</code>; el engine de motion lee ese
atributo y corre el preset JS. (En el preview en segundo plano el rAF está congelado; en un
browser enfocado el panel rebota al entrar y se encoge al salir.)
</p>
</div>
<div class="demo">
<div class="actions">
<button class="btn" type="button" onclick={() => (panelOpen = !panelOpen)}>
{panelOpen ? 'Cerrar' : 'Abrir'} panel
</button>
<span class="tag">{panelPresence.isPresent ? 'montado' : 'desmontado'}</span>
</div>
<div class="stage auto">
{#if panelPresence.isPresent}
<div
class="box spring-box"
bind:this={panelEl}
data-animation-style="panel-spring"
{...panelPresence.transitionAttrs}
>
panel (spring presence)
</div>
{/if}
</div>
</div>
<pre class="code">{`const presence = new Presence({ dom, open, ref, motion: eidos.motion })
// 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">
<span class="eyebrow">eidos.motion · driver 'rect' (FLIP)</span>
<h2>FLIP <small>(transición de layout)</small></h2>
<p>
Transición de <strong>layout</strong>: el bloque cambia de tamaño y el driver
<code>rect</code> mide el rect antes y después y anima el <strong>delta inverso</strong>
(translate + scale) vía WAAPI — la técnica FLIP de Framer. El cambio de layout es
instantáneo; la animación lo hace continuo. (Throttled en el preview en segundo plano.)
</p>
</div>
<div class="demo">
<div class="actions">
<button class="btn" type="button" onclick={doFlip}>Flip</button>
</div>
<div class="flip-stage">
<div class="box flip-box" bind:this={flipEl} style="width: {flipBig ? '80%' : '120px'}">
flip
</div>
</div>
</div>
<pre class="code">{`const first = el.getBoundingClientRect()
mutateLayout() // size / position changes
await tick()
eidos.motion.enter(el, 'flip', { dom, sourceRect: first })`}</pre>
</section>
<!-- Expressive JS presets — genie & friends -->
<section class="preset">
<div class="head">
<span class="eyebrow">eidos.motion.enter · presets JS expresivos</span>
<h2>genie y cía <small>(entradas dramáticas)</small></h2>
<p>
Más presets de driver JS al nivel de <code>genie</code>: warp tipo minimizar
(<code>genie</code>, scale + translate + blur), giro 3D (<code>flip-3d</code>,
<code>rotateY</code>), péndulo (<code>swing</code>) y muelle elástico con overshoot real
(<code>elastic</code>). Pulsa <strong>Play</strong> y el motor corre cada entrada vía
<code>eidos.motion.enter</code>; asientan en su sitio. (En el preview en segundo plano el
rAF está congelado; enfócalo en un browser para verlas.)
</p>
</div>
<div class="demo">
<div class="grid">
{#each jsPresets as p (p.name)}
<div class="stage small">
<button class="btn" type="button" onclick={() => playJs(p.name)}>▶ {p.label}</button>
<div class="box" bind:this={jsBoxes[p.name]}>{p.label}</div>
</div>
{/each}
</div>
</div>
<pre class="code">{`eidos.motion.register('genie', {
driver: 'waapi',
enter: waapi([{ transform: 'translateY(70px) scale(0.18)', opacity: 0, filter: 'blur(6px)' },
{ transform: 'none', opacity: 1, filter: 'blur(0)' }],
{ duration: 650, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' })
})
eidos.motion.enter(boxEl, 'genie') // ▶ play`}</pre>
</section>
<!-- Stagger — Material list choreography (declarative, zero JS) -->
<section class="preset">
<div class="head">
<span class="eyebrow">--motion-stagger-index × --motion-stagger-each · coreografía</span>
<h2>stagger <small>(Material 3 · list choreography)</small></h2>
<p>
Una lista entra <strong>en secuencia</strong>, sin orquestación JS: el contenedor pone el
ritmo (<code>--motion-stagger-each</code>) y cada ítem su índice
(<code>--motion-stagger-index</code>); el preset retrasa su entrada por
<code>índice × ritmo</code>. El fill <code>backwards</code> mantiene cada ítem en su
estado inicial hasta su turno. Compone con cualquier preset CSS — aquí
<code>slide-fade</code>.
</p>
</div>
<div class="demo">
<div class="actions">
<button class="btn" type="button" onclick={() => staggerKey++}>Replay</button>
</div>
<div class="stage auto stagger-stage" style="--motion-stagger-each: 70ms">
{#key staggerKey}
{#each Array(6) as _, i (i)}
<div
class="stagger-item"
data-animation-style="slide-fade"
data-side="bottom"
data-state="open"
style="--motion-stagger-index: {i}"
>
item {i + 1}
</div>
{/each}
{/key}
</div>
</div>
<pre class="code">{`<ul style="--motion-stagger-each: 70ms">
{#each items as item, i}
<li data-animation-style="slide-fade" style="--motion-stagger-index: {i}">…</li>
{/each}
</ul>`}</pre>
</section>
<!-- Material transitions — shared-axis + fade-through presets -->
<section class="preset">
<div class="head">
<span class="eyebrow">data-animation-style · transiciones Material 3</span>
<h2>shared-axis · fade-through <small>(coreografía)</small></h2>
<p>
Presets de transición Material: <code>shared-axis-x/y</code> (movimiento direccional
coordinado para cambios espaciales / navegacionales) y <code>fade-through</code> (para
contenido sin relación — el entrante escala desde 0.92 + fade, el saliente solo opacidad).
Bajo <em>reduced-motion</em>: solo fade. El <strong>container-transform</strong> ya está
cubierto por el driver <code>rect</code> (FLIP).
</p>
</div>
<div class="demo">
<div class="grid">
{#each materialPresets as p (p)}
<div class="stage small">
<div class="box" data-animation-style={p} data-state={dataState(p)}>{p}</div>
</div>
{/each}
</div>
<div class="actions">
<button class="btn" type="button" onclick={() => toggleMany(materialPresets)}>
Toggle todos
</button>
</div>
</div>
<pre class="code">{`<div data-animation-style="shared-axis-x" data-state="open|closed" />`}</pre>
</section>
<!-- F6 — productive / expressive motion sets (Carbon token rigor) -->
<section class="preset">
<div class="head">
<span class="eyebrow">[data-motion-set] · sets productive / expressive (F6)</span>
<h2>productive ⇄ expressive <small>(rigor de tokens · Carbon)</small></h2>
<p>
El <strong>mismo</strong> preset (<code>scale-fade</code>), dos personalidades. El set
<strong>productive</strong> (default) usa el easing eficiente; <strong>expressive</strong>
remapea las curvas a las <em>emphasized</em> de Carbon vía un scope
<code>[data-motion-set='expressive']</code> — sin tocar el componente. Las curvas son
tokens: el theme retinta lo perceptualmente fijo, no inventa animaciones. Cambia el set
y abre/cierra para sentir la diferencia.
</p>
</div>
<div class="demo">
<div class="actions">
<button class="btn" type="button" onclick={() => toggle('motion-set-box')}>
{isOpen('motion-set-box') ? 'Cerrar' : 'Abrir'}
</button>
<button
class="btn"
type="button"
onclick={() => (motionSet = motionSet === 'productive' ? 'expressive' : 'productive')}
>
set: {motionSet}
</button>
</div>
<div class="stage" data-motion-set={motionSet === 'expressive' ? 'expressive' : undefined}>
<div
class="box"
data-animation-style="scale-fade"
data-state={dataState('motion-set-box')}
>
{motionSet}
</div>
</div>
</div>
<pre class="code">{`<div data-motion-set="expressive"> <!-- scope: remaps --ease-* -->
<div data-animation-style="scale-fade" data-state="open" />
</div>`}</pre>
</section>
<!-- Real components — now animating THROUGH the preset system (momento --state) -->
<section class="preset">
<div class="head">
<span class="eyebrow">Componentes · vía el sistema de presets</span>
<h2>Componentes reales</h2>
<p>
Estos overlays animan <strong>a través del sistema de presets</strong> de arriba —
Dialog → <code>scale-fade</code> + <code>fade</code>, Popover → <code>slide-fade</code>,
Drawer → <code>slide-full</code>. Sus animaciones bespoke se migraron al registro y cada
uno conserva su timing exacto vía <code>--motion-duration/ease</code> por componente. La
<code>Presence</code> de soma espera la animación del preset antes de desmontar. Ábrelos
para verlo (tema + reduced-motion aplican, proyectados a <code>documentElement</code>).
</p>
</div>
<div class="components">
<div class="component-card">
<span class="tag">Dialog · spring (JS · motion)</span>
<Dialog>
<Dialog.Trigger class="btn">Abrir dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content motion="dialog-spring">
<Dialog.Title>Confirmar</Dialog.Title>
<p>
El contenido entra con un <code>spring</code> real (driver JS) — su
<code>Presence</code> corre y espera el muelle vía <code>motion</code>. El
overlay sigue con <code>fade</code> (CSS). Un overlay con nombre brincando.
</p>
<Dialog.Close class="btn">Cerrar</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</div>
<div class="component-card">
<span class="tag">Popover · slide-fade</span>
<Popover>
<Popover.Trigger class="btn">Abrir popover</Popover.Trigger>
<Popover.Portal>
<Popover.Content>
<Popover.Arrow />
<p>Desliza desde el lado según <code>data-side</code>.</p>
<Popover.Close class="btn">Cerrar</Popover.Close>
</Popover.Content>
</Popover.Portal>
</Popover>
</div>
<div class="component-card">
<span class="tag">Drawer · slide-full</span>
<Drawer variant="overlay" direction="right">
<Drawer.Trigger class="btn">Abrir drawer</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Content>
<Drawer.Title>Panel</Drawer.Title>
<p>Entra deslizando completo desde el borde.</p>
<Drawer.Close class="btn">Cerrar</Drawer.Close>
</Drawer.Content>
</Drawer>
</div>
<div class="component-card">
<span class="tag">Toast · slide-fade</span>
<button class="btn" type="button" onclick={fireToast}>Lanzar toast</button>
</div>
</div>
</section>
</main>
<Toaster {toaster} position="bottom-right" />
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default);
color: var(--color-content-primary);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: color-mix(in srgb, var(--color-surface-default) 86%, transparent);
backdrop-filter: blur(8px);
border-block-end: 1px solid var(--color-border-subtle);
}
.brand {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand strong {
font-size: var(--font-size-lg);
font-weight: 700;
}
.brand small {
color: var(--color-content-muted);
font-size: var(--font-size-xs);
}
.controls {
display: flex;
align-items: center;
gap: var(--space-4);
}
.seg {
display: inline-flex;
padding: 2px;
gap: 2px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
}
.seg button {
appearance: none;
border: 0;
cursor: pointer;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-content-secondary);
font: inherit;
font-size: var(--font-size-sm);
}
.seg button[data-active='true'] {
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
}
.switch {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
cursor: pointer;
user-select: none;
}
.canvas {
max-width: 880px;
margin-inline: auto;
padding: var(--space-8) var(--space-6) var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.lede {
color: var(--color-content-secondary);
font-size: var(--font-size-md);
line-height: 1.6;
max-width: 64ch;
}
.preset {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
}
.head h2 {
margin: var(--space-1) 0 var(--space-2);
font-size: var(--font-size-xl);
font-weight: 700;
}
.head h2 small {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-content-muted);
}
.head p {
margin: 0;
color: var(--color-content-secondary);
line-height: 1.55;
max-width: 60ch;
}
.eyebrow {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: var(--font-size-xs);
letter-spacing: 0.02em;
color: var(--color-primary-text, var(--color-content-muted));
}
.demo {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.actions {
display: flex;
gap: var(--space-2);
}
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: var(--font-size-sm);
font-weight: 600;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-default);
background: var(--color-surface-default);
color: var(--color-content-primary);
}
.btn:hover {
background: color-mix(in srgb, var(--color-primary-solid) 8%, var(--color-surface-default));
border-color: var(--color-primary-solid);
}
.stage {
display: grid;
place-items: center;
height: 140px;
border-radius: var(--radius-lg);
background:
repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--color-content-muted) 6%, transparent) 0 8px,
transparent 8px 16px
),
var(--color-surface-default);
border: 1px dashed var(--color-border-subtle);
}
.stage.small {
height: 96px;
}
.stage.clip {
overflow: hidden;
}
.stage.auto {
height: auto;
min-height: 64px;
align-content: start;
padding: var(--space-3);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.box {
display: grid;
place-items: center;
min-width: 120px;
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-md);
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
font-weight: 600;
font-size: var(--font-size-sm);
box-shadow: var(--shadow-3);
}
.panel {
align-self: stretch;
justify-self: stretch;
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: var(--radius-md);
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
font-weight: 600;
font-size: var(--font-size-sm);
}
.collapse {
overflow: hidden;
width: 100%;
border-radius: var(--radius-md);
background: var(--color-surface-default);
border: 1px solid var(--color-border-subtle);
}
.collapse-content {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
color: var(--color-content-secondary);
line-height: 1.5;
}
.collapse-content p {
margin: 0;
}
code {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: 0.9em;
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-content-muted) 12%, transparent);
}
.code {
margin: 0;
overflow-x: auto;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface-default);
border: 1px solid var(--color-border-subtle);
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-3);
}
.component-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-surface-default);
border: 1px dashed var(--color-border-subtle);
}
.tag {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
.firma-box {
min-width: 96px;
will-change: transform, opacity, box-shadow;
}
.firma-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.btn.sm {
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-xs);
font-weight: 500;
}
.spring-box {
min-width: 96px;
will-change: transform, opacity;
}
.sliders {
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
}
.sliders label {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sliders input[type='range'] {
flex: 1;
max-width: 240px;
accent-color: var(--color-primary-solid);
}
.flip-stage {
padding: var(--space-3);
border: 1px dashed var(--color-border-subtle);
border-radius: var(--radius-lg);
}
.flip-box {
transform-origin: top left;
will-change: transform;
}
.stagger-stage {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.stagger-item {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border-subtle);
color: var(--color-content-primary);
font-size: var(--font-size-sm);
font-weight: 500;
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>

Powered by TurnKey Linux.