Skip to content
125 changes: 81 additions & 44 deletions PROTOCOL_OPTIMIZATION_REPORT.md

Large diffs are not rendered by default.

197 changes: 197 additions & 0 deletions packages/spec/src/ui/animation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest';
import {
TransitionPresetSchema,
EasingFunctionSchema,
TransitionConfigSchema,
AnimationTriggerSchema,
ComponentAnimationSchema,
PageTransitionSchema,
MotionConfigSchema,
type TransitionPreset,
type EasingFunction,
type TransitionConfig,
type AnimationTrigger,
type ComponentAnimation,
type PageTransition,
type MotionConfig,
} from './animation.zod';

describe('TransitionPresetSchema', () => {
it('should accept all valid presets', () => {
const presets = ['fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none'] as const;
presets.forEach(preset => {
expect(() => TransitionPresetSchema.parse(preset)).not.toThrow();
});
});

it('should reject invalid presets', () => {
expect(() => TransitionPresetSchema.parse('dissolve')).toThrow();
expect(() => TransitionPresetSchema.parse('')).toThrow();
});
});

describe('EasingFunctionSchema', () => {
it('should accept all valid easing functions', () => {
const easings = ['linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring'] as const;
easings.forEach(easing => {
expect(() => EasingFunctionSchema.parse(easing)).not.toThrow();
});
});

it('should reject invalid easing functions', () => {
expect(() => EasingFunctionSchema.parse('bounce')).toThrow();
expect(() => EasingFunctionSchema.parse('')).toThrow();
});
});

describe('TransitionConfigSchema', () => {
it('should accept empty config', () => {
const result = TransitionConfigSchema.parse({});
expect(result).toEqual({});
});

it('should accept full config with all fields', () => {
const config: TransitionConfig = {
preset: 'fade',
duration: 200,
easing: 'ease_in_out',
delay: 50,
customKeyframes: 'bounce-in',
};
const result = TransitionConfigSchema.parse(config);
expect(result.preset).toBe('fade');
expect(result.duration).toBe(200);
expect(result.easing).toBe('ease_in_out');
expect(result.delay).toBe(50);
expect(result.customKeyframes).toBe('bounce-in');
});

it('should leave optional fields undefined when not provided', () => {
const result = TransitionConfigSchema.parse({ duration: 100 });
expect(result.duration).toBe(100);
expect(result.preset).toBeUndefined();
expect(result.easing).toBeUndefined();
expect(result.delay).toBeUndefined();
expect(result.customKeyframes).toBeUndefined();
});
});

describe('AnimationTriggerSchema', () => {
it('should accept all valid triggers', () => {
const triggers = ['on_mount', 'on_unmount', 'on_hover', 'on_focus', 'on_click', 'on_scroll', 'on_visible'] as const;
triggers.forEach(trigger => {
expect(() => AnimationTriggerSchema.parse(trigger)).not.toThrow();
});
});

it('should reject invalid triggers', () => {
expect(() => AnimationTriggerSchema.parse('on_drag')).toThrow();
expect(() => AnimationTriggerSchema.parse('')).toThrow();
});
});

describe('ComponentAnimationSchema', () => {
it('should apply default reducedMotion for empty config', () => {
const result = ComponentAnimationSchema.parse({});
expect(result.reducedMotion).toBe('respect');
});

it('should accept full config with enter/exit/hover/trigger/reducedMotion', () => {
const config: ComponentAnimation = {
enter: { preset: 'slide_up', duration: 300, easing: 'ease_out' },
exit: { preset: 'fade', duration: 200 },
hover: { preset: 'scale', duration: 150 },
trigger: 'on_visible',
reducedMotion: 'alternative',
};
const result = ComponentAnimationSchema.parse(config);
expect(result.enter?.preset).toBe('slide_up');
expect(result.exit?.preset).toBe('fade');
expect(result.hover?.preset).toBe('scale');
expect(result.trigger).toBe('on_visible');
expect(result.reducedMotion).toBe('alternative');
});

it('should accept disable for reducedMotion', () => {
const result = ComponentAnimationSchema.parse({ reducedMotion: 'disable' });
expect(result.reducedMotion).toBe('disable');
});
});

describe('PageTransitionSchema', () => {
it('should apply defaults for empty config', () => {
const result = PageTransitionSchema.parse({});
expect(result.type).toBe('fade');
expect(result.duration).toBe(300);
expect(result.easing).toBe('ease_in_out');
expect(result.crossFade).toBe(false);
});

it('should accept full config overriding defaults', () => {
const config: PageTransition = {
type: 'slide_left',
duration: 500,
easing: 'spring',
crossFade: true,
};
const result = PageTransitionSchema.parse(config);
expect(result.type).toBe('slide_left');
expect(result.duration).toBe(500);
expect(result.easing).toBe('spring');
expect(result.crossFade).toBe(true);
});
});

describe('MotionConfigSchema', () => {
it('should apply defaults for empty config', () => {
const result = MotionConfigSchema.parse({});
expect(result.enabled).toBe(true);
expect(result.reducedMotion).toBe(false);
});

it('should accept full config with componentAnimations record', () => {
const config: MotionConfig = {
defaultTransition: { preset: 'fade', duration: 250, easing: 'ease' },
pageTransitions: { type: 'slide_right', duration: 400, easing: 'ease_in_out', crossFade: false },
componentAnimations: {
card: { enter: { preset: 'scale', duration: 200 }, reducedMotion: 'respect' },
modal: { enter: { preset: 'slide_up' }, exit: { preset: 'fade' }, reducedMotion: 'disable' },
},
reducedMotion: true,
enabled: false,
};
const result = MotionConfigSchema.parse(config);
expect(result.defaultTransition?.preset).toBe('fade');
expect(result.pageTransitions?.type).toBe('slide_right');
expect(result.componentAnimations?.card.enter?.preset).toBe('scale');
expect(result.componentAnimations?.modal.reducedMotion).toBe('disable');
expect(result.reducedMotion).toBe(true);
expect(result.enabled).toBe(false);
});

it('should leave optional fields undefined when not provided', () => {
const result = MotionConfigSchema.parse({});
expect(result.defaultTransition).toBeUndefined();
expect(result.pageTransitions).toBeUndefined();
expect(result.componentAnimations).toBeUndefined();
});
});

describe('Type exports', () => {
it('should have valid type exports', () => {
const preset: TransitionPreset = 'fade';
const easing: EasingFunction = 'linear';
const transition: TransitionConfig = {};
const trigger: AnimationTrigger = 'on_mount';
const component: ComponentAnimation = { reducedMotion: 'respect' };
const page: PageTransition = { type: 'fade', duration: 300, easing: 'ease_in_out', crossFade: false };
const motion: MotionConfig = { reducedMotion: false, enabled: true };
expect(preset).toBeDefined();
expect(easing).toBeDefined();
expect(transition).toBeDefined();
expect(trigger).toBeDefined();
expect(component).toBeDefined();
expect(page).toBeDefined();
expect(motion).toBeDefined();
});
});
109 changes: 109 additions & 0 deletions packages/spec/src/ui/animation.zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';

/**
* Transition Preset Schema
* Common animation transition presets.
*/
export const TransitionPresetSchema = z.enum([
'fade',
'slide_up',
'slide_down',
'slide_left',
'slide_right',
'scale',
'rotate',
'flip',
'none',
]).describe('Transition preset type');

export type TransitionPreset = z.infer<typeof TransitionPresetSchema>;

/**
* Easing Function Schema
* Supported animation easing/timing functions.
*/
export const EasingFunctionSchema = z.enum([
'linear',
'ease',
'ease_in',
'ease_out',
'ease_in_out',
'spring',
]).describe('Animation easing function');

export type EasingFunction = z.infer<typeof EasingFunctionSchema>;

/**
* Transition Configuration Schema
* Defines a single animation transition with timing and easing options.
*/
export const TransitionConfigSchema = z.object({
preset: TransitionPresetSchema.optional().describe('Transition preset to apply'),
duration: z.number().optional().describe('Transition duration in milliseconds'),
easing: EasingFunctionSchema.optional().describe('Easing function for the transition'),
delay: z.number().optional().describe('Delay before transition starts in milliseconds'),
customKeyframes: z.string().optional().describe('CSS @keyframes name for custom animations'),
}).describe('Animation transition configuration');

export type TransitionConfig = z.infer<typeof TransitionConfigSchema>;

/**
* Animation Trigger Schema
* Events that can trigger an animation.
*/
export const AnimationTriggerSchema = z.enum([
'on_mount',
'on_unmount',
'on_hover',
'on_focus',
'on_click',
'on_scroll',
'on_visible',
]).describe('Event that triggers the animation');

export type AnimationTrigger = z.infer<typeof AnimationTriggerSchema>;

/**
* Component Animation Schema
* Animation configuration for an individual UI component.
*/
export const ComponentAnimationSchema = z.object({
enter: TransitionConfigSchema.optional().describe('Enter/mount animation'),
exit: TransitionConfigSchema.optional().describe('Exit/unmount animation'),
hover: TransitionConfigSchema.optional().describe('Hover state animation'),
trigger: AnimationTriggerSchema.optional().describe('When to trigger the animation'),
reducedMotion: z.enum(['respect', 'disable', 'alternative']).default('respect')
.describe('Accessibility: how to handle prefers-reduced-motion'),
}).describe('Component-level animation configuration');

export type ComponentAnimation = z.infer<typeof ComponentAnimationSchema>;

/**
* Page Transition Schema
* Defines the animation used when navigating between pages.
*/
export const PageTransitionSchema = z.object({
type: TransitionPresetSchema.default('fade').describe('Page transition type'),
duration: z.number().default(300).describe('Transition duration in milliseconds'),
easing: EasingFunctionSchema.default('ease_in_out').describe('Easing function for the transition'),
crossFade: z.boolean().default(false).describe('Whether to cross-fade between pages'),
}).describe('Page-level transition configuration');

export type PageTransition = z.infer<typeof PageTransitionSchema>;

/**
* Motion Configuration Schema
* Top-level animation and motion design configuration.
*/
export const MotionConfigSchema = z.object({
defaultTransition: TransitionConfigSchema.optional().describe('Default transition applied to all animations'),
pageTransitions: PageTransitionSchema.optional().describe('Page navigation transition settings'),
componentAnimations: z.record(z.string(), ComponentAnimationSchema).optional()
.describe('Component name to animation configuration mapping'),
reducedMotion: z.boolean().default(false).describe('When true, respect prefers-reduced-motion and suppress animations globally'),
enabled: z.boolean().default(true).describe('Enable or disable all animations globally'),
}).describe('Top-level motion and animation design configuration');

export type MotionConfig = z.infer<typeof MotionConfigSchema>;
5 changes: 4 additions & 1 deletion packages/spec/src/ui/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { z } from 'zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { I18nLabelSchema } from './i18n.zod';
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';

/**
* Base Navigation Item Schema
Expand Down Expand Up @@ -212,6 +212,9 @@ export const AppSchema = z.object({
bottomNavItems: z.array(z.string()).optional()
.describe('Navigation item IDs to show in bottom nav (max 5)'),
}).optional().describe('Mobile-specific navigation configuration'),

/** ARIA accessibility attributes */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the application'),
});

/**
Expand Down
Loading
Loading