|
|
<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 { 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
|
|
|
})
|
|
|
|
|
|
// 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>
|
|
|
|
|
|
<!-- 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;
|
|
|
}
|
|
|
</style>
|