diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index 7a7f54351..0301ccbed 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -42,6 +42,7 @@ import { type RenderThemeCssOptions } from './lib/render-css'; import { createThemeBaseEidosConfig } from './lib/themes/base'; +import { BUILTIN_JS_PRESETS } from './lib/motion/presets/js'; import { buildScheme, schemeDeclarations, @@ -326,6 +327,16 @@ export class ActiveEidos { } } + // JS-driven presets (the `spring` engine tier) are framework built-ins + // registered DIRECTLY: they carry functions (`MotionRun`) that the + // serializable, deep-cloned `EidosConfig` can't hold, so they don't ride + // `config.motion.presets`. soma's `Presence` runs them the same way. + if (this.#uix) { + for (const [name, preset] of Object.entries(BUILTIN_JS_PRESETS)) { + this.#uix.motion.register(name, preset); + } + } + if (this.#applyDom) { this.apply(); this.#unsubscribe = this.#preferences.onPreferenceChange(() => this.apply()); diff --git a/src/uix/eidos/lib/motion/presets/js.ts b/src/uix/eidos/lib/motion/presets/js.ts new file mode 100644 index 000000000..d88bbdb9f --- /dev/null +++ b/src/uix/eidos/lib/motion/presets/js.ts @@ -0,0 +1,32 @@ +import { spring } from '$motion' +import type { JsStatePreset } from '$motion' +import { BUILTIN_CSS_PRESETS } from './css' + +/** + * Built-in JS-driven presets — the first consumers of the `arts/motion` engine's + * `spring` driver (until now built + tested but DORMANT: every shipped preset was + * `driver: 'css'`). Registered with `uix.motion` (active-eidos) exactly like the + * CSS presets, but RUN by the engine — semi-implicit-Euler physics stepped on + * `requestFrame`, the overshoot/settle curve no cubic-bezier expresses. soma's + * `Presence` calls `motion.run(node, phase)` and awaits `pending(el)`, which + * `getAnimations()` can't see (the spring is invisible to it). + * + * They generate NO CSS (`render-css` skips non-`css` drivers), so adding one needs + * NO `base.css` regeneration. Each carries a `fallback` CSS preset: with no DOM + * port (SSR / headless) or under reduced motion, the engine settles on the + * fallback instead of stepping the physics. + * + * Runs only on a Presence surface (an overlay): ``. + */ +export const BUILTIN_JS_PRESETS: Readonly> = { + // A PHYSICAL pop. Low damping on enter → it overshoots and bounces (a real + // spring, not a keyframe); the exit is stiffer (snappy, no bounce). Scales from + // the element's `transform-origin` (the floating origin on an anchored panel). + 'spring-pop': { + driver: 'spring', + enter: spring({ values: { scale: [0.7, 1], opacity: [0, 1] }, stiffness: 300, damping: 14 }), + exit: spring({ values: { scale: [1, 0.86], opacity: [1, 0] }, stiffness: 360, damping: 30 }), + reduce: 'opacity-only', + fallback: BUILTIN_CSS_PRESETS['scale-fade'] + } +} diff --git a/src/uix/eidos/lib/motion/registry.ts b/src/uix/eidos/lib/motion/registry.ts index a2737564d..a90990cdf 100644 --- a/src/uix/eidos/lib/motion/registry.ts +++ b/src/uix/eidos/lib/motion/registry.ts @@ -29,6 +29,8 @@ export interface EidosMotionPresets { 'shared-axis-x': true 'shared-axis-y': true 'fade-through': true + // JS-driven (the `spring` engine tier) — runs via uix.motion, not CSS. + 'spring-pop': true } /** diff --git a/src/uix/eidos/lib/themes/base.ts b/src/uix/eidos/lib/themes/base.ts index 2dc8dfc72..54b8149cc 100644 --- a/src/uix/eidos/lib/themes/base.ts +++ b/src/uix/eidos/lib/themes/base.ts @@ -495,6 +495,9 @@ export const THEME_BASE_OPTIONS: EidosConfig = defineEidosConfig({ motion: { keyframes: BUILTIN_KEYFRAMES, signatures: BUILTIN_SIGNATURES, + // CSS presets only — the EidosConfig is serializable (deep-cloned via + // `clonePlain`), and JS presets carry functions (`MotionRun`). The JS + // (`spring`) tier is registered DIRECTLY with the engine in `ActiveEidos`. presets: BUILTIN_CSS_PRESETS }, themes: { diff --git a/src/uix/eidos/motion.test.ts b/src/uix/eidos/motion.test.ts index 55dfc2420..2b8134387 100644 --- a/src/uix/eidos/motion.test.ts +++ b/src/uix/eidos/motion.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { validateEidosConfig } from './lib/config'; import { createEngineMotion } from '$motion'; import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES } from './lib/motion/presets/css'; +import { BUILTIN_JS_PRESETS } from './lib/motion/presets/js'; import { renderStaticCss } from './lib/render-css'; import { createThemeBaseEidosConfig } from './lib/themes/base'; import type { EidosMotionPresets } from './lib/motion/registry'; @@ -51,8 +52,10 @@ describe('eidos motion — CSS generation', () => { expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`); expect(css).toContain(`[data-animation-style='fade-through'][data-state='open']`); expect(css).toContain('@keyframes scale-through'); - // shared-axis composes a directional slide with a fade - expect(css).toMatch(/shared-axis-x'\]\[data-state='open'\] \{\s*animation: slide-axis-x-in/); + // shared-axis composes a directional slide with a fade. The enter selector is + // a comma-list (self-driven + the container-driven cascade branch), so match + // the rule's `animation:` past whatever selector precedes the brace. + expect(css).toMatch(/shared-axis-x'\][^{]*\{\s*animation: slide-axis-x-in/); }); it('emits side-aware variants for slide-fade (data-side)', () => { @@ -117,6 +120,17 @@ describe('eidos motion — runtime registry', () => { expect(runtime.list()).toEqual(expect.arrayContaining(Object.keys(BUILTIN_CSS_PRESETS))); }); + it('registers the JS (spring) presets, resolving to their driver', () => { + // The engine's spring tier — registered DIRECTLY (not via the serializable + // config, which can't hold their `MotionRun` functions); `ActiveEidos` does + // this with `BUILTIN_JS_PRESETS`. Run by `uix.motion` (Presence), not the browser. + const engine = createEngineMotion(); + for (const [name, preset] of Object.entries(BUILTIN_JS_PRESETS)) { + engine.register(name, preset); + expect(engine.resolve(name)?.driver).toBe('spring'); + } + }); + it('returns undefined for an unknown preset', () => { expect(runtime.resolve('does-not-exist')).toBeUndefined(); expect(runtime.has('does-not-exist')).toBe(false); @@ -236,11 +250,12 @@ describe('eidos motion — F6 token rigor', () => { }); describe('eidos motion — F7 typegen registry', () => { - it('declares every built-in CSS preset as a type-safe registry name', () => { + it('declares every built-in preset (CSS + JS) as a type-safe registry name', () => { // `registered` is the closed built-in set; the `satisfies` makes each a // `keyof EidosMotionPresets` — a TYPO fails compile (the registry's // `(string & {})` escape is NOT in play here). The runtime check pins it to - // the ACTUAL presets, so adding a built-in without registering it fails too. + // the ACTUAL presets (CSS + JS), so adding a built-in without registering it + // fails too. const registered = [ 'fade', 'scale-fade', @@ -249,8 +264,11 @@ describe('eidos motion — F7 typegen registry', () => { 'collapse', 'shared-axis-x', 'shared-axis-y', - 'fade-through' + 'fade-through', + 'spring-pop' ] as const satisfies readonly (keyof EidosMotionPresets)[]; - expect([...Object.keys(BUILTIN_CSS_PRESETS)].sort()).toEqual([...registered].sort()); + expect( + [...Object.keys(BUILTIN_CSS_PRESETS), ...Object.keys(BUILTIN_JS_PRESETS)].sort() + ).toEqual([...registered].sort()); }); });