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

394 lines
15 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 { CoordinatedPreset, 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)' } },
// 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'
}
}
/**
* Coordinated presets (M6) — for `PresenceGroup`-driven surfaces. They react to
* soma's `data-starting-style` / `data-ending-style` (the group's release), NOT
* `data-state`, so they compose with the coordinated cascade instead of firing on
* mount. A component sets `animation="cascade-…"` + the rhythm
* (`--motion-stagger-each` / `--motion-stagger-count`); eidos generates the transition,
* the off-state, and the reversible stagger. `cascade-` prefix keeps them out of the
* `data-state` preset namespace. Custom ones: add to `EidosConfig.motion.coordinated`.
*/
export const BUILTIN_COORDINATED_PRESETS: Readonly<Record<string, CoordinatedPreset>> = {
'cascade-slide': { off: { opacity: '0', transform: 'translateX(-16px)' } },
'cascade-fade': { off: { opacity: '0' } },
'cascade-scale': { off: { opacity: '0', transform: 'scale(0.9)' } }
}

Powered by TurnKey Linux.