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/motion.test.ts

257 lines
10 KiB

import { describe, expect, it } from 'vitest';
import { validateEidosConfig } from './lib/config';
import { createEngineMotion } from '$motion';
refactor(motion): retire parallel orchestration motor (Plan A) + verified model + Fase 1 prototype Remove the parallel motion-coordination service per the redesign: animation is a channel of the EVENT's firma (sema owns it across all channels), not a parallel axis. Code is gone; the RFC stays as historical record with a retirement banner. - morfo: drop MorfoPart.animation + its schema/compile/types/exports - arts/motion: drop CoordinatedPreset / MotionConfig.coordinated - eidos: drop BUILTIN_COORDINATED_PRESETS / renderCoordinatedPresetRules / the animation:none neutralization + registry block; regen generated/base.css - soma: Presence reduced to a single-surface island; delete presence-group / dom-cascade / coordination; dropdown-menu loses the cascade wiring - delete Reveal / Rail (morfo + soma + demos) Docs: MOTION_SERVICE_RFC gains the retirement banner + §D.8 (retirada) + §D.9 (verified model: morfo->soma->sema->eidos pipeline + the two hard rules — soma never writes a visual --var; data-event-* is a single-target stamp, not a bus) + §D.10 (Fase 1 prototype). Fix stale "5 canales" claim in GUIA §11; eidos-motion + dropdown README aligned. Fase 1 prototype (web/routes/temas/animations/panel-cascade): validates the model end to end — panel->cards cascade (enter/exit), dynamic removal with Svelte out: retention, nested cascade — all via :nth-child + custom-property inheritance, with zero JS visual writes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
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: '<integer>'");
// 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());
});
});

Powered by TurnKey Linux.