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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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.