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 { renderStaticCss } from './lib/render-css'; import { createThemeBaseEidosConfig } from './lib/themes/base'; import type { EidosMotionPresets } from './lib/motion/registry'; describe('eidos motion — CSS generation', () => { const css = renderStaticCss(createThemeBaseEidosConfig()); it('emits an @keyframes for every built-in keyframe', () => { for (const name of Object.keys(BUILTIN_KEYFRAMES)) { expect(css).toContain(`@keyframes ${name} {`); } }); it('emits enter (open) + exit (closed) rules per css preset, composing keyframes', () => { expect(css).toContain(`[data-animation-style='scale-fade'][data-state='open']`); expect(css).toContain(`[data-animation-style='scale-fade'][data-state='closed']`); // composed keyframes under one animation; the duration is per-component // overridable (`--motion-duration-{enter,exit}`) with the preset token as // the fallback. Enter holds `backwards`, exit `forwards`. expect(css).toContain( 'scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards' ); expect(css).toContain( 'fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards' ); expect(css).toContain( 'scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards' ); // the duration + easing override custom props are registered (inherits:false) expect(css).toContain('@property --motion-duration-enter'); expect(css).toContain('@property --motion-ease-exit'); }); it('emits the stagger choreography hook on enter rules (Material list)', () => { // per-item index registered as a non-inheriting integer expect(css).toContain('@property --motion-stagger-index'); expect(css).toContain("syntax: ''"); // enter rules carry the index × each delay (default 0 → no stagger) expect(css).toContain( 'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms))' ); }); it('emits the Material transition presets (shared-axis + fade-through)', () => { expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`); 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/); }); it('emits side-aware variants for slide-fade (data-side)', () => { expect(css).toContain( `[data-animation-style='slide-fade'][data-side='top'][data-state='open']` ); expect(css).toContain( `[data-animation-style='slide-fade'][data-side='bottom'][data-state='open']` ); expect(css).toContain( `[data-animation-style='slide-fade'][data-side='left'][data-state='open']` ); }); it('honors reduced motion per policy (projected attr + media query)', () => { // opacity-only preset drops transforms, keeps a fade expect(css).toContain( `[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='open']` ); expect(css).toContain('@media (prefers-reduced-motion: reduce)'); // instant preset kills the animation expect(css).toContain( `[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']` ); expect(css).toMatch( /\[data-motion='reduce'\] \[data-animation-style='collapse'\]\[data-state='open'\] \{\s*animation: none !important;/ ); // 'none' presets (fade) get NO per-preset reduced override expect(css).not.toContain(`[data-motion='reduce'] [data-animation-style='fade'][data-state`); }); it('emits event-moment signatures (data-event-*) from motion.signatures', () => { const withSig = renderStaticCss({ ...createThemeBaseEidosConfig(), motion: { keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } }, signatures: { 'announce-threat': { family: 'signal', intent: 'threat', keyframes: 'fade-in', duration: 'slow' } } } }); expect(withSig).toContain( `[data-event-family='signal'][data-event-intent='threat'][data-event-phase='active']` ); expect(withSig).toContain('fade-in var(--duration-slow)'); }); }); describe('eidos motion — runtime registry', () => { const runtime = createEngineMotion(createThemeBaseEidosConfig().motion); it('registers the built-in presets', () => { for (const name of Object.keys(BUILTIN_CSS_PRESETS)) { expect(runtime.has(name)).toBe(true); expect(runtime.resolve(name)?.driver).toBe('css'); } expect(runtime.list()).toEqual(expect.arrayContaining(Object.keys(BUILTIN_CSS_PRESETS))); }); it('returns undefined for an unknown preset', () => { expect(runtime.resolve('does-not-exist')).toBeUndefined(); expect(runtime.has('does-not-exist')).toBe(false); }); it('css presets resolve enter/exit to a settled handle (declarative path)', async () => { // CSS presets never touch the element — soma's Presence drives them. const el = {} as HTMLElement; await expect(runtime.enter(el, 'scale-fade').finished).resolves.toBeUndefined(); await expect(runtime.exit(el, 'fade').finished).resolves.toBeUndefined(); // an unknown name is a no-op settled handle too await expect(runtime.enter(el, 'nope').finished).resolves.toBeUndefined(); }); it('register adds a preset', () => { const local = createEngineMotion(); expect(local.has('fade')).toBe(false); local.register('fade', BUILTIN_CSS_PRESETS.fade); expect(local.resolve('fade')?.driver).toBe('css'); }); }); describe('eidos motion — validation', () => { it('passes for the base config', () => { expect(validateEidosConfig(createThemeBaseEidosConfig()).ok).toBe(true); }); it('flags a css preset referencing an unknown keyframe', () => { const report = validateEidosConfig({ ...createThemeBaseEidosConfig(), motion: { keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } }, presets: { broken: { driver: 'css', enter: { keyframes: 'no-such-keyframe' } } } } }); expect(report.ok).toBe(false); expect(report.issues.some((issue) => issue.path.startsWith('motion.presets.broken'))).toBe( true ); }); it("flags an 'opacity-only' preset missing the fade keyframes", () => { const report = validateEidosConfig({ ...createThemeBaseEidosConfig(), motion: { keyframes: { 'slide-in': { from: { translate: '0 4px' }, to: { translate: '0 0' } } }, presets: { slide: { driver: 'css', enter: { keyframes: 'slide-in' }, reduce: 'opacity-only' } } } }); expect(report.ok).toBe(false); expect(report.issues.some((issue) => issue.message.includes('fade-in'))).toBe(true); }); it('flags a signature referencing an unknown keyframe', () => { const report = validateEidosConfig({ ...createThemeBaseEidosConfig(), motion: { keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } }, signatures: { bad: { family: 'commit', keyframes: 'no-such-kf' } } } }); expect(report.ok).toBe(false); expect(report.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe( true ); }); }); describe('eidos motion — F6 token rigor', () => { const css = renderStaticCss(createThemeBaseEidosConfig()); it('emits the extended duration scale (perceptual holds for the firma)', () => { expect(css).toContain('--duration-slower: 400ms;'); expect(css).toContain('--duration-deliberate: 600ms;'); expect(css).toContain('--duration-emphatic: 800ms;'); expect(css).toContain('--duration-sustained: 1000ms;'); }); it('emits the emphasized easing + shared-axis (xl) + fade-through (through) tokens', () => { expect(css).toContain('--ease-emphasized:'); expect(css).toContain('--motion-distance-xl: 30px;'); expect(css).toContain('--motion-scale-through: 0.92;'); }); it('tokenizes the firma durations — announce pulses escalate by intent severity', () => { expect(css).toContain('animation: announce-pulse-neutral var(--duration-deliberate)'); expect(css).toContain('animation: announce-pulse-risk var(--duration-emphatic)'); expect(css).toContain('animation: announce-pulse-threat var(--duration-sustained)'); // press / commit snap onto the existing scale (sub-perceptual) expect(css).toContain('animation: press-squeeze var(--duration-fast)'); expect(css).toContain('animation: commit-settle var(--duration-moderate)'); }); it('points the Material keyframes at the new tokens (no hardcoded 30px / 0.92)', () => { expect(css).toContain('translate: var(--motion-distance-xl) 0'); expect(css).toContain('scale: var(--motion-scale-through)'); }); it('emits the expressive motion set (Carbon productive/expressive) as a scoped override', () => { expect(css).toContain("[data-motion-set='expressive']"); // expressive remaps the easing curves; productive (`:root`) stays the default expect(css).toMatch(/\[data-motion-set='expressive'\] \{[^}]*--ease-out:/); }); it('does NOT remap expressive tokens that the set leaves to productive', () => { // the base set only overrides default/out/in — `spring` is untouched expect(css).not.toMatch(/\[data-motion-set='expressive'\] \{[^}]*--ease-spring:/); }); }); describe('eidos motion — F7 typegen registry', () => { it('declares every built-in CSS preset 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. const registered = [ 'fade', 'scale-fade', 'slide-fade', 'slide-full', 'collapse', 'shared-axis-x', 'shared-axis-y', 'fade-through' ] as const satisfies readonly (keyof EidosMotionPresets)[]; expect([...Object.keys(BUILTIN_CSS_PRESETS)].sort()).toEqual([...registered].sort()); }); });