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.
433 lines
17 KiB
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' }
|
|
}
|
|
|