Skip to content

Commit 3c3746e

Browse files
authored
Merge pull request #613 from objectstack-ai/copilot/optimize-ui-protocols
2 parents c9a1ac9 + ece8bdd commit 3c3746e

10 files changed

Lines changed: 939 additions & 46 deletions

PROTOCOL_OPTIMIZATION_REPORT.md

Lines changed: 81 additions & 44 deletions
Large diffs are not rendered by default.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
TransitionPresetSchema,
4+
EasingFunctionSchema,
5+
TransitionConfigSchema,
6+
AnimationTriggerSchema,
7+
ComponentAnimationSchema,
8+
PageTransitionSchema,
9+
MotionConfigSchema,
10+
type TransitionPreset,
11+
type EasingFunction,
12+
type TransitionConfig,
13+
type AnimationTrigger,
14+
type ComponentAnimation,
15+
type PageTransition,
16+
type MotionConfig,
17+
} from './animation.zod';
18+
19+
describe('TransitionPresetSchema', () => {
20+
it('should accept all valid presets', () => {
21+
const presets = ['fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none'] as const;
22+
presets.forEach(preset => {
23+
expect(() => TransitionPresetSchema.parse(preset)).not.toThrow();
24+
});
25+
});
26+
27+
it('should reject invalid presets', () => {
28+
expect(() => TransitionPresetSchema.parse('dissolve')).toThrow();
29+
expect(() => TransitionPresetSchema.parse('')).toThrow();
30+
});
31+
});
32+
33+
describe('EasingFunctionSchema', () => {
34+
it('should accept all valid easing functions', () => {
35+
const easings = ['linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring'] as const;
36+
easings.forEach(easing => {
37+
expect(() => EasingFunctionSchema.parse(easing)).not.toThrow();
38+
});
39+
});
40+
41+
it('should reject invalid easing functions', () => {
42+
expect(() => EasingFunctionSchema.parse('bounce')).toThrow();
43+
expect(() => EasingFunctionSchema.parse('')).toThrow();
44+
});
45+
});
46+
47+
describe('TransitionConfigSchema', () => {
48+
it('should accept empty config', () => {
49+
const result = TransitionConfigSchema.parse({});
50+
expect(result).toEqual({});
51+
});
52+
53+
it('should accept full config with all fields', () => {
54+
const config: TransitionConfig = {
55+
preset: 'fade',
56+
duration: 200,
57+
easing: 'ease_in_out',
58+
delay: 50,
59+
customKeyframes: 'bounce-in',
60+
};
61+
const result = TransitionConfigSchema.parse(config);
62+
expect(result.preset).toBe('fade');
63+
expect(result.duration).toBe(200);
64+
expect(result.easing).toBe('ease_in_out');
65+
expect(result.delay).toBe(50);
66+
expect(result.customKeyframes).toBe('bounce-in');
67+
});
68+
69+
it('should leave optional fields undefined when not provided', () => {
70+
const result = TransitionConfigSchema.parse({ duration: 100 });
71+
expect(result.duration).toBe(100);
72+
expect(result.preset).toBeUndefined();
73+
expect(result.easing).toBeUndefined();
74+
expect(result.delay).toBeUndefined();
75+
expect(result.customKeyframes).toBeUndefined();
76+
});
77+
});
78+
79+
describe('AnimationTriggerSchema', () => {
80+
it('should accept all valid triggers', () => {
81+
const triggers = ['on_mount', 'on_unmount', 'on_hover', 'on_focus', 'on_click', 'on_scroll', 'on_visible'] as const;
82+
triggers.forEach(trigger => {
83+
expect(() => AnimationTriggerSchema.parse(trigger)).not.toThrow();
84+
});
85+
});
86+
87+
it('should reject invalid triggers', () => {
88+
expect(() => AnimationTriggerSchema.parse('on_drag')).toThrow();
89+
expect(() => AnimationTriggerSchema.parse('')).toThrow();
90+
});
91+
});
92+
93+
describe('ComponentAnimationSchema', () => {
94+
it('should apply default reducedMotion for empty config', () => {
95+
const result = ComponentAnimationSchema.parse({});
96+
expect(result.reducedMotion).toBe('respect');
97+
});
98+
99+
it('should accept full config with enter/exit/hover/trigger/reducedMotion', () => {
100+
const config: ComponentAnimation = {
101+
enter: { preset: 'slide_up', duration: 300, easing: 'ease_out' },
102+
exit: { preset: 'fade', duration: 200 },
103+
hover: { preset: 'scale', duration: 150 },
104+
trigger: 'on_visible',
105+
reducedMotion: 'alternative',
106+
};
107+
const result = ComponentAnimationSchema.parse(config);
108+
expect(result.enter?.preset).toBe('slide_up');
109+
expect(result.exit?.preset).toBe('fade');
110+
expect(result.hover?.preset).toBe('scale');
111+
expect(result.trigger).toBe('on_visible');
112+
expect(result.reducedMotion).toBe('alternative');
113+
});
114+
115+
it('should accept disable for reducedMotion', () => {
116+
const result = ComponentAnimationSchema.parse({ reducedMotion: 'disable' });
117+
expect(result.reducedMotion).toBe('disable');
118+
});
119+
});
120+
121+
describe('PageTransitionSchema', () => {
122+
it('should apply defaults for empty config', () => {
123+
const result = PageTransitionSchema.parse({});
124+
expect(result.type).toBe('fade');
125+
expect(result.duration).toBe(300);
126+
expect(result.easing).toBe('ease_in_out');
127+
expect(result.crossFade).toBe(false);
128+
});
129+
130+
it('should accept full config overriding defaults', () => {
131+
const config: PageTransition = {
132+
type: 'slide_left',
133+
duration: 500,
134+
easing: 'spring',
135+
crossFade: true,
136+
};
137+
const result = PageTransitionSchema.parse(config);
138+
expect(result.type).toBe('slide_left');
139+
expect(result.duration).toBe(500);
140+
expect(result.easing).toBe('spring');
141+
expect(result.crossFade).toBe(true);
142+
});
143+
});
144+
145+
describe('MotionConfigSchema', () => {
146+
it('should apply defaults for empty config', () => {
147+
const result = MotionConfigSchema.parse({});
148+
expect(result.enabled).toBe(true);
149+
expect(result.reducedMotion).toBe(false);
150+
});
151+
152+
it('should accept full config with componentAnimations record', () => {
153+
const config: MotionConfig = {
154+
defaultTransition: { preset: 'fade', duration: 250, easing: 'ease' },
155+
pageTransitions: { type: 'slide_right', duration: 400, easing: 'ease_in_out', crossFade: false },
156+
componentAnimations: {
157+
card: { enter: { preset: 'scale', duration: 200 }, reducedMotion: 'respect' },
158+
modal: { enter: { preset: 'slide_up' }, exit: { preset: 'fade' }, reducedMotion: 'disable' },
159+
},
160+
reducedMotion: true,
161+
enabled: false,
162+
};
163+
const result = MotionConfigSchema.parse(config);
164+
expect(result.defaultTransition?.preset).toBe('fade');
165+
expect(result.pageTransitions?.type).toBe('slide_right');
166+
expect(result.componentAnimations?.card.enter?.preset).toBe('scale');
167+
expect(result.componentAnimations?.modal.reducedMotion).toBe('disable');
168+
expect(result.reducedMotion).toBe(true);
169+
expect(result.enabled).toBe(false);
170+
});
171+
172+
it('should leave optional fields undefined when not provided', () => {
173+
const result = MotionConfigSchema.parse({});
174+
expect(result.defaultTransition).toBeUndefined();
175+
expect(result.pageTransitions).toBeUndefined();
176+
expect(result.componentAnimations).toBeUndefined();
177+
});
178+
});
179+
180+
describe('Type exports', () => {
181+
it('should have valid type exports', () => {
182+
const preset: TransitionPreset = 'fade';
183+
const easing: EasingFunction = 'linear';
184+
const transition: TransitionConfig = {};
185+
const trigger: AnimationTrigger = 'on_mount';
186+
const component: ComponentAnimation = { reducedMotion: 'respect' };
187+
const page: PageTransition = { type: 'fade', duration: 300, easing: 'ease_in_out', crossFade: false };
188+
const motion: MotionConfig = { reducedMotion: false, enabled: true };
189+
expect(preset).toBeDefined();
190+
expect(easing).toBeDefined();
191+
expect(transition).toBeDefined();
192+
expect(trigger).toBeDefined();
193+
expect(component).toBeDefined();
194+
expect(page).toBeDefined();
195+
expect(motion).toBeDefined();
196+
});
197+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { z } from 'zod';
4+
5+
/**
6+
* Transition Preset Schema
7+
* Common animation transition presets.
8+
*/
9+
export const TransitionPresetSchema = z.enum([
10+
'fade',
11+
'slide_up',
12+
'slide_down',
13+
'slide_left',
14+
'slide_right',
15+
'scale',
16+
'rotate',
17+
'flip',
18+
'none',
19+
]).describe('Transition preset type');
20+
21+
export type TransitionPreset = z.infer<typeof TransitionPresetSchema>;
22+
23+
/**
24+
* Easing Function Schema
25+
* Supported animation easing/timing functions.
26+
*/
27+
export const EasingFunctionSchema = z.enum([
28+
'linear',
29+
'ease',
30+
'ease_in',
31+
'ease_out',
32+
'ease_in_out',
33+
'spring',
34+
]).describe('Animation easing function');
35+
36+
export type EasingFunction = z.infer<typeof EasingFunctionSchema>;
37+
38+
/**
39+
* Transition Configuration Schema
40+
* Defines a single animation transition with timing and easing options.
41+
*/
42+
export const TransitionConfigSchema = z.object({
43+
preset: TransitionPresetSchema.optional().describe('Transition preset to apply'),
44+
duration: z.number().optional().describe('Transition duration in milliseconds'),
45+
easing: EasingFunctionSchema.optional().describe('Easing function for the transition'),
46+
delay: z.number().optional().describe('Delay before transition starts in milliseconds'),
47+
customKeyframes: z.string().optional().describe('CSS @keyframes name for custom animations'),
48+
}).describe('Animation transition configuration');
49+
50+
export type TransitionConfig = z.infer<typeof TransitionConfigSchema>;
51+
52+
/**
53+
* Animation Trigger Schema
54+
* Events that can trigger an animation.
55+
*/
56+
export const AnimationTriggerSchema = z.enum([
57+
'on_mount',
58+
'on_unmount',
59+
'on_hover',
60+
'on_focus',
61+
'on_click',
62+
'on_scroll',
63+
'on_visible',
64+
]).describe('Event that triggers the animation');
65+
66+
export type AnimationTrigger = z.infer<typeof AnimationTriggerSchema>;
67+
68+
/**
69+
* Component Animation Schema
70+
* Animation configuration for an individual UI component.
71+
*/
72+
export const ComponentAnimationSchema = z.object({
73+
enter: TransitionConfigSchema.optional().describe('Enter/mount animation'),
74+
exit: TransitionConfigSchema.optional().describe('Exit/unmount animation'),
75+
hover: TransitionConfigSchema.optional().describe('Hover state animation'),
76+
trigger: AnimationTriggerSchema.optional().describe('When to trigger the animation'),
77+
reducedMotion: z.enum(['respect', 'disable', 'alternative']).default('respect')
78+
.describe('Accessibility: how to handle prefers-reduced-motion'),
79+
}).describe('Component-level animation configuration');
80+
81+
export type ComponentAnimation = z.infer<typeof ComponentAnimationSchema>;
82+
83+
/**
84+
* Page Transition Schema
85+
* Defines the animation used when navigating between pages.
86+
*/
87+
export const PageTransitionSchema = z.object({
88+
type: TransitionPresetSchema.default('fade').describe('Page transition type'),
89+
duration: z.number().default(300).describe('Transition duration in milliseconds'),
90+
easing: EasingFunctionSchema.default('ease_in_out').describe('Easing function for the transition'),
91+
crossFade: z.boolean().default(false).describe('Whether to cross-fade between pages'),
92+
}).describe('Page-level transition configuration');
93+
94+
export type PageTransition = z.infer<typeof PageTransitionSchema>;
95+
96+
/**
97+
* Motion Configuration Schema
98+
* Top-level animation and motion design configuration.
99+
*/
100+
export const MotionConfigSchema = z.object({
101+
defaultTransition: TransitionConfigSchema.optional().describe('Default transition applied to all animations'),
102+
pageTransitions: PageTransitionSchema.optional().describe('Page navigation transition settings'),
103+
componentAnimations: z.record(z.string(), ComponentAnimationSchema).optional()
104+
.describe('Component name to animation configuration mapping'),
105+
reducedMotion: z.boolean().default(false).describe('When true, respect prefers-reduced-motion and suppress animations globally'),
106+
enabled: z.boolean().default(true).describe('Enable or disable all animations globally'),
107+
}).describe('Top-level motion and animation design configuration');
108+
109+
export type MotionConfig = z.infer<typeof MotionConfigSchema>;

packages/spec/src/ui/app.zod.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { z } from 'zod';
44
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
5-
import { I18nLabelSchema } from './i18n.zod';
5+
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
66

77
/**
88
* Base Navigation Item Schema
@@ -212,6 +212,9 @@ export const AppSchema = z.object({
212212
bottomNavItems: z.array(z.string()).optional()
213213
.describe('Navigation item IDs to show in bottom nav (max 5)'),
214214
}).optional().describe('Mobile-specific navigation configuration'),
215+
216+
/** ARIA accessibility attributes */
217+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the application'),
215218
});
216219

217220
/**

0 commit comments

Comments
 (0)