/** * 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> = { '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> = { 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> = { 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> = { 'cascade-slide': { off: { opacity: '0', transform: 'translateX(-16px)' } }, 'cascade-fade': { off: { opacity: '0' } }, 'cascade-scale': { off: { opacity: '0', transform: 'scale(0.9)' } } }