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/src/uix/eidos/lib/motion/presets/css.ts

433 lines
17 KiB

/**
* Built-in motion — keyframes + CSS-driven presets.
*
* These ship in the base config so `renderStaticCss` emits them into the
* foundation CSS, and the motion engine (`uix.motion`) registers them. A theme/app can
* override or add presets via `EidosConfig.motion`.
*
* Authoring notes:
* - Keyframe stops use CSS property names (kebab) → value strings; the
* generator emits them verbatim.
* - Slides/scales use the INDIVIDUAL transform properties (`translate` /
* `scale`) — not the `transform` shorthand — so composed keyframes
* (`['slide-from-bottom', 'fade-in']`) don't clobber each other.
* - Partial slides read `var(--motion-distance-md)` (the distance token);
* collapse reads `var(--height)` / `var(--collapsed-height, 0)` (the
* measured size a component sets). The keyframe is a template.
*/
import type { CssStatePreset, EventSignature, KeyframeStops } from '$motion'
const D = 'var(--motion-distance-md)'
const NEG_D = `calc(${D} * -1)`
/** Registered `@keyframes`, by name. */
export const BUILTIN_KEYFRAMES: Readonly<Record<string, KeyframeStops>> = {
'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } },
'fade-out': { from: { opacity: '1' }, to: { opacity: '0' } },
// Individual `scale` so it composes with `fade-*` (opacity) under one anim.
'scale-in': { from: { scale: 'var(--motion-scale-enter)' }, to: { scale: '1' } },
'scale-out': { from: { scale: '1' }, to: { scale: 'var(--motion-scale-enter)' } },
// State-domain EMPHASIS (momento --state) — NO opacity, so the element stays
// visible. A both-visible state machine (Card selected/idle, Switch on/off) pops
// once when it ENTERS its active state and settles back to its steady style — it
// does NOT appear/disappear, so it must NOT fade from 0 like the presence presets.
// Peak is a design literal (cf. press-squeeze's 0.96).
'select-pop-in': { '0%': { scale: '1' }, '45%': { scale: '1.06' }, '100%': { scale: '1' } },
// ── Loops (momento --content, infinite) — Tailwind's canonical set ────────────
// Pure content: the `motion` prop is the PRIMARY trigger (no event/state to
// compose with), and the animation runs forever while the element is present.
// Individual transform properties so they compose; `bounce` carries per-keyframe
// timing (the squash/rebound asymmetry). Run via the un-gated loop rules
// (`render-css > renderMotionBlocks`), NOT the enter/exit state path.
'loop-spin': { to: { rotate: '360deg' } },
'loop-pulse': { '0%, 100%': { opacity: '1' }, '50%': { opacity: '0.45' } },
'loop-ping': { '0%': { scale: '1', opacity: '1' }, '75%, 100%': { scale: '2', opacity: '0' } },
'loop-bounce': {
'0%, 100%': { translate: '0 -22%', 'animation-timing-function': 'cubic-bezier(0.8, 0, 1, 1)' },
'50%': { translate: '0 0', 'animation-timing-function': 'cubic-bezier(0, 0, 0.2, 1)' }
},
// Partial slides (~8px nudge) — popover / tooltip / menu / toast.
'slide-from-top': { from: { translate: `0 ${NEG_D}` }, to: { translate: '0 0' } },
'slide-from-bottom': { from: { translate: `0 ${D}` }, to: { translate: '0 0' } },
'slide-from-left': { from: { translate: `${NEG_D} 0` }, to: { translate: '0 0' } },
'slide-from-right': { from: { translate: `${D} 0` }, to: { translate: '0 0' } },
'slide-to-top': { from: { translate: '0 0' }, to: { translate: `0 ${NEG_D}` } },
'slide-to-bottom': { from: { translate: '0 0' }, to: { translate: `0 ${D}` } },
'slide-to-left': { from: { translate: '0 0' }, to: { translate: `${NEG_D} 0` } },
'slide-to-right': { from: { translate: '0 0' }, to: { translate: `${D} 0` } },
// Full slides (100%) — drawers / sheets. `--motion-slide-leave` (default 0)
// lets an INSET panel travel its own size PLUS the gap to fully clear the
// edge (a drawer set off `var(--space-3)` sets it to that inset).
'slide-from-top-full': {
from: { translate: '0 calc(-100% - var(--motion-slide-leave, 0px))' },
to: { translate: '0 0' }
},
'slide-from-bottom-full': {
from: { translate: '0 calc(100% + var(--motion-slide-leave, 0px))' },
to: { translate: '0 0' }
},
'slide-from-left-full': {
from: { translate: 'calc(-100% - var(--motion-slide-leave, 0px)) 0' },
to: { translate: '0 0' }
},
'slide-from-right-full': {
from: { translate: 'calc(100% + var(--motion-slide-leave, 0px)) 0' },
to: { translate: '0 0' }
},
'slide-to-top-full': {
from: { translate: '0 0' },
to: { translate: '0 calc(-100% - var(--motion-slide-leave, 0px))' }
},
'slide-to-bottom-full': {
from: { translate: '0 0' },
to: { translate: '0 calc(100% + var(--motion-slide-leave, 0px))' }
},
'slide-to-left-full': {
from: { translate: '0 0' },
to: { translate: 'calc(-100% - var(--motion-slide-leave, 0px)) 0' }
},
'slide-to-right-full': {
from: { translate: '0 0' },
to: { translate: 'calc(100% + var(--motion-slide-leave, 0px)) 0' }
},
// Collapse/expand to a MEASURED size (component sets `--height`). The
// recipe must set `overflow: hidden` on the animated element.
'expand-height': {
from: { height: 'var(--collapsed-height, 0)' },
to: { height: 'var(--height)' }
},
'collapse-height': {
from: { height: 'var(--height)' },
to: { height: 'var(--collapsed-height, 0)' }
},
// Shared axis (Material) — directional slide + fade, per axis. Forward
// direction (incoming from the leading edge, outgoing to the trailing edge).
// Travels the canonical `--motion-distance-xl` (30px); a theme retunes it there.
'slide-axis-x-in': {
from: { translate: 'var(--motion-distance-xl) 0' },
to: { translate: '0 0' }
},
'slide-axis-x-out': {
from: { translate: '0 0' },
to: { translate: 'calc(var(--motion-distance-xl) * -1) 0' }
},
'slide-axis-y-in': {
from: { translate: '0 var(--motion-distance-xl)' },
to: { translate: '0 0' }
},
'slide-axis-y-out': {
from: { translate: '0 0' },
to: { translate: '0 calc(var(--motion-distance-xl) * -1)' }
},
// Fade through (Material) — incoming scales up from `--motion-scale-through`
// (0.92); the outgoing side is opacity-only (see the `fade-through` preset's exit).
'scale-through': { from: { scale: 'var(--motion-scale-through)' }, to: { scale: '1' } },
// ── Firma perceptiva (momento --event) ──────────────────────────────────────
// Pulsos por intent (announce / commit-intent), settle de commit, squeeze de
// contact, fade de dismiss, rise de present. Referencian color tokens del theme.
//
// DEPTH — momento-EVENTO de la profundidad (DEPTH_ENGINE_RFC §3.bis, Fase 3):
// `present-rise` y `press-squeeze` llevan la dimensión de ELEVACIÓN además de la
// posición/escala. La sombra CRECE al emerger (de plano `transparent` → la sombra de
// reposo del elemento: sube de plano) y se APLANA al presionar (recede a la superficie).
// Generic: un elemento flush (sin sombra de reposo) → no-op; uno raised/overlay → la
// elevación anima proporcional a SU plano. Profundidad + motion + sound + haptic salen
// coordinados de un solo evento. (announce-affirm usa `fulfill-element` a propósito.)
'announce-pulse-neutral': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'20%': { 'box-shadow': '0 0 0 var(--focus-ring-width) var(--color-neutral-element)' }
},
'announce-pulse-affirm': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'20%': { 'box-shadow': '0 0 0 var(--focus-ring-width) var(--color-fulfill-element)' }
},
'announce-pulse-fulfill': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'20%': { 'box-shadow': '0 0 0 var(--focus-ring-width) var(--color-fulfill-element)' }
},
'announce-pulse-risk': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'15%': { 'box-shadow': '0 0 0 4px var(--color-risk-element)' },
'30%': { 'box-shadow': '0 0 0 2px var(--color-risk-element)' }
},
'announce-pulse-threat': {
'0%, 100%': { 'box-shadow': '0 0 0 0 transparent' },
'10%': { 'box-shadow': '0 0 0 6px var(--color-threat-element)' },
'35%': { 'box-shadow': '0 0 0 3px var(--color-threat-element)' }
},
'dismiss-fade': { to: { opacity: '0', transform: 'translateX(8%)' } },
// Emerge: the shadow grows from flat → the element's resting plane shadow as it rises
// (the depth dimension of the rise). Flat elements interpolate none→none (no-op).
'present-rise': {
from: { opacity: '0', transform: 'translateY(8px)', 'box-shadow': '0 0 0 0 transparent' }
},
// Contact: cross-modal press firma — scale (motion) + shadow flattens to the surface (depth) +
// the corner firms (shape: `--shape-smoothing` rises → squircle squares up). Only set at the
// peak so it interpolates from/to the element's underlying smoothing (respects a theme
// override); a no-op for non-`continuous` corners, which don't read `--shape-smoothing`.
'press-squeeze': {
'0%': { transform: 'scale(1)' },
'40%': {
transform: 'scale(0.96)',
filter: 'brightness(0.96)',
'box-shadow': '0 0 0 0 transparent',
'--shape-smoothing': '3'
},
'100%': { transform: 'scale(1)' }
},
'commit-settle': {
'0%': { 'box-shadow': '0 0 0 0 transparent' },
'30%': {
'box-shadow': '0 0 0 3px color-mix(in srgb, var(--color-primary-solid) 32%, transparent)'
},
'100%': { 'box-shadow': '0 0 0 0 transparent' }
}
}
/**
* Built-in event-moment SIGNATURES (the perceptual firma), migrated 1:1 from
* the hand-written `events.css`. Generic by `family` / `intent` / `event` — a
* `commit` settles the same everywhere; an `announce` pulses by intent. This is
* the CSS half of the firma; sema emits sound + haptic from the SAME event, so
* motion + sound + haptic stay coordinated from one signal (the coupling
* SwiftUI's `.sensoryFeedback` leaves disconnected). Durations use the token
* scale (F6): the long end (`deliberate`/`emphatic`/`sustained`) are the
* perceptual holds, escalating with the `announce` intent's severity.
*/
export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
present: { event: ['present', 'open'], keyframes: 'present-rise', duration: 'slow', ease: 'out' },
dismiss: {
event: ['dismiss', 'close'],
keyframes: 'dismiss-fade',
duration: 'slow',
ease: 'default',
fill: 'forwards'
},
'announce-neutral': {
event: 'announce',
intent: 'neutral',
keyframes: 'announce-pulse-neutral',
duration: 'deliberate',
ease: 'default'
},
'announce-affirm': {
event: 'announce',
intent: 'affirm',
keyframes: 'announce-pulse-affirm',
duration: 'deliberate',
ease: 'default'
},
'announce-fulfill': {
event: 'announce',
intent: 'fulfill',
keyframes: 'announce-pulse-fulfill',
duration: 'deliberate',
ease: 'default'
},
'announce-risk': {
event: 'announce',
intent: 'risk',
keyframes: 'announce-pulse-risk',
duration: 'emphatic',
ease: 'spring'
},
'announce-threat': {
event: 'announce',
intent: 'threat',
keyframes: 'announce-pulse-threat',
duration: 'sustained',
ease: 'spring'
},
press: { family: 'contact', keyframes: 'press-squeeze', duration: 'fast', ease: 'default' },
commit: { family: 'commit', keyframes: 'commit-settle', duration: 'moderate', ease: 'out' },
'commit-fulfill': {
family: 'commit',
intent: 'fulfill',
keyframes: 'announce-pulse-fulfill',
duration: 'slow',
ease: 'out'
},
'commit-affirm': {
family: 'commit',
intent: 'affirm',
keyframes: 'announce-pulse-affirm',
duration: 'slow',
ease: 'out'
},
'commit-threat': {
family: 'commit',
intent: 'threat',
keyframes: 'announce-pulse-threat',
duration: 'slower',
ease: 'spring'
}
}
/**
* Built-in CSS presets. Phase-aware: one name covers enter (`data-state='open'`)
* and exit (`data-state='closed'`). `bySide` makes a preset placement-aware
* (reads `data-side`); the base `keyframes` is the no-side fallback. Exit
* durations are intentionally shorter than enter (asymmetry).
*/
export const BUILTIN_CSS_PRESETS: Readonly<Record<string, CssStatePreset>> = {
fade: {
driver: 'css',
enter: { keyframes: 'fade-in', duration: 'moderate', ease: 'out' },
exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' },
// Already opacity-only by nature — safe under reduced motion.
reduce: 'none'
},
'scale-fade': {
driver: 'css',
enter: {
keyframes: ['scale-in', 'fade-in'],
duration: 'moderate',
ease: 'out',
transformOrigin: 'var(--floating-transform-origin)'
},
exit: {
keyframes: ['scale-out', 'fade-out'],
duration: 'fast',
ease: 'in',
transformOrigin: 'var(--floating-transform-origin)'
},
reduce: 'opacity-only'
},
// Canonical floating-panel entrance — slide + subtle scale + fade, by side.
// The `scale-in` (0.985) is what anchors a popover/menu/tooltip/select to its
// trigger (matches Radix / Base UI / Chakra). Composes cleanly: slide uses
// `translate`, scale uses `scale`, fade uses `opacity` — distinct properties.
'slide-fade': {
driver: 'css',
enter: {
keyframes: ['slide-from-bottom', 'scale-in', 'fade-in'],
duration: 'moderate',
ease: 'out',
bySide: {
top: ['slide-from-bottom', 'scale-in', 'fade-in'],
bottom: ['slide-from-top', 'scale-in', 'fade-in'],
left: ['slide-from-right', 'scale-in', 'fade-in'],
right: ['slide-from-left', 'scale-in', 'fade-in']
}
},
exit: {
keyframes: ['slide-to-bottom', 'scale-out', 'fade-out'],
duration: 'fast',
ease: 'in',
bySide: {
top: ['slide-to-bottom', 'scale-out', 'fade-out'],
bottom: ['slide-to-top', 'scale-out', 'fade-out'],
left: ['slide-to-right', 'scale-out', 'fade-out'],
right: ['slide-to-left', 'scale-out', 'fade-out']
}
},
reduce: 'opacity-only'
},
'slide-full': {
driver: 'css',
enter: {
keyframes: 'slide-from-right-full',
duration: 'moderate',
ease: 'out',
bySide: {
top: 'slide-from-top-full',
bottom: 'slide-from-bottom-full',
left: 'slide-from-left-full',
right: 'slide-from-right-full'
}
},
exit: {
keyframes: 'slide-to-right-full',
duration: 'fast',
ease: 'in',
bySide: {
top: 'slide-to-top-full',
bottom: 'slide-to-bottom-full',
left: 'slide-to-left-full',
right: 'slide-to-right-full'
}
},
reduce: 'instant'
},
collapse: {
driver: 'css',
enter: { keyframes: 'expand-height', duration: 'moderate', ease: 'out' },
exit: { keyframes: 'collapse-height', duration: 'fast', ease: 'in' },
reduce: 'instant'
},
// ── Material 3 transitions (choreography) ────────────────────────────────────
// Shared axis — coordinated directional motion for spatial / navigational
// changes (outgoing + incoming move together along one axis). Per-element here;
// an app triggers both sides together for the "shared" effect.
'shared-axis-x': {
driver: 'css',
enter: { keyframes: ['slide-axis-x-in', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: ['slide-axis-x-out', 'fade-out'], duration: 'fast', ease: 'in' },
reduce: 'opacity-only'
},
'shared-axis-y': {
driver: 'css',
enter: { keyframes: ['slide-axis-y-in', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: ['slide-axis-y-out', 'fade-out'], duration: 'fast', ease: 'in' },
reduce: 'opacity-only'
},
// Fade through — for content with NO relationship (incoming scales up + fades
// in; outgoing is opacity-only).
'fade-through': {
driver: 'css',
enter: { keyframes: ['scale-through', 'fade-in'], duration: 'moderate', ease: 'out' },
exit: { keyframes: 'fade-out', duration: 'fast', ease: 'in' },
reduce: 'opacity-only'
},
// ── State-domain emphasis (momento --state — stateful components) ─────────────
// For a component with its OWN data-state machine (Card selected/idle, Switch
// on/off): `motion="select-pop"` pops the element when it ENTERS its active state
// and settles back. The generator emits this under the dedicated
// `[data-motion-state='open']` selector (NOT data-state, which the component uses
// for its semantics) and ENTER-ONLY (no `exit`) — animate-on, snap-off: the
// inactive state has no rule, so an idle element never animates on mount. Always
// pair the state domain with an EMPHASIS preset (this), never a presence one
// (scale-fade et al. would flash the still-visible element from invisible).
'select-pop': {
driver: 'css',
enter: { keyframes: 'select-pop-in', duration: 'moderate', ease: 'out' },
reduce: 'instant'
}
}
/**
* Built-in LOOP presets (content domain, infinite) — Tailwind's canonical set.
* Unlike the enter/exit state-presets, a loop runs continuously while the element
* is present: the generator emits `[data-animation-style='X'] { animation: … infinite }`
* with NO `data-state` gate. The duration is themeable per loop via
* `--motion-loop-{name}` (default below). Reduced motion stops them — the element
* rests in its static frame.
*/
export const BUILTIN_LOOP_PRESETS: Readonly<
Record<string, { keyframes: string; duration: string; timing: string }>
> = {
spin: { keyframes: 'loop-spin', duration: '1100ms', timing: 'linear' },
pulse: { keyframes: 'loop-pulse', duration: '2s', timing: 'cubic-bezier(0.4, 0, 0.6, 1)' },
ping: { keyframes: 'loop-ping', duration: '1100ms', timing: 'cubic-bezier(0, 0, 0.2, 1)' },
bounce: { keyframes: 'loop-bounce', duration: '1s', timing: 'linear' }
}

Powered by TurnKey Linux.