Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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