Skip to content

Commit 62f0bcf

Browse files
authored
Merge pull request #699 from objectstack-ai/copilot/implement-element-library-and-builder
2 parents 77235d6 + bb42a78 commit 62f0bcf

File tree

12 files changed

+917
-2
lines changed

12 files changed

+917
-2
lines changed

packages/spec/src/studio/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,17 @@ export {
8181
// Object Designer Helpers
8282
defineObjectDesignerConfig,
8383
} from './object-designer.zod';
84+
85+
export {
86+
// Interface Builder Schemas
87+
CanvasSnapSettingsSchema,
88+
CanvasZoomSettingsSchema,
89+
ElementPaletteItemSchema,
90+
InterfaceBuilderConfigSchema,
91+
92+
// Interface Builder Types
93+
type CanvasSnapSettings,
94+
type CanvasZoomSettings,
95+
type ElementPaletteItem,
96+
type InterfaceBuilderConfig,
97+
} from './interface-builder.zod';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
CanvasSnapSettingsSchema,
4+
CanvasZoomSettingsSchema,
5+
ElementPaletteItemSchema,
6+
InterfaceBuilderConfigSchema,
7+
type InterfaceBuilderConfig,
8+
} from './interface-builder.zod';
9+
10+
// ---------------------------------------------------------------------------
11+
// CanvasSnapSettingsSchema
12+
// ---------------------------------------------------------------------------
13+
describe('CanvasSnapSettingsSchema', () => {
14+
it('should accept empty config with defaults', () => {
15+
const settings = CanvasSnapSettingsSchema.parse({});
16+
expect(settings.enabled).toBe(true);
17+
expect(settings.gridSize).toBe(8);
18+
expect(settings.showGrid).toBe(true);
19+
expect(settings.showGuides).toBe(true);
20+
});
21+
22+
it('should accept custom snap settings', () => {
23+
const settings = CanvasSnapSettingsSchema.parse({
24+
enabled: false,
25+
gridSize: 16,
26+
showGrid: false,
27+
showGuides: false,
28+
});
29+
expect(settings.enabled).toBe(false);
30+
expect(settings.gridSize).toBe(16);
31+
});
32+
});
33+
34+
// ---------------------------------------------------------------------------
35+
// CanvasZoomSettingsSchema
36+
// ---------------------------------------------------------------------------
37+
describe('CanvasZoomSettingsSchema', () => {
38+
it('should accept empty config with defaults', () => {
39+
const settings = CanvasZoomSettingsSchema.parse({});
40+
expect(settings.min).toBe(0.25);
41+
expect(settings.max).toBe(3);
42+
expect(settings.default).toBe(1);
43+
expect(settings.step).toBe(0.1);
44+
});
45+
46+
it('should accept custom zoom settings', () => {
47+
const settings = CanvasZoomSettingsSchema.parse({
48+
min: 0.5,
49+
max: 5,
50+
default: 1.5,
51+
step: 0.25,
52+
});
53+
expect(settings.min).toBe(0.5);
54+
expect(settings.max).toBe(5);
55+
});
56+
});
57+
58+
// ---------------------------------------------------------------------------
59+
// ElementPaletteItemSchema
60+
// ---------------------------------------------------------------------------
61+
describe('ElementPaletteItemSchema', () => {
62+
it('should accept valid palette item', () => {
63+
const item = ElementPaletteItemSchema.parse({
64+
type: 'element:button',
65+
label: 'Button',
66+
category: 'interactive',
67+
});
68+
expect(item.type).toBe('element:button');
69+
expect(item.defaultWidth).toBe(4);
70+
expect(item.defaultHeight).toBe(2);
71+
});
72+
73+
it('should accept all category values', () => {
74+
const categories = ['content', 'interactive', 'data', 'layout'] as const;
75+
categories.forEach(category => {
76+
expect(() => ElementPaletteItemSchema.parse({
77+
type: 'element:text',
78+
label: 'Text',
79+
category,
80+
})).not.toThrow();
81+
});
82+
});
83+
84+
it('should reject without required fields', () => {
85+
expect(() => ElementPaletteItemSchema.parse({})).toThrow();
86+
expect(() => ElementPaletteItemSchema.parse({ type: 'element:text' })).toThrow();
87+
});
88+
});
89+
90+
// ---------------------------------------------------------------------------
91+
// InterfaceBuilderConfigSchema
92+
// ---------------------------------------------------------------------------
93+
describe('InterfaceBuilderConfigSchema', () => {
94+
it('should accept empty config with defaults', () => {
95+
const config: InterfaceBuilderConfig = InterfaceBuilderConfigSchema.parse({});
96+
expect(config.showLayerPanel).toBe(true);
97+
expect(config.showPropertyPanel).toBe(true);
98+
expect(config.undoLimit).toBe(50);
99+
});
100+
101+
it('should accept full builder config', () => {
102+
const config = InterfaceBuilderConfigSchema.parse({
103+
snap: { enabled: true, gridSize: 4 },
104+
zoom: { min: 0.5, max: 2 },
105+
palette: [
106+
{ type: 'element:button', label: 'Button', category: 'interactive' },
107+
{ type: 'element:text', label: 'Text', category: 'content' },
108+
],
109+
showLayerPanel: false,
110+
showPropertyPanel: true,
111+
undoLimit: 100,
112+
});
113+
114+
expect(config.snap?.gridSize).toBe(4);
115+
expect(config.palette).toHaveLength(2);
116+
expect(config.undoLimit).toBe(100);
117+
});
118+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @module studio/interface-builder
5+
*
6+
* Studio Interface Builder Protocol
7+
*
8+
* Defines the specification for the drag-and-drop Interface Builder UI.
9+
* The builder allows visual composition of blank pages by placing
10+
* elements on a grid canvas with snapping, alignment, and layer ordering.
11+
*/
12+
13+
import { z } from 'zod';
14+
15+
/**
16+
* Canvas Snap Settings Schema
17+
* Controls grid snapping behavior during element placement.
18+
*/
19+
export const CanvasSnapSettingsSchema = z.object({
20+
enabled: z.boolean().default(true).describe('Enable snap-to-grid'),
21+
gridSize: z.number().int().min(1).default(8).describe('Snap grid size in pixels'),
22+
showGrid: z.boolean().default(true).describe('Show grid overlay on canvas'),
23+
showGuides: z.boolean().default(true).describe('Show alignment guides when dragging'),
24+
});
25+
26+
/**
27+
* Canvas Zoom Settings Schema
28+
* Controls zoom behavior for the builder canvas.
29+
*/
30+
export const CanvasZoomSettingsSchema = z.object({
31+
min: z.number().min(0.1).default(0.25).describe('Minimum zoom level'),
32+
max: z.number().max(10).default(3).describe('Maximum zoom level'),
33+
default: z.number().default(1).describe('Default zoom level'),
34+
step: z.number().default(0.1).describe('Zoom step increment'),
35+
});
36+
37+
/**
38+
* Element Palette Item Schema
39+
* An element available in the builder palette for drag-and-drop placement.
40+
*/
41+
export const ElementPaletteItemSchema = z.object({
42+
type: z.string().describe('Component type (e.g. "element:button", "element:text")'),
43+
label: z.string().describe('Display label in palette'),
44+
icon: z.string().optional().describe('Icon name for palette display'),
45+
category: z.enum(['content', 'interactive', 'data', 'layout'])
46+
.describe('Palette category grouping'),
47+
defaultWidth: z.number().int().min(1).default(4).describe('Default width in grid columns'),
48+
defaultHeight: z.number().int().min(1).default(2).describe('Default height in grid rows'),
49+
});
50+
51+
/**
52+
* Interface Builder Config Schema
53+
* Configuration for the Studio Interface Builder.
54+
*/
55+
export const InterfaceBuilderConfigSchema = z.object({
56+
snap: CanvasSnapSettingsSchema.optional().describe('Canvas snap settings'),
57+
zoom: CanvasZoomSettingsSchema.optional().describe('Canvas zoom settings'),
58+
palette: z.array(ElementPaletteItemSchema).optional()
59+
.describe('Custom element palette (defaults to all registered elements)'),
60+
showLayerPanel: z.boolean().default(true).describe('Show layer ordering panel'),
61+
showPropertyPanel: z.boolean().default(true).describe('Show property inspector panel'),
62+
undoLimit: z.number().int().min(1).default(50).describe('Maximum undo history steps'),
63+
});
64+
65+
// Type Exports
66+
export type CanvasSnapSettings = z.infer<typeof CanvasSnapSettingsSchema>;
67+
export type CanvasZoomSettings = z.infer<typeof CanvasZoomSettingsSchema>;
68+
export type ElementPaletteItem = z.infer<typeof ElementPaletteItemSchema>;
69+
export type InterfaceBuilderConfig = z.infer<typeof InterfaceBuilderConfigSchema>;

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,59 @@ export const ElementImagePropsSchema = z.object({
167167
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
168168
});
169169

170+
/**
171+
* ----------------------------------------------------------------------
172+
* 4. Interactive Element Components (Phase B — Element Library)
173+
* ----------------------------------------------------------------------
174+
*/
175+
176+
export const ElementButtonPropsSchema = z.object({
177+
label: I18nLabelSchema.describe('Button display label'),
178+
variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link'])
179+
.optional().default('primary').describe('Button visual variant'),
180+
size: z.enum(['small', 'medium', 'large'])
181+
.optional().default('medium').describe('Button size'),
182+
icon: z.string().optional().describe('Icon name (Lucide icon)'),
183+
iconPosition: z.enum(['left', 'right'])
184+
.optional().default('left').describe('Icon position relative to label'),
185+
disabled: z.boolean().optional().default(false).describe('Disable the button'),
186+
/** ARIA accessibility */
187+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
188+
});
189+
190+
export const ElementFilterPropsSchema = z.object({
191+
object: z.string().describe('Object to filter'),
192+
fields: z.array(z.string()).describe('Filterable field names'),
193+
targetVariable: z.string().optional().describe('Page variable to store filter state'),
194+
layout: z.enum(['inline', 'dropdown', 'sidebar'])
195+
.optional().default('inline').describe('Filter display layout'),
196+
showSearch: z.boolean().optional().default(true).describe('Show search input'),
197+
/** ARIA accessibility */
198+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
199+
});
200+
201+
export const ElementFormPropsSchema = z.object({
202+
object: z.string().describe('Object for the form'),
203+
fields: z.array(z.string()).optional().describe('Fields to display (defaults to all editable fields)'),
204+
mode: z.enum(['create', 'edit']).optional().default('create').describe('Form mode'),
205+
submitLabel: I18nLabelSchema.optional().describe('Submit button label'),
206+
onSubmit: z.string().optional().describe('Action expression on form submit'),
207+
/** ARIA accessibility */
208+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
209+
});
210+
211+
export const ElementRecordPickerPropsSchema = z.object({
212+
object: z.string().describe('Object to pick records from'),
213+
displayField: z.string().describe('Field to display as the record label'),
214+
searchFields: z.array(z.string()).optional().describe('Fields to search against'),
215+
filter: z.any().optional().describe('Filter criteria for available records'),
216+
multiple: z.boolean().optional().default(false).describe('Allow multiple record selection'),
217+
targetVariable: z.string().optional().describe('Page variable to bind selected record ID(s)'),
218+
placeholder: I18nLabelSchema.optional().describe('Placeholder text'),
219+
/** ARIA accessibility */
220+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
221+
});
222+
170223
/**
171224
* ----------------------------------------------------------------------
172225
* Component Props Map
@@ -210,6 +263,12 @@ export const ComponentPropsMap = {
210263
'element:number': ElementNumberPropsSchema,
211264
'element:image': ElementImagePropsSchema,
212265
'element:divider': EmptyProps,
266+
267+
// Interactive Elements
268+
'element:button': ElementButtonPropsSchema,
269+
'element:filter': ElementFilterPropsSchema,
270+
'element:form': ElementFormPropsSchema,
271+
'element:record_picker': ElementRecordPickerPropsSchema,
213272
} as const;
214273

215274
/**

packages/spec/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ export * from './animation.zod';
2929
export * from './notification.zod';
3030
export * from './dnd.zod';
3131
export * from './interface.zod';
32+
export * from './sharing.zod';

0 commit comments

Comments
 (0)