feat(motion): wake the dormant `spring` engine tier — `spring-pop` preset

Until now every shipped motion preset was `driver: 'css'`: the `arts/motion`
engine's `spring` driver (semi-implicit-Euler physics, velocity handoff) was
built + tested but never CONSUMED. This ships the first JS preset, `spring-pop`
— a PHYSICAL pop (overshoot + settle, the curve no cubic-bezier expresses; low
damping on enter, stiffer exit). Run by `uix.motion` through soma's Presence
(`motion.run` + `pending()`), with a CSS `scale-fade` fallback for reduced-motion
/ SSR. Use on a Presence surface: `<Popover.Content motion="spring-pop">`.

Key architecture decision: JS presets are registered DIRECTLY with the engine in
`ActiveEidos`, NOT via `config.motion.presets`. The `EidosConfig` is serializable
(deep-cloned via `clonePlain`/`structuredClone`), and a `MotionRun` is a function —
`structuredClone` throws on it (DataCloneError). Rather than make the whole config
clone function-aware (which flattened special types and broke ~23 config tests),
JS presets — framework built-ins — live outside the cloneable config. The config
stays plain; `clonePlain` is untouched.

- `lib/motion/presets/js.ts`: `BUILTIN_JS_PRESETS` (`spring-pop`).
- `active-eidos`: registers `BUILTIN_JS_PRESETS` directly with `uix.motion`.
- `registry.ts`: `spring-pop` added (type-safe `motion` prop).
- `motion.test`: spring registration + driver test; F7 typegen extended to JS
  presets; fixed a stale shared-axis regex (the container-driven enter branch from
  `9d40ebe9` made the selector a comma-list — a pre-existing failure surfaced here).

motion.test 23/23; the config-dependent suites are back to their pre-existing
(unrelated, other-track) baseline — this adds zero failures. `check` clean for
the touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 3d41459936
commit 4878ccfbef

@ -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());

@ -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): `<Popover.Content motion="spring-pop">`.
*/
export const BUILTIN_JS_PRESETS: Readonly<Record<string, JsStatePreset>> = {
// 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']
}
}

@ -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
}
/**

@ -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: {

@ -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());
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.