From 4430d1311129e04c0c0e013c3ec7995aadd679b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:09:58 +0000 Subject: [PATCH 1/8] Initial plan From 4406e9b6d3a8389fb5b2701aefac10b5c2dab627 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:15:44 +0000 Subject: [PATCH 2/8] feat(ui): add InterfaceSchema, RecordReviewConfig, content elements, and per-element data binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A: Interface Foundation (v3.2) — Airtable Interface Parity - Add InterfaceSchema as self-contained, shareable, multi-page application surface - Add RecordReviewConfigSchema for sequential record review/approval pages - Add content elements (element:text, element:number, element:image, element:divider) to PageComponentType - Add ElementDataSourceSchema for per-element data binding on PageComponentSchema - Add ElementTextPropsSchema, ElementNumberPropsSchema, ElementImagePropsSchema to component.zod.ts - Update ComponentPropsMap with new element types - Export new interface module from src/ui/index.ts - Add comprehensive tests (57 tests across all new schemas) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/ui/component.zod.ts | 47 +- packages/spec/src/ui/index.ts | 1 + packages/spec/src/ui/interface.test.ts | 729 +++++++++++++++++++++++++ packages/spec/src/ui/interface.zod.ts | 180 ++++++ packages/spec/src/ui/page.zod.ts | 24 +- 5 files changed, 979 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/ui/interface.test.ts create mode 100644 packages/spec/src/ui/interface.zod.ts diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d4931a600..bb91f1daf 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -128,6 +128,45 @@ export const AIChatWindowProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); +/** + * ---------------------------------------------------------------------- + * 3. Content Element Components (Airtable Interface Parity) + * ---------------------------------------------------------------------- + */ + +export const ElementTextPropsSchema = z.object({ + content: z.string().describe('Text or Markdown content'), + variant: z.enum(['heading', 'subheading', 'body', 'caption']) + .optional().default('body').describe('Text style variant'), + align: z.enum(['left', 'center', 'right']) + .optional().default('left').describe('Text alignment'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +export const ElementNumberPropsSchema = z.object({ + object: z.string().describe('Source object'), + field: z.string().optional().describe('Field to aggregate'), + aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) + .describe('Aggregation function'), + filter: z.any().optional().describe('Filter criteria'), + format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), + prefix: z.string().optional().describe('Prefix text (e.g. "$")'), + suffix: z.string().optional().describe('Suffix text (e.g. "%")'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +export const ElementImagePropsSchema = z.object({ + src: z.string().describe('Image URL or attachment field'), + alt: z.string().optional().describe('Alt text for accessibility'), + fit: z.enum(['cover', 'contain', 'fill']) + .optional().default('cover').describe('Image object-fit mode'), + height: z.number().optional().describe('Fixed height in pixels'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + /** * ---------------------------------------------------------------------- * Component Props Map @@ -164,7 +203,13 @@ export const ComponentPropsMap = { // AI 'ai:chat_window': AIChatWindowProps, - 'ai:suggestion': z.object({ context: z.string().optional() }) + 'ai:suggestion': z.object({ context: z.string().optional() }), + + // Content Elements + 'element:text': ElementTextPropsSchema, + 'element:number': ElementNumberPropsSchema, + 'element:image': ElementImagePropsSchema, + 'element:divider': EmptyProps, } as const; /** diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index a6e6b2248..45ca21ad2 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -28,3 +28,4 @@ export * from './keyboard.zod'; export * from './animation.zod'; export * from './notification.zod'; export * from './dnd.zod'; +export * from './interface.zod'; diff --git a/packages/spec/src/ui/interface.test.ts b/packages/spec/src/ui/interface.test.ts new file mode 100644 index 000000000..55d9287ca --- /dev/null +++ b/packages/spec/src/ui/interface.test.ts @@ -0,0 +1,729 @@ +import { describe, it, expect } from 'vitest'; +import { + InterfaceSchema, + InterfacePageSchema, + InterfacePageTypeSchema, + InterfaceBrandingSchema, + RecordReviewConfigSchema, + defineInterface, + type Interface, + type InterfacePage, + type RecordReviewConfig, +} from './interface.zod'; +import { + PageComponentSchema, + ElementDataSourceSchema, + type ElementDataSource, +} from './page.zod'; +import { + ElementTextPropsSchema, + ElementNumberPropsSchema, + ElementImagePropsSchema, + ComponentPropsMap, +} from './component.zod'; + +// --------------------------------------------------------------------------- +// InterfacePageTypeSchema +// --------------------------------------------------------------------------- +describe('InterfacePageTypeSchema', () => { + it('should accept all valid page types', () => { + const types = [ + 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', + 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + ]; + + types.forEach(type => { + expect(() => InterfacePageTypeSchema.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid page type', () => { + expect(() => InterfacePageTypeSchema.parse('invalid')).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// RecordReviewConfigSchema +// --------------------------------------------------------------------------- +describe('RecordReviewConfigSchema', () => { + it('should accept minimal review config', () => { + const config: RecordReviewConfig = RecordReviewConfigSchema.parse({ + object: 'order', + actions: [ + { label: 'Approve', type: 'approve' }, + ], + }); + + expect(config.object).toBe('order'); + expect(config.actions).toHaveLength(1); + expect(config.navigation).toBe('sequential'); + expect(config.showProgress).toBe(true); + }); + + it('should accept full review config', () => { + const config = RecordReviewConfigSchema.parse({ + object: 'invoice', + filter: { status: 'pending' }, + sort: [{ field: 'created_at', order: 'desc' }], + displayFields: ['amount', 'vendor', 'description'], + actions: [ + { label: 'Approve', type: 'approve', field: 'status', value: 'approved', nextRecord: true }, + { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + { label: 'Skip', type: 'skip', nextRecord: true }, + { label: 'Flag', type: 'custom', field: 'flagged', value: true }, + ], + navigation: 'filtered', + showProgress: false, + }); + + expect(config.actions).toHaveLength(4); + expect(config.navigation).toBe('filtered'); + expect(config.showProgress).toBe(false); + expect(config.displayFields).toEqual(['amount', 'vendor', 'description']); + }); + + it('should reject review config without object', () => { + expect(() => RecordReviewConfigSchema.parse({ + actions: [{ label: 'Approve', type: 'approve' }], + })).toThrow(); + }); + + it('should reject review config without actions', () => { + expect(() => RecordReviewConfigSchema.parse({ + object: 'order', + })).toThrow(); + }); + + it('should accept all action types', () => { + const types = ['approve', 'reject', 'skip', 'custom'] as const; + + types.forEach(type => { + expect(() => RecordReviewConfigSchema.parse({ + object: 'order', + actions: [{ label: 'Action', type }], + })).not.toThrow(); + }); + }); + + it('should accept all navigation modes', () => { + const modes = ['sequential', 'random', 'filtered'] as const; + + modes.forEach(navigation => { + const config = RecordReviewConfigSchema.parse({ + object: 'order', + actions: [{ label: 'Ok', type: 'approve' }], + navigation, + }); + expect(config.navigation).toBe(navigation); + }); + }); +}); + +// --------------------------------------------------------------------------- +// InterfaceBrandingSchema +// --------------------------------------------------------------------------- +describe('InterfaceBrandingSchema', () => { + it('should accept empty branding', () => { + expect(() => InterfaceBrandingSchema.parse({})).not.toThrow(); + }); + + it('should accept full branding config', () => { + const branding = InterfaceBrandingSchema.parse({ + primaryColor: '#0070F3', + logo: '/assets/logo.png', + coverImage: '/assets/cover.jpg', + }); + + expect(branding.primaryColor).toBe('#0070F3'); + expect(branding.logo).toBe('/assets/logo.png'); + expect(branding.coverImage).toBe('/assets/cover.jpg'); + }); +}); + +// --------------------------------------------------------------------------- +// InterfacePageSchema +// --------------------------------------------------------------------------- +describe('InterfacePageSchema', () => { + it('should accept minimal page', () => { + const page: InterfacePage = InterfacePageSchema.parse({ + id: 'page_overview', + label: 'Overview', + regions: [], + }); + + expect(page.id).toBe('page_overview'); + expect(page.type).toBe('blank'); + expect(page.template).toBe('default'); + }); + + it('should accept dashboard page', () => { + const page = InterfacePageSchema.parse({ + id: 'page_dashboard', + label: 'Dashboard', + type: 'dashboard', + regions: [ + { + name: 'main', + components: [ + { type: 'element:number', properties: { object: 'order', aggregate: 'count' } }, + ], + }, + ], + }); + + expect(page.type).toBe('dashboard'); + expect(page.regions[0].components).toHaveLength(1); + }); + + it('should accept record_review page with config', () => { + const page = InterfacePageSchema.parse({ + id: 'page_review', + label: 'Review Queue', + type: 'record_review', + object: 'order', + recordReview: { + object: 'order', + actions: [ + { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, + { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + ], + }, + regions: [], + }); + + expect(page.type).toBe('record_review'); + expect(page.recordReview?.actions).toHaveLength(2); + }); + + it('should accept page with variables', () => { + const page = InterfacePageSchema.parse({ + id: 'page_filtered', + label: 'Filtered View', + type: 'blank', + variables: [ + { name: 'selectedId', type: 'string' }, + { name: 'showArchived', type: 'boolean', defaultValue: false }, + ], + regions: [], + }); + + expect(page.variables).toHaveLength(2); + }); + + it('should accept all page types', () => { + const types = [ + 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', + 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + ]; + + types.forEach(type => { + expect(() => InterfacePageSchema.parse({ + id: 'test_page', + label: 'Test', + type, + regions: [], + })).not.toThrow(); + }); + }); + + it('should accept page with i18n label', () => { + expect(() => InterfacePageSchema.parse({ + id: 'i18n_page', + label: { key: 'interface.pages.overview', defaultValue: 'Overview' }, + regions: [], + })).not.toThrow(); + }); + + it('should accept page with ARIA attributes', () => { + expect(() => InterfacePageSchema.parse({ + id: 'accessible_page', + label: 'Accessible Page', + regions: [], + aria: { ariaLabel: 'Interface overview page', role: 'main' }, + })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// InterfaceSchema +// --------------------------------------------------------------------------- +describe('InterfaceSchema', () => { + it('should accept minimal interface', () => { + const iface: Interface = InterfaceSchema.parse({ + name: 'order_review', + label: 'Order Review', + pages: [], + }); + + expect(iface.name).toBe('order_review'); + expect(iface.label).toBe('Order Review'); + expect(iface.pages).toHaveLength(0); + }); + + it('should accept full interface', () => { + const iface = InterfaceSchema.parse({ + name: 'sales_portal', + label: 'Sales Portal', + description: 'Self-service portal for sales reps', + object: 'opportunity', + pages: [ + { + id: 'page_dashboard', + label: 'Dashboard', + type: 'dashboard', + regions: [], + }, + { + id: 'page_pipeline', + label: 'Pipeline', + type: 'kanban', + object: 'opportunity', + regions: [], + }, + ], + homePageId: 'page_dashboard', + branding: { + primaryColor: '#1A73E8', + logo: '/logos/sales.png', + }, + assignedRoles: ['sales_rep', 'sales_manager'], + isDefault: true, + }); + + expect(iface.pages).toHaveLength(2); + expect(iface.homePageId).toBe('page_dashboard'); + expect(iface.assignedRoles).toHaveLength(2); + expect(iface.isDefault).toBe(true); + }); + + it('should validate name format (snake_case)', () => { + expect(() => InterfaceSchema.parse({ + name: 'valid_name', + label: 'Valid', + pages: [], + })).not.toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'InvalidName', + label: 'Invalid', + pages: [], + })).toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'invalid-name', + label: 'Invalid', + pages: [], + })).toThrow(); + }); + + it('should reject without required fields', () => { + expect(() => InterfaceSchema.parse({ + label: 'Missing Name', + pages: [], + })).toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'missing_label', + pages: [], + })).toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'missing_pages', + label: 'Missing Pages', + })).toThrow(); + }); + + it('should accept interface with ARIA attributes', () => { + expect(() => InterfaceSchema.parse({ + name: 'accessible_interface', + label: 'Accessible Interface', + pages: [], + aria: { ariaLabel: 'Sales portal interface', role: 'application' }, + })).not.toThrow(); + }); + + it('should accept i18n labels', () => { + expect(() => InterfaceSchema.parse({ + name: 'i18n_interface', + label: { key: 'interfaces.review', defaultValue: 'Review' }, + description: { key: 'interfaces.review.desc', defaultValue: 'Review orders' }, + pages: [], + })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// defineInterface factory +// --------------------------------------------------------------------------- +describe('defineInterface', () => { + it('should create a validated interface', () => { + const iface = defineInterface({ + name: 'hr_portal', + label: 'HR Portal', + pages: [ + { + id: 'page_onboarding', + label: 'Onboarding', + type: 'overview', + regions: [], + }, + ], + }); + + expect(iface.name).toBe('hr_portal'); + expect(iface.pages).toHaveLength(1); + }); + + it('should throw on invalid config', () => { + expect(() => defineInterface({ + name: 'InvalidName', + label: 'Invalid', + pages: [], + })).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Content Elements in PageComponentType +// --------------------------------------------------------------------------- +describe('Content Elements', () => { + it('should accept element:text component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:text', + properties: { content: 'Hello World' }, + })).not.toThrow(); + }); + + it('should accept element:number component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:number', + properties: { object: 'order', aggregate: 'count' }, + })).not.toThrow(); + }); + + it('should accept element:image component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:image', + properties: { src: '/images/banner.jpg' }, + })).not.toThrow(); + }); + + it('should accept element:divider component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:divider', + properties: {}, + })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ElementDataSourceSchema (per-element data binding) +// --------------------------------------------------------------------------- +describe('ElementDataSourceSchema', () => { + it('should accept minimal data source', () => { + const ds: ElementDataSource = ElementDataSourceSchema.parse({ + object: 'order', + }); + + expect(ds.object).toBe('order'); + expect(ds.view).toBeUndefined(); + expect(ds.filter).toBeUndefined(); + expect(ds.sort).toBeUndefined(); + expect(ds.limit).toBeUndefined(); + }); + + it('should accept full data source', () => { + const ds = ElementDataSourceSchema.parse({ + object: 'invoice', + view: 'pending_review', + filter: { status: 'pending' }, + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + }); + + expect(ds.object).toBe('invoice'); + expect(ds.view).toBe('pending_review'); + expect(ds.sort).toHaveLength(1); + expect(ds.limit).toBe(50); + }); + + it('should reject without object', () => { + expect(() => ElementDataSourceSchema.parse({})).toThrow(); + }); + + it('should reject invalid sort order', () => { + expect(() => ElementDataSourceSchema.parse({ + object: 'order', + sort: [{ field: 'name', order: 'invalid' }], + })).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// PageComponentSchema dataSource integration +// --------------------------------------------------------------------------- +describe('PageComponent dataSource integration', () => { + it('should accept component with dataSource', () => { + const component = PageComponentSchema.parse({ + type: 'element:number', + properties: { object: 'order', aggregate: 'sum', field: 'total' }, + dataSource: { + object: 'order', + filter: { status: 'completed' }, + limit: 100, + }, + }); + + expect(component.dataSource?.object).toBe('order'); + expect(component.dataSource?.limit).toBe(100); + }); + + it('should accept component without dataSource', () => { + const component = PageComponentSchema.parse({ + type: 'element:text', + properties: { content: 'Static text' }, + }); + + expect(component.dataSource).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Element Props Schemas +// --------------------------------------------------------------------------- +describe('ElementTextPropsSchema', () => { + it('should accept minimal text props', () => { + const props = ElementTextPropsSchema.parse({ content: 'Hello' }); + expect(props.content).toBe('Hello'); + expect(props.variant).toBe('body'); + expect(props.align).toBe('left'); + }); + + it('should accept full text props', () => { + const props = ElementTextPropsSchema.parse({ + content: '# Welcome', + variant: 'heading', + align: 'center', + }); + expect(props.variant).toBe('heading'); + expect(props.align).toBe('center'); + }); + + it('should accept all variants', () => { + const variants = ['heading', 'subheading', 'body', 'caption'] as const; + variants.forEach(variant => { + expect(() => ElementTextPropsSchema.parse({ content: 'Test', variant })).not.toThrow(); + }); + }); + + it('should reject without content', () => { + expect(() => ElementTextPropsSchema.parse({})).toThrow(); + }); +}); + +describe('ElementNumberPropsSchema', () => { + it('should accept minimal number props', () => { + const props = ElementNumberPropsSchema.parse({ + object: 'order', + aggregate: 'count', + }); + expect(props.object).toBe('order'); + expect(props.aggregate).toBe('count'); + expect(props.field).toBeUndefined(); + }); + + it('should accept full number props', () => { + const props = ElementNumberPropsSchema.parse({ + object: 'order', + field: 'amount', + aggregate: 'sum', + filter: { status: 'paid' }, + format: 'currency', + prefix: '$', + suffix: ' USD', + }); + expect(props.format).toBe('currency'); + expect(props.prefix).toBe('$'); + expect(props.suffix).toBe(' USD'); + }); + + it('should accept all aggregate functions', () => { + const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const; + aggregates.forEach(aggregate => { + expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate })).not.toThrow(); + }); + }); + + it('should accept all format options', () => { + const formats = ['number', 'currency', 'percent'] as const; + formats.forEach(format => { + expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate: 'count', format })).not.toThrow(); + }); + }); + + it('should reject without required fields', () => { + expect(() => ElementNumberPropsSchema.parse({})).toThrow(); + expect(() => ElementNumberPropsSchema.parse({ object: 'order' })).toThrow(); + }); +}); + +describe('ElementImagePropsSchema', () => { + it('should accept minimal image props', () => { + const props = ElementImagePropsSchema.parse({ src: '/images/hero.jpg' }); + expect(props.src).toBe('/images/hero.jpg'); + expect(props.fit).toBe('cover'); + }); + + it('should accept full image props', () => { + const props = ElementImagePropsSchema.parse({ + src: '/images/banner.png', + alt: 'Company banner', + fit: 'contain', + height: 200, + }); + expect(props.alt).toBe('Company banner'); + expect(props.fit).toBe('contain'); + expect(props.height).toBe(200); + }); + + it('should accept all fit modes', () => { + const fits = ['cover', 'contain', 'fill'] as const; + fits.forEach(fit => { + expect(() => ElementImagePropsSchema.parse({ src: '/img.png', fit })).not.toThrow(); + }); + }); + + it('should reject without src', () => { + expect(() => ElementImagePropsSchema.parse({})).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ComponentPropsMap content elements +// --------------------------------------------------------------------------- +describe('ComponentPropsMap content elements', () => { + it('should contain element:text', () => { + expect(ComponentPropsMap['element:text']).toBeDefined(); + }); + + it('should contain element:number', () => { + expect(ComponentPropsMap['element:number']).toBeDefined(); + }); + + it('should contain element:image', () => { + expect(ComponentPropsMap['element:image']).toBeDefined(); + }); + + it('should contain element:divider', () => { + expect(ComponentPropsMap['element:divider']).toBeDefined(); + }); + + it('should parse element:text props', () => { + const result = ComponentPropsMap['element:text'].parse({ content: 'Hello' }); + expect(result.content).toBe('Hello'); + }); + + it('should parse element:number props', () => { + const result = ComponentPropsMap['element:number'].parse({ + object: 'order', + aggregate: 'count', + }); + expect(result.object).toBe('order'); + }); + + it('should parse element:image props', () => { + const result = ComponentPropsMap['element:image'].parse({ src: '/img.png' }); + expect(result.src).toBe('/img.png'); + }); + + it('should parse element:divider (empty props)', () => { + expect(() => ComponentPropsMap['element:divider'].parse({})).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end: Full interface with all features +// --------------------------------------------------------------------------- +describe('Interface end-to-end', () => { + it('should accept a complete real-world interface definition', () => { + const iface = defineInterface({ + name: 'order_management', + label: 'Order Management', + description: 'Complete order management interface', + object: 'order', + pages: [ + { + id: 'page_overview', + label: 'Overview', + type: 'dashboard', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:text', + properties: { content: '# Order Dashboard', variant: 'heading' }, + }, + { + type: 'element:number', + properties: { object: 'order', aggregate: 'count' }, + dataSource: { object: 'order', filter: { status: 'pending' } }, + }, + { + type: 'element:number', + properties: { object: 'order', aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, + dataSource: { object: 'order', filter: { status: 'completed' } }, + }, + { + type: 'element:divider', + properties: {}, + }, + { + type: 'element:image', + properties: { src: '/images/banner.jpg', alt: 'Order management', fit: 'cover', height: 200 }, + }, + ], + }, + ], + }, + { + id: 'page_review', + label: 'Review Queue', + type: 'record_review', + object: 'order', + recordReview: { + object: 'order', + filter: { status: 'pending_review' }, + sort: [{ field: 'priority', order: 'desc' }], + displayFields: ['customer_name', 'total', 'items_count'], + actions: [ + { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, + { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + { label: 'Skip', type: 'skip' }, + ], + navigation: 'sequential', + showProgress: true, + }, + regions: [], + }, + { + id: 'page_grid', + label: 'All Orders', + type: 'grid', + object: 'order', + regions: [], + }, + ], + homePageId: 'page_overview', + branding: { primaryColor: '#2563EB', logo: '/logos/orders.png' }, + assignedRoles: ['order_manager', 'admin'], + isDefault: true, + }); + + expect(iface.name).toBe('order_management'); + expect(iface.pages).toHaveLength(3); + expect(iface.pages[1].recordReview?.actions).toHaveLength(3); + expect(iface.branding?.primaryColor).toBe('#2563EB'); + expect(iface.assignedRoles).toEqual(['order_manager', 'admin']); + }); +}); diff --git a/packages/spec/src/ui/interface.zod.ts b/packages/spec/src/ui/interface.zod.ts new file mode 100644 index 000000000..ac4446f26 --- /dev/null +++ b/packages/spec/src/ui/interface.zod.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { PageRegionSchema, PageVariableSchema } from './page.zod'; + +/** + * Interface Page Type Schema + * Page types available within an Interface (Airtable Interface parity). + */ +export const InterfacePageTypeSchema = z.enum([ + 'dashboard', // KPI summary with charts/metrics + 'grid', // Spreadsheet-like data table + 'list', // Record list with quick actions + 'gallery', // Card-based visual browsing + 'kanban', // Status-based board + 'calendar', // Date-based scheduling + 'timeline', // Gantt-like project timeline + 'form', // Data entry form + 'record_detail', // Single record deep-dive + 'record_review', // Sequential record review/approval + 'overview', // Landing/navigation hub + 'blank', // Free-form canvas +]); + +/** + * Record Review Config Schema + * Configuration for a sequential record review/approval page. + * Users navigate through records one-by-one, taking actions (approve/reject/skip). + */ +export const RecordReviewConfigSchema = z.object({ + object: z.string().describe('Target object for review'), + filter: z.any().optional().describe('Filter criteria for review queue'), + sort: z.array(z.object({ + field: z.string().describe('Field name to sort by'), + order: z.enum(['asc', 'desc']).describe('Sort direction'), + })).optional().describe('Sort order for review queue'), + displayFields: z.array(z.string()).optional() + .describe('Fields to display on the review page'), + actions: z.array(z.object({ + label: z.string().describe('Action button label'), + type: z.enum(['approve', 'reject', 'skip', 'custom']) + .describe('Action type'), + field: z.string().optional() + .describe('Field to update on action'), + value: z.any().optional() + .describe('Value to set on action'), + nextRecord: z.boolean().optional().default(true) + .describe('Auto-advance to next record after action'), + })).describe('Review actions'), + navigation: z.enum(['sequential', 'random', 'filtered']) + .optional().default('sequential') + .describe('Record navigation mode'), + showProgress: z.boolean().optional().default(true) + .describe('Show review progress indicator'), +}); + +/** + * Interface Page Schema + * A page within an Interface, with Airtable-inspired page types. + */ +export const InterfacePageSchema = z.object({ + id: z.string().describe('Unique page identifier within the interface'), + label: I18nLabelSchema.describe('Page display label'), + description: I18nLabelSchema.optional().describe('Page description'), + icon: z.string().optional().describe('Page icon name'), + + /** Page Type */ + type: InterfacePageTypeSchema.default('blank').describe('Page type'), + + /** Object Context */ + object: z.string().optional().describe('Bound object (for data-driven page types)'), + + /** Record Review Configuration (only for record_review pages) */ + recordReview: RecordReviewConfigSchema.optional() + .describe('Record review configuration (required when type is "record_review")'), + + /** Page State Definitions */ + variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), + + /** Layout Template */ + template: z.string().default('default').describe('Layout template name'), + + /** Regions & Content */ + regions: z.array(PageRegionSchema).describe('Defined regions with components'), + + /** ARIA accessibility attributes */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +/** + * Interface Branding Schema + * Visual branding overrides for an interface. + */ +export const InterfaceBrandingSchema = z.object({ + primaryColor: z.string().optional().describe('Primary theme color hex code'), + logo: z.string().optional().describe('Custom logo URL'), + coverImage: z.string().optional().describe('Cover image URL for the interface landing'), +}); + +/** + * Interface Schema + * A self-contained, shareable, multi-page application surface. + * + * Unlike `AppSchema` (which is a navigation container for the full platform), + * an Interface is a focused, role-specific surface that stitches together + * views, elements, and actions into a cohesive experience. + * + * An App can contain multiple Interfaces. + * + * **NAMING CONVENTION:** + * Interface names must be lowercase snake_case. + * + * @example + * ```ts + * const reviewInterface = defineInterface({ + * name: 'order_review', + * label: 'Order Review', + * object: 'order', + * pages: [ + * { + * id: 'review_queue', + * label: 'Review Queue', + * type: 'record_review', + * object: 'order', + * recordReview: { + * object: 'order', + * actions: [ + * { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, + * { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + * ], + * }, + * regions: [], + * }, + * ], + * }); + * ``` + */ +export const InterfaceSchema = z.object({ + name: SnakeCaseIdentifierSchema.describe('Interface unique machine name (lowercase snake_case)'), + label: I18nLabelSchema.describe('Interface display label'), + description: I18nLabelSchema.optional().describe('Interface purpose description'), + + /** Primary object binding */ + object: z.string().optional().describe('Primary object binding (snake_case)'), + + /** Pages */ + pages: z.array(InterfacePageSchema).describe('Ordered list of pages in this interface'), + + /** Default landing page */ + homePageId: z.string().optional().describe('Default landing page ID'), + + /** Visual branding */ + branding: InterfaceBrandingSchema.optional().describe('Visual branding overrides'), + + /** Access control */ + assignedRoles: z.array(z.string()).optional().describe('Roles that can access this interface'), + + /** Default flag */ + isDefault: z.boolean().optional().describe('Whether this is the default interface for the object'), + + /** ARIA accessibility attributes */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +/** + * Type-safe factory for creating interface definitions. + */ +export function defineInterface(config: z.input): Interface { + return InterfaceSchema.parse(config); +} + +// Type Exports +export type Interface = z.infer; +export type InterfaceInput = z.input; +export type InterfacePage = z.infer; +export type InterfacePageType = z.infer; +export type InterfaceBranding = z.infer; +export type RecordReviewConfig = z.infer; diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index ded64bb4e..10673c673 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -28,9 +28,27 @@ export const PageComponentType = z.enum([ // Utility 'global:search', 'global:notifications', 'user:profile', // AI - 'ai:chat_window', 'ai:suggestion' + 'ai:chat_window', 'ai:suggestion', + // Content Elements (Airtable Interface parity) + 'element:text', 'element:number', 'element:image', 'element:divider' ]); +/** + * Element Data Source Schema + * Per-element data binding for multi-object pages. + * Overrides page-level object context so each element can query a different object. + */ +export const ElementDataSourceSchema = z.object({ + object: z.string().describe('Object to query'), + view: z.string().optional().describe('Named view to apply'), + filter: z.any().optional().describe('Additional filter criteria'), + sort: z.array(z.object({ + field: z.string().describe('Field name to sort by'), + order: z.enum(['asc', 'desc']).describe('Sort direction'), + })).optional().describe('Sort order'), + limit: z.number().int().positive().optional().describe('Max records to display'), +}); + /** * Page Component Schema * A configured instance of a UI component. @@ -62,6 +80,9 @@ export const PageComponentSchema = z.object({ /** Visibility Rule */ visibility: z.string().optional().describe('Visibility filter/formula'), + /** Per-element data binding, overrides page-level object context */ + dataSource: ElementDataSourceSchema.optional().describe('Per-element data binding for multi-object pages'), + /** Responsive layout overrides per breakpoint */ responsive: ResponsiveConfigSchema.optional().describe('Responsive layout configuration'), @@ -130,3 +151,4 @@ export type Page = z.infer; export type PageComponent = z.infer; export type PageRegion = z.infer; export type PageVariable = z.infer; +export type ElementDataSource = z.infer; From a0a3d1afe37d6f45b3d3dd05f6d5a360e23ea9fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:38:38 +0000 Subject: [PATCH 3/8] =?UTF-8?q?refactor(ui):=20reduce=20overlap=20?= =?UTF-8?q?=E2=80=94=20extract=20SortItemSchema,=20extend=20AppBrandingSch?= =?UTF-8?q?ema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shared SortItemSchema to enums.zod.ts (reuses SortDirectionEnum) - ElementDataSourceSchema now uses shared SortItemSchema instead of inline sort - RecordReviewConfigSchema now uses shared SortItemSchema instead of inline sort - InterfaceBrandingSchema now extends AppBrandingSchema (adds coverImage only) - Add SortItemSchema tests to enums.test.ts Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/shared/enums.test.ts | 21 +++++++++++++++++++++ packages/spec/src/shared/enums.zod.ts | 7 +++++++ packages/spec/src/ui/interface.zod.ts | 12 +++++------- packages/spec/src/ui/page.zod.ts | 6 ++---- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/spec/src/shared/enums.test.ts b/packages/spec/src/shared/enums.test.ts index dafa50a04..fa3861ff7 100644 --- a/packages/spec/src/shared/enums.test.ts +++ b/packages/spec/src/shared/enums.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { AggregationFunctionEnum, SortDirectionEnum, + SortItemSchema, MutationEventEnum, IsolationLevelEnum, CacheStrategyEnum, @@ -46,6 +47,26 @@ describe('SortDirectionEnum', () => { }); }); +describe('SortItemSchema', () => { + it('should accept valid sort item', () => { + const result = SortItemSchema.parse({ field: 'created_at', order: 'desc' }); + expect(result.field).toBe('created_at'); + expect(result.order).toBe('desc'); + }); + + it('should reject without field', () => { + expect(() => SortItemSchema.parse({ order: 'asc' })).toThrow(); + }); + + it('should reject without order', () => { + expect(() => SortItemSchema.parse({ field: 'name' })).toThrow(); + }); + + it('should reject invalid order direction', () => { + expect(() => SortItemSchema.parse({ field: 'name', order: 'up' })).toThrow(); + }); +}); + describe('MutationEventEnum', () => { it('should accept all mutation events', () => { const valid = ['insert', 'update', 'delete', 'upsert']; diff --git a/packages/spec/src/shared/enums.zod.ts b/packages/spec/src/shared/enums.zod.ts index f9fabee4d..d945f2d79 100644 --- a/packages/spec/src/shared/enums.zod.ts +++ b/packages/spec/src/shared/enums.zod.ts @@ -18,6 +18,13 @@ export const SortDirectionEnum = z.enum(['asc', 'desc']) .describe('Sort order direction'); export type SortDirection = z.infer; +/** Reusable sort item — field + direction pair used across views, data sources, filters */ +export const SortItemSchema = z.object({ + field: z.string().describe('Field name to sort by'), + order: SortDirectionEnum.describe('Sort direction'), +}).describe('Sort field and direction pair'); +export type SortItem = z.infer; + /** CRUD mutation events used across hook, validation, object CDC */ export const MutationEventEnum = z.enum([ 'insert', 'update', 'delete', 'upsert', diff --git a/packages/spec/src/ui/interface.zod.ts b/packages/spec/src/ui/interface.zod.ts index ac4446f26..6d8d74bd1 100644 --- a/packages/spec/src/ui/interface.zod.ts +++ b/packages/spec/src/ui/interface.zod.ts @@ -2,8 +2,10 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { SortItemSchema } from '../shared/enums.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { PageRegionSchema, PageVariableSchema } from './page.zod'; +import { AppBrandingSchema } from './app.zod'; /** * Interface Page Type Schema @@ -32,10 +34,7 @@ export const InterfacePageTypeSchema = z.enum([ export const RecordReviewConfigSchema = z.object({ object: z.string().describe('Target object for review'), filter: z.any().optional().describe('Filter criteria for review queue'), - sort: z.array(z.object({ - field: z.string().describe('Field name to sort by'), - order: z.enum(['asc', 'desc']).describe('Sort direction'), - })).optional().describe('Sort order for review queue'), + sort: z.array(SortItemSchema).optional().describe('Sort order for review queue'), displayFields: z.array(z.string()).optional() .describe('Fields to display on the review page'), actions: z.array(z.object({ @@ -92,10 +91,9 @@ export const InterfacePageSchema = z.object({ /** * Interface Branding Schema * Visual branding overrides for an interface. + * Extends AppBrandingSchema with interface-specific properties (coverImage). */ -export const InterfaceBrandingSchema = z.object({ - primaryColor: z.string().optional().describe('Primary theme color hex code'), - logo: z.string().optional().describe('Custom logo URL'), +export const InterfaceBrandingSchema = AppBrandingSchema.extend({ coverImage: z.string().optional().describe('Cover image URL for the interface landing'), }); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 10673c673..bcb24eca0 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { SortItemSchema } from '../shared/enums.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ResponsiveConfigSchema } from './responsive.zod'; @@ -42,10 +43,7 @@ export const ElementDataSourceSchema = z.object({ object: z.string().describe('Object to query'), view: z.string().optional().describe('Named view to apply'), filter: z.any().optional().describe('Additional filter criteria'), - sort: z.array(z.object({ - field: z.string().describe('Field name to sort by'), - order: z.enum(['asc', 'desc']).describe('Sort direction'), - })).optional().describe('Sort order'), + sort: z.array(SortItemSchema).optional().describe('Sort order'), limit: z.number().int().positive().optional().describe('Max records to display'), }); From 11b01571c5f20bbe73d894aaefec40b7910f3e4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:58:17 +0000 Subject: [PATCH 4/8] =?UTF-8?q?refactor(ui):=20merge=20InterfacePageSchema?= =?UTF-8?q?=20into=20PageSchema=20=E2=80=94=20unified=20page=20type=20syst?= =?UTF-8?q?em?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand PageSchema.type to include all 16 types (4 platform + 12 interface) - Move RecordReviewConfigSchema from interface.zod.ts to page.zod.ts - Add icon and recordReview properties to PageSchema - Export new PageTypeSchema for the unified enum - InterfaceSchema.pages now uses PageSchema directly - Remove InterfacePageSchema, InterfacePageTypeSchema (merged into PageSchema) - Update all tests for the merged structure - All 199 test files (5468 tests) pass Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/ui/interface.test.ts | 90 ++++++++++++++--------- packages/spec/src/ui/interface.zod.ts | 98 +++----------------------- packages/spec/src/ui/page.test.ts | 6 +- packages/spec/src/ui/page.zod.ts | 72 ++++++++++++++++++- 4 files changed, 137 insertions(+), 129 deletions(-) diff --git a/packages/spec/src/ui/interface.test.ts b/packages/spec/src/ui/interface.test.ts index 55d9287ca..8d6b7823d 100644 --- a/packages/spec/src/ui/interface.test.ts +++ b/packages/spec/src/ui/interface.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect } from 'vitest'; import { InterfaceSchema, - InterfacePageSchema, - InterfacePageTypeSchema, InterfaceBrandingSchema, - RecordReviewConfigSchema, defineInterface, type Interface, - type InterfacePage, - type RecordReviewConfig, } from './interface.zod'; import { + PageSchema, + PageTypeSchema, PageComponentSchema, + RecordReviewConfigSchema, ElementDataSourceSchema, + type Page, type ElementDataSource, + type RecordReviewConfig, } from './page.zod'; import { ElementTextPropsSchema, @@ -23,22 +23,29 @@ import { } from './component.zod'; // --------------------------------------------------------------------------- -// InterfacePageTypeSchema +// PageTypeSchema — unified page types (platform + interface) // --------------------------------------------------------------------------- -describe('InterfacePageTypeSchema', () => { - it('should accept all valid page types', () => { +describe('PageTypeSchema', () => { + it('should accept all platform page types', () => { + const types = ['record', 'home', 'app', 'utility']; + types.forEach(type => { + expect(() => PageTypeSchema.parse(type)).not.toThrow(); + }); + }); + + it('should accept all interface page types', () => { const types = [ 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', ]; types.forEach(type => { - expect(() => InterfacePageTypeSchema.parse(type)).not.toThrow(); + expect(() => PageTypeSchema.parse(type)).not.toThrow(); }); }); it('should reject invalid page type', () => { - expect(() => InterfacePageTypeSchema.parse('invalid')).toThrow(); + expect(() => PageTypeSchema.parse('invalid')).toThrow(); }); }); @@ -141,24 +148,25 @@ describe('InterfaceBrandingSchema', () => { }); // --------------------------------------------------------------------------- -// InterfacePageSchema +// PageSchema — interface page types (merged from InterfacePageSchema) // --------------------------------------------------------------------------- -describe('InterfacePageSchema', () => { - it('should accept minimal page', () => { - const page: InterfacePage = InterfacePageSchema.parse({ - id: 'page_overview', +describe('PageSchema with interface page types', () => { + it('should accept minimal interface-style page', () => { + const page: Page = PageSchema.parse({ + name: 'page_overview', label: 'Overview', + type: 'blank', regions: [], }); - expect(page.id).toBe('page_overview'); + expect(page.name).toBe('page_overview'); expect(page.type).toBe('blank'); expect(page.template).toBe('default'); }); it('should accept dashboard page', () => { - const page = InterfacePageSchema.parse({ - id: 'page_dashboard', + const page = PageSchema.parse({ + name: 'page_dashboard', label: 'Dashboard', type: 'dashboard', regions: [ @@ -176,8 +184,8 @@ describe('InterfacePageSchema', () => { }); it('should accept record_review page with config', () => { - const page = InterfacePageSchema.parse({ - id: 'page_review', + const page = PageSchema.parse({ + name: 'page_review', label: 'Review Queue', type: 'record_review', object: 'order', @@ -196,8 +204,8 @@ describe('InterfacePageSchema', () => { }); it('should accept page with variables', () => { - const page = InterfacePageSchema.parse({ - id: 'page_filtered', + const page = PageSchema.parse({ + name: 'page_filtered', label: 'Filtered View', type: 'blank', variables: [ @@ -210,15 +218,15 @@ describe('InterfacePageSchema', () => { expect(page.variables).toHaveLength(2); }); - it('should accept all page types', () => { + it('should accept all interface page types', () => { const types = [ 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', ]; types.forEach(type => { - expect(() => InterfacePageSchema.parse({ - id: 'test_page', + expect(() => PageSchema.parse({ + name: 'test_page', label: 'Test', type, regions: [], @@ -226,17 +234,29 @@ describe('InterfacePageSchema', () => { }); }); + it('should accept page with icon', () => { + const page = PageSchema.parse({ + name: 'page_with_icon', + label: 'Dashboard', + type: 'dashboard', + icon: 'bar-chart', + regions: [], + }); + + expect(page.icon).toBe('bar-chart'); + }); + it('should accept page with i18n label', () => { - expect(() => InterfacePageSchema.parse({ - id: 'i18n_page', + expect(() => PageSchema.parse({ + name: 'i18n_page', label: { key: 'interface.pages.overview', defaultValue: 'Overview' }, regions: [], })).not.toThrow(); }); it('should accept page with ARIA attributes', () => { - expect(() => InterfacePageSchema.parse({ - id: 'accessible_page', + expect(() => PageSchema.parse({ + name: 'accessible_page', label: 'Accessible Page', regions: [], aria: { ariaLabel: 'Interface overview page', role: 'main' }, @@ -268,13 +288,13 @@ describe('InterfaceSchema', () => { object: 'opportunity', pages: [ { - id: 'page_dashboard', + name: 'page_dashboard', label: 'Dashboard', type: 'dashboard', regions: [], }, { - id: 'page_pipeline', + name: 'page_pipeline', label: 'Pipeline', type: 'kanban', object: 'opportunity', @@ -362,7 +382,7 @@ describe('defineInterface', () => { label: 'HR Portal', pages: [ { - id: 'page_onboarding', + name: 'page_onboarding', label: 'Onboarding', type: 'overview', regions: [], @@ -653,7 +673,7 @@ describe('Interface end-to-end', () => { object: 'order', pages: [ { - id: 'page_overview', + name: 'page_overview', label: 'Overview', type: 'dashboard', regions: [ @@ -687,7 +707,7 @@ describe('Interface end-to-end', () => { ], }, { - id: 'page_review', + name: 'page_review', label: 'Review Queue', type: 'record_review', object: 'order', @@ -707,7 +727,7 @@ describe('Interface end-to-end', () => { regions: [], }, { - id: 'page_grid', + name: 'page_grid', label: 'All Orders', type: 'grid', object: 'order', diff --git a/packages/spec/src/ui/interface.zod.ts b/packages/spec/src/ui/interface.zod.ts index 6d8d74bd1..a841ecb87 100644 --- a/packages/spec/src/ui/interface.zod.ts +++ b/packages/spec/src/ui/interface.zod.ts @@ -2,92 +2,10 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; -import { SortItemSchema } from '../shared/enums.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; -import { PageRegionSchema, PageVariableSchema } from './page.zod'; +import { PageSchema } from './page.zod'; import { AppBrandingSchema } from './app.zod'; -/** - * Interface Page Type Schema - * Page types available within an Interface (Airtable Interface parity). - */ -export const InterfacePageTypeSchema = z.enum([ - 'dashboard', // KPI summary with charts/metrics - 'grid', // Spreadsheet-like data table - 'list', // Record list with quick actions - 'gallery', // Card-based visual browsing - 'kanban', // Status-based board - 'calendar', // Date-based scheduling - 'timeline', // Gantt-like project timeline - 'form', // Data entry form - 'record_detail', // Single record deep-dive - 'record_review', // Sequential record review/approval - 'overview', // Landing/navigation hub - 'blank', // Free-form canvas -]); - -/** - * Record Review Config Schema - * Configuration for a sequential record review/approval page. - * Users navigate through records one-by-one, taking actions (approve/reject/skip). - */ -export const RecordReviewConfigSchema = z.object({ - object: z.string().describe('Target object for review'), - filter: z.any().optional().describe('Filter criteria for review queue'), - sort: z.array(SortItemSchema).optional().describe('Sort order for review queue'), - displayFields: z.array(z.string()).optional() - .describe('Fields to display on the review page'), - actions: z.array(z.object({ - label: z.string().describe('Action button label'), - type: z.enum(['approve', 'reject', 'skip', 'custom']) - .describe('Action type'), - field: z.string().optional() - .describe('Field to update on action'), - value: z.any().optional() - .describe('Value to set on action'), - nextRecord: z.boolean().optional().default(true) - .describe('Auto-advance to next record after action'), - })).describe('Review actions'), - navigation: z.enum(['sequential', 'random', 'filtered']) - .optional().default('sequential') - .describe('Record navigation mode'), - showProgress: z.boolean().optional().default(true) - .describe('Show review progress indicator'), -}); - -/** - * Interface Page Schema - * A page within an Interface, with Airtable-inspired page types. - */ -export const InterfacePageSchema = z.object({ - id: z.string().describe('Unique page identifier within the interface'), - label: I18nLabelSchema.describe('Page display label'), - description: I18nLabelSchema.optional().describe('Page description'), - icon: z.string().optional().describe('Page icon name'), - - /** Page Type */ - type: InterfacePageTypeSchema.default('blank').describe('Page type'), - - /** Object Context */ - object: z.string().optional().describe('Bound object (for data-driven page types)'), - - /** Record Review Configuration (only for record_review pages) */ - recordReview: RecordReviewConfigSchema.optional() - .describe('Record review configuration (required when type is "record_review")'), - - /** Page State Definitions */ - variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), - - /** Layout Template */ - template: z.string().default('default').describe('Layout template name'), - - /** Regions & Content */ - regions: z.array(PageRegionSchema).describe('Defined regions with components'), - - /** ARIA accessibility attributes */ - aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -}); - /** * Interface Branding Schema * Visual branding overrides for an interface. @@ -107,6 +25,9 @@ export const InterfaceBrandingSchema = AppBrandingSchema.extend({ * * An App can contain multiple Interfaces. * + * Pages within an Interface use the unified `PageSchema` with interface page types + * (dashboard, grid, kanban, record_review, etc.). + * * **NAMING CONVENTION:** * Interface names must be lowercase snake_case. * @@ -118,7 +39,7 @@ export const InterfaceBrandingSchema = AppBrandingSchema.extend({ * object: 'order', * pages: [ * { - * id: 'review_queue', + * name: 'review_queue', * label: 'Review Queue', * type: 'record_review', * object: 'order', @@ -143,11 +64,11 @@ export const InterfaceSchema = z.object({ /** Primary object binding */ object: z.string().optional().describe('Primary object binding (snake_case)'), - /** Pages */ - pages: z.array(InterfacePageSchema).describe('Ordered list of pages in this interface'), + /** Pages — uses the unified PageSchema */ + pages: z.array(PageSchema).describe('Ordered list of pages in this interface'), /** Default landing page */ - homePageId: z.string().optional().describe('Default landing page ID'), + homePageId: z.string().optional().describe('Default landing page name'), /** Visual branding */ branding: InterfaceBrandingSchema.optional().describe('Visual branding overrides'), @@ -172,7 +93,4 @@ export function defineInterface(config: z.input): Interf // Type Exports export type Interface = z.infer; export type InterfaceInput = z.input; -export type InterfacePage = z.infer; -export type InterfacePageType = z.infer; export type InterfaceBranding = z.infer; -export type RecordReviewConfig = z.infer; diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 895ddf1bd..4d95656c1 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -191,7 +191,11 @@ describe('PageSchema', () => { }); it('should accept different page types', () => { - const types: Array = ['record', 'home', 'app', 'utility']; + const types: Array = [ + 'record', 'home', 'app', 'utility', + 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', + 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + ]; types.forEach(type => { const page = PageSchema.parse({ diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index bcb24eca0..8b35ed9ea 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -98,10 +98,67 @@ export const PageVariableSchema = z.object({ defaultValue: z.unknown().optional(), }); +/** + * Page Type Schema + * Unified page type enum covering both platform pages (record, home, app, utility) + * and Airtable-inspired interface page types (dashboard, grid, kanban, etc.). + */ +export const PageTypeSchema = z.enum([ + // Platform page types + 'record', // Record detail page (Salesforce FlexiPage) + 'home', // Home/landing page + 'app', // App-level page + 'utility', // Utility panel + // Interface page types (Airtable Interface parity) + 'dashboard', // KPI summary with charts/metrics + 'grid', // Spreadsheet-like data table + 'list', // Record list with quick actions + 'gallery', // Card-based visual browsing + 'kanban', // Status-based board + 'calendar', // Date-based scheduling + 'timeline', // Gantt-like project timeline + 'form', // Data entry form + 'record_detail', // Single record deep-dive + 'record_review', // Sequential record review/approval + 'overview', // Landing/navigation hub + 'blank', // Free-form canvas +]).describe('Page type — platform or interface page types'); + +/** + * Record Review Config Schema + * Configuration for a sequential record review/approval page. + * Users navigate through records one-by-one, taking actions (approve/reject/skip). + * Only applicable when page type is 'record_review'. + */ +export const RecordReviewConfigSchema = z.object({ + object: z.string().describe('Target object for review'), + filter: z.any().optional().describe('Filter criteria for review queue'), + sort: z.array(SortItemSchema).optional().describe('Sort order for review queue'), + displayFields: z.array(z.string()).optional() + .describe('Fields to display on the review page'), + actions: z.array(z.object({ + label: z.string().describe('Action button label'), + type: z.enum(['approve', 'reject', 'skip', 'custom']) + .describe('Action type'), + field: z.string().optional() + .describe('Field to update on action'), + value: z.any().optional() + .describe('Value to set on action'), + nextRecord: z.boolean().optional().default(true) + .describe('Auto-advance to next record after action'), + })).describe('Review actions'), + navigation: z.enum(['sequential', 'random', 'filtered']) + .optional().default('sequential') + .describe('Record navigation mode'), + showProgress: z.boolean().optional().default(true) + .describe('Show review progress indicator'), +}); + /** * Page Schema - * Defines a composition of components for a specific context (Record, Home, App). - * Compare to Salesforce FlexiPage. + * Defines a composition of components for a specific context. + * Supports both platform pages (Salesforce FlexiPage style: record, home, app, utility) + * and interface pages (Airtable Interface style: dashboard, grid, kanban, record_review, etc.). * * **NAMING CONVENTION:** * Page names are used in routing and must be lowercase snake_case. @@ -121,15 +178,22 @@ export const PageSchema = z.object({ name: SnakeCaseIdentifierSchema.describe('Page unique name (lowercase snake_case)'), label: I18nLabelSchema, description: I18nLabelSchema.optional(), + + /** Icon (used in interface navigation) */ + icon: z.string().optional().describe('Page icon name'), /** Page Type */ - type: z.enum(['record', 'home', 'app', 'utility']).default('record'), + type: PageTypeSchema.default('record').describe('Page type'), /** Page State Definitions */ variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), /** Context */ object: z.string().optional().describe('Bound object (for Record pages)'), + + /** Record Review Configuration (only for record_review pages) */ + recordReview: RecordReviewConfigSchema.optional() + .describe('Record review configuration (required when type is "record_review")'), /** Layout Template */ template: z.string().default('default').describe('Layout template name (e.g. "header-sidebar-main")'), @@ -146,7 +210,9 @@ export const PageSchema = z.object({ }); export type Page = z.infer; +export type PageType = z.infer; export type PageComponent = z.infer; export type PageRegion = z.infer; export type PageVariable = z.infer; export type ElementDataSource = z.infer; +export type RecordReviewConfig = z.infer; From 420c89684eaa40b42525eec92394d12369e02fb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:01:09 +0000 Subject: [PATCH 5/8] fix(ui): rename homePageId to homePageName for consistency with PageSchema.name Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/ui/interface.test.ts | 6 +++--- packages/spec/src/ui/interface.zod.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ui/interface.test.ts b/packages/spec/src/ui/interface.test.ts index 8d6b7823d..84881655f 100644 --- a/packages/spec/src/ui/interface.test.ts +++ b/packages/spec/src/ui/interface.test.ts @@ -301,7 +301,7 @@ describe('InterfaceSchema', () => { regions: [], }, ], - homePageId: 'page_dashboard', + homePageName: 'page_dashboard', branding: { primaryColor: '#1A73E8', logo: '/logos/sales.png', @@ -311,7 +311,7 @@ describe('InterfaceSchema', () => { }); expect(iface.pages).toHaveLength(2); - expect(iface.homePageId).toBe('page_dashboard'); + expect(iface.homePageName).toBe('page_dashboard'); expect(iface.assignedRoles).toHaveLength(2); expect(iface.isDefault).toBe(true); }); @@ -734,7 +734,7 @@ describe('Interface end-to-end', () => { regions: [], }, ], - homePageId: 'page_overview', + homePageName: 'page_overview', branding: { primaryColor: '#2563EB', logo: '/logos/orders.png' }, assignedRoles: ['order_manager', 'admin'], isDefault: true, diff --git a/packages/spec/src/ui/interface.zod.ts b/packages/spec/src/ui/interface.zod.ts index a841ecb87..0ff8b1ff1 100644 --- a/packages/spec/src/ui/interface.zod.ts +++ b/packages/spec/src/ui/interface.zod.ts @@ -68,7 +68,7 @@ export const InterfaceSchema = z.object({ pages: z.array(PageSchema).describe('Ordered list of pages in this interface'), /** Default landing page */ - homePageId: z.string().optional().describe('Default landing page name'), + homePageName: z.string().optional().describe('Default landing page name'), /** Visual branding */ branding: InterfaceBrandingSchema.optional().describe('Visual branding overrides'), From 0e355438fa8f80a360f0ff52ac1e21a9016c412b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:33:01 +0000 Subject: [PATCH 6/8] docs: update ROADMAP and design doc for Phase A completion, export defineInterface - Export defineInterface from root index.ts (reviewer suggestion) - Mark ROADMAP Phase A items as complete - Update ROADMAP metrics (199 test files, 5468 tests, 177 schema files) - Update Phase 1 deliverables with Interface/content elements - Add defineInterface() to DX helper list - Update design doc status from "Proposal" to "Phase A Implemented" - Mark design doc Phase A checklist items complete - Add decisions #6-8 to design doc decision log (PageSchema merge, SortItemSchema, branding extend) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 22 ++++++++--------- .../design/airtable-interface-gap-analysis.md | 24 ++++++++++++------- packages/spec/src/index.ts | 1 + 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fa47a0efc..ba1738dad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,13 +73,13 @@ These are the backbone of ObjectStack's enterprise capabilities. | Packages (total) | 27 | | Apps | 2 (Studio, Docs) | | Examples | 4 (Todo, CRM, Host, BI Plugin) | -| Zod Schema Files | 176 | +| Zod Schema Files | 177 | | Exported Schemas | 1,100+ | | `.describe()` Annotations | 7,111+ | | Service Contracts | 25 | | Contracts Implemented | 11 (44%) | -| Test Files | 197 | -| Tests Passing | 5,363 / 5,363 | +| Test Files | 199 | +| Tests Passing | 5,468 / 5,468 | | `@deprecated` Items | 3 | | Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) | @@ -129,7 +129,7 @@ The following renames are planned for packages that implement core service contr - [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document - [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions -- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page, Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n +- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Interface, Content Elements - [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services - [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook - [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development @@ -142,7 +142,7 @@ The following renames are planned for packages that implement core service contr - [x] **QA Protocol** — Testing framework schemas - [x] **Studio Protocol** — Plugin extension schemas - [x] **Contracts** — 25 service interfaces with full method signatures -- [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineFlow()`, `defineAgent()` helpers +- [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineInterface()`, `defineFlow()`, `defineAgent()` helpers - [x] **Error Map** — Custom Zod error messages with `objectStackErrorMap` - [x] **DX Utilities** — `safeParsePretty()`, `formatZodError()`, `suggestFieldType()` @@ -356,13 +356,13 @@ The following renames are planned for packages that implement core service contr > See [Airtable Interface Gap Analysis](docs/design/airtable-interface-gap-analysis.md) for the full evaluation. -#### Phase A: Interface Foundation (v3.2) +#### Phase A: Interface Foundation (v3.2) ✅ -- [ ] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`) -- [ ] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions -- [ ] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions -- [ ] Per-element data binding — `dataSource` property on `PageComponentInstanceSchema` for multi-object pages -- [ ] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` +- [x] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`) +- [x] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions +- [x] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions +- [x] Per-element data binding — `dataSource` property on `PageComponentSchema` for multi-object pages +- [x] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` #### Phase B: Element Library & Builder (v3.3) diff --git a/docs/design/airtable-interface-gap-analysis.md b/docs/design/airtable-interface-gap-analysis.md index 74a12281c..dbf0060d9 100644 --- a/docs/design/airtable-interface-gap-analysis.md +++ b/docs/design/airtable-interface-gap-analysis.md @@ -2,7 +2,7 @@ > **Author:** ObjectStack Core Team > **Created:** 2026-02-16 -> **Status:** Proposal +> **Status:** Phase A Implemented > **Target Version:** v3.2 – v4.0 --- @@ -562,17 +562,20 @@ export const EmbedConfigSchema = z.object({ ## 7. Implementation Road Map -### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026) +### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026) ✅ > **Goal:** Establish the "Interface" abstraction as a first-class protocol entity. -- [ ] Define `InterfaceSchema` in `src/ui/interface.zod.ts` -- [ ] Add `RecordReviewConfigSchema` to `PageSchema` types -- [ ] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`) -- [ ] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props -- [ ] Add `dataSource` property to `PageComponentInstanceSchema` for per-element data binding -- [ ] Write comprehensive tests for all new schemas -- [ ] Update `src/ui/index.ts` exports +- [x] Define `InterfaceSchema` in `src/ui/interface.zod.ts` +- [x] Add `RecordReviewConfigSchema` to `PageSchema` types +- [x] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`) +- [x] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props +- [x] Add `dataSource` property to `PageComponentSchema` for per-element data binding +- [x] Write comprehensive tests for all new schemas +- [x] Update `src/ui/index.ts` exports +- [x] Merge `InterfacePageSchema` into `PageSchema` — unified `PageTypeSchema` with 16 types +- [x] Extract shared `SortItemSchema` to `shared/enums.zod.ts` +- [x] Export `defineInterface()` from root index.ts - [ ] Generate JSON Schema for new types **Estimated effort:** 2–3 weeks @@ -644,6 +647,9 @@ export const EmbedConfigSchema = z.object({ | 3 | Phase sharing/embedding to v4.0 | Requires security infrastructure (RLS, share tokens, origin validation) that depends on service implementations in v3.x | 2026-02-16 | | 4 | Keep `RecordReviewConfig` as part of `PageSchema` rather than a new view type | Record Review is a page layout pattern, not a data visualization (view). It combines record display with workflow actions. | 2026-02-16 | | 5 | Support per-element `dataSource` instead of page-level-only binding | Critical for dashboards and overview pages that aggregate data from multiple objects | 2026-02-16 | +| 6 | Merge `InterfacePageSchema` into `PageSchema` | 7 of 9 properties were identical. Unified `PageTypeSchema` with 16 types (4 platform + 12 interface) eliminates duplication while preserving both use cases. `InterfaceSchema.pages` now references `PageSchema` directly. | 2026-02-16 | +| 7 | Extract shared `SortItemSchema` to `shared/enums.zod.ts` | Sort item pattern `{ field, order }` was defined inline in 4+ schemas (ElementDataSource, RecordReview, ListView, RecordRelatedList). Shared schema ensures consistency and reduces duplication. | 2026-02-16 | +| 8 | `InterfaceBrandingSchema` extends `AppBrandingSchema` | 2 of 3 fields (`primaryColor`, `logo`) were identical. Using `.extend()` adds only `coverImage`, avoiding property divergence. | 2026-02-16 | --- diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 0ffecaceb..b635a1a59 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -78,6 +78,7 @@ export * from './stack.zod'; // DX Helper Functions (re-exported for convenience) export { defineView } from './ui/view.zod'; export { defineApp } from './ui/app.zod'; +export { defineInterface } from './ui/interface.zod'; export { defineFlow } from './automation/flow.zod'; export { defineAgent } from './ai/agent.zod'; From 6e0ee6931ba6ff69b64ba52750505926e524ddaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:48:16 +0000 Subject: [PATCH 7/8] Initial plan From 35d526e00be6191e47dc555158fe733276632c5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:54:09 +0000 Subject: [PATCH 8/8] feat(ui): add InterfaceNavItemSchema, disambiguate PageTypeSchema, update design doc decisions Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../design/airtable-interface-gap-analysis.md | 17 ++-- packages/spec/src/ui/app.test.ts | 81 +++++++++++++++++++ packages/spec/src/ui/app.zod.ts | 15 +++- packages/spec/src/ui/page.zod.ts | 31 ++++--- 4 files changed, 127 insertions(+), 17 deletions(-) diff --git a/docs/design/airtable-interface-gap-analysis.md b/docs/design/airtable-interface-gap-analysis.md index dbf0060d9..3dbc4d751 100644 --- a/docs/design/airtable-interface-gap-analysis.md +++ b/docs/design/airtable-interface-gap-analysis.md @@ -58,13 +58,13 @@ ties them together — specifically: | Area | Airtable | ObjectStack | |:---|:---|:---| -| **Interface as a first-class entity** | ✅ Multi-page app per base | 🟡 App + Page exist separately | +| **Interface as a first-class entity** | ✅ Multi-page app per base | ✅ `InterfaceSchema` + `InterfaceNavItemSchema` in App navigation | | **Drag-and-drop element canvas** | ✅ Free-form element placement | 🟡 Region-based composition | -| **Record Review workflow** | ✅ Built-in record-by-record review | ❌ Not modeled | -| **Element-level data binding** | ✅ Each element binds to any table/view | 🟡 Page-level object binding | -| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled | -| **Interface-level permissions** | ✅ Per-interface user access | 🟡 App-level permissions only | -| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled | +| **Record Review workflow** | ✅ Built-in record-by-record review | ✅ `RecordReviewConfigSchema` in `PageSchema` | +| **Element-level data binding** | ✅ Each element binds to any table/view | ✅ `ElementDataSourceSchema` per component | +| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled (Phase C) | +| **Interface-level permissions** | ✅ Per-interface user access | ✅ `assignedRoles` on `InterfaceSchema` | +| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled (Phase C) | This document proposes specific schema additions and a phased roadmap to close these gaps while preserving ObjectStack's superior extensibility and enterprise capabilities. @@ -576,6 +576,8 @@ export const EmbedConfigSchema = z.object({ - [x] Merge `InterfacePageSchema` into `PageSchema` — unified `PageTypeSchema` with 16 types - [x] Extract shared `SortItemSchema` to `shared/enums.zod.ts` - [x] Export `defineInterface()` from root index.ts +- [x] Add `InterfaceNavItemSchema` to `AppSchema` navigation for App↔Interface bridging +- [x] Disambiguate overlapping page types (`record`/`record_detail`, `home`/`overview`) in `PageTypeSchema` docs - [ ] Generate JSON Schema for new types **Estimated effort:** 2–3 weeks @@ -650,6 +652,9 @@ export const EmbedConfigSchema = z.object({ | 6 | Merge `InterfacePageSchema` into `PageSchema` | 7 of 9 properties were identical. Unified `PageTypeSchema` with 16 types (4 platform + 12 interface) eliminates duplication while preserving both use cases. `InterfaceSchema.pages` now references `PageSchema` directly. | 2026-02-16 | | 7 | Extract shared `SortItemSchema` to `shared/enums.zod.ts` | Sort item pattern `{ field, order }` was defined inline in 4+ schemas (ElementDataSource, RecordReview, ListView, RecordRelatedList). Shared schema ensures consistency and reduces duplication. | 2026-02-16 | | 8 | `InterfaceBrandingSchema` extends `AppBrandingSchema` | 2 of 3 fields (`primaryColor`, `logo`) were identical. Using `.extend()` adds only `coverImage`, avoiding property divergence. | 2026-02-16 | +| 9 | Keep `InterfaceSchema` and `AppSchema` separate — do NOT merge | **App** = navigation container (menu tree, routing, mobile nav). **Interface** = content surface (ordered pages, data binding, role-specific views). Merging would conflate navigation topology with page composition. An App can embed multiple Interfaces via `InterfaceNavItemSchema`. This mirrors Salesforce App/FlexiPage and Airtable Base/Interface separation. | 2026-02-16 | +| 10 | Add `InterfaceNavItemSchema` to bridge App↔Interface | `AppSchema.navigation` lacked a way to reference Interfaces. Added `type: 'interface'` nav item with `interfaceName` and optional `pageName` to enable App→Interface navigation without merging the schemas. | 2026-02-16 | +| 11 | Keep all 16 page types — no merge, disambiguate in docs | Reviewed overlapping pairs: `record` vs `record_detail` (component-based layout vs auto-generated field display), `home` vs `overview` (platform landing vs interface navigation hub), `app`/`utility`/`blank` (distinct layout contexts). Each serves a different use case at a different abstraction level. Added disambiguation comments to `PageTypeSchema`. | 2026-02-16 | --- diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 06d561732..c612138d4 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -7,6 +7,7 @@ import { DashboardNavItemSchema, PageNavItemSchema, UrlNavItemSchema, + InterfaceNavItemSchema, GroupNavItemSchema, defineApp, type App, @@ -127,6 +128,53 @@ describe('UrlNavItemSchema', () => { }); }); +describe('InterfaceNavItemSchema', () => { + it('should accept interface nav item with just interfaceName', () => { + const navItem = { + id: 'nav_order_review', + label: 'Order Review', + type: 'interface' as const, + interfaceName: 'order_review', + }; + + const result = InterfaceNavItemSchema.parse(navItem); + expect(result.interfaceName).toBe('order_review'); + expect(result.pageName).toBeUndefined(); + }); + + it('should accept interface nav item with pageName', () => { + const navItem = { + id: 'nav_sales_dashboard', + label: 'Sales Dashboard', + icon: 'layout-dashboard', + type: 'interface' as const, + interfaceName: 'sales_portal', + pageName: 'page_dashboard', + }; + + const result = InterfaceNavItemSchema.parse(navItem); + expect(result.interfaceName).toBe('sales_portal'); + expect(result.pageName).toBe('page_dashboard'); + }); + + it('should work in NavigationItemSchema union', () => { + expect(() => NavigationItemSchema.parse({ + id: 'nav_interface', + label: 'Interface', + type: 'interface', + interfaceName: 'my_interface', + })).not.toThrow(); + }); + + it('should reject without interfaceName', () => { + expect(() => InterfaceNavItemSchema.parse({ + id: 'nav_missing', + label: 'Missing', + type: 'interface', + })).toThrow(); + }); +}); + describe('GroupNavItemSchema', () => { it('should accept group nav item', () => { const navItem = { @@ -459,6 +507,39 @@ describe('AppSchema', () => { expect(() => AppSchema.parse(hrApp)).not.toThrow(); }); + + it('should accept app with interface navigation items', () => { + const app: App = { + name: 'data_platform', + label: 'Data Platform', + navigation: [ + { + id: 'nav_home', + label: 'Home', + icon: 'home', + type: 'dashboard', + dashboardName: 'main_dashboard', + }, + { + id: 'nav_order_review', + label: 'Order Review', + icon: 'clipboard-check', + type: 'interface', + interfaceName: 'order_review', + }, + { + id: 'nav_sales_portal', + label: 'Sales Portal', + icon: 'layout-dashboard', + type: 'interface', + interfaceName: 'sales_portal', + pageName: 'page_dashboard', + }, + ], + }; + + expect(() => AppSchema.parse(app)).not.toThrow(); + }); }); }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 55ba26f44..f1d2592b1 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -78,7 +78,18 @@ export const UrlNavItemSchema = BaseNavItemSchema.extend({ }); /** - * 5. Group Navigation Item + * 5. Interface Navigation Item + * Navigates to a specific Interface (self-contained multi-page surface). + * Bridges AppSchema (navigation container) with InterfaceSchema (content surface). + */ +export const InterfaceNavItemSchema = BaseNavItemSchema.extend({ + type: z.literal('interface'), + interfaceName: z.string().describe('Target interface name (snake_case)'), + pageName: z.string().optional().describe('Specific page within the interface to open'), +}); + +/** + * 6. Group Navigation Item * A container for child navigation items (Sub-menu). * Does not perform navigation itself. */ @@ -101,6 +112,7 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => DashboardNavItemSchema, PageNavItemSchema, UrlNavItemSchema, + InterfaceNavItemSchema, GroupNavItemSchema.extend({ children: z.array(NavigationItemSchema).describe('Child navigation items'), }) @@ -256,4 +268,5 @@ export type ObjectNavItem = z.infer; export type DashboardNavItem = z.infer; export type PageNavItem = z.infer; export type UrlNavItem = z.infer; +export type InterfaceNavItem = z.infer; export type GroupNavItem = z.infer & { children: NavigationItem[] }; diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 8b35ed9ea..6930be0c2 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -100,15 +100,26 @@ export const PageVariableSchema = z.object({ /** * Page Type Schema - * Unified page type enum covering both platform pages (record, home, app, utility) - * and Airtable-inspired interface page types (dashboard, grid, kanban, etc.). + * Unified page type enum covering both platform pages (Salesforce FlexiPage style) + * and Airtable-inspired interface page types. + * + * **Disambiguation of similar types:** + * - `record` vs `record_detail`: `record` is a component-based layout page (FlexiPage style with regions), + * `record_detail` is a field-display page showing all fields of a single record (Airtable style). + * Use `record` for custom record pages with regions/components, `record_detail` for auto-generated detail views. + * - `home` vs `overview`: `home` is the platform-level landing page (tab landing), + * `overview` is an interface-level navigation hub with links/instructions. + * Use `home` for app-level landing, `overview` for in-interface navigation hubs. + * - `app` vs `utility` vs `blank`: `app` is an app-level page with navigation context, + * `utility` is a floating utility panel (e.g. notes, phone), `blank` is a free-form canvas + * for custom composition. They serve distinct layout purposes. */ export const PageTypeSchema = z.enum([ - // Platform page types - 'record', // Record detail page (Salesforce FlexiPage) - 'home', // Home/landing page - 'app', // App-level page - 'utility', // Utility panel + // Platform page types (Salesforce FlexiPage style) + 'record', // Component-based record layout page with regions + 'home', // Platform-level home/landing page + 'app', // App-level page with navigation context + 'utility', // Floating utility panel (e.g. notes, phone dialer) // Interface page types (Airtable Interface parity) 'dashboard', // KPI summary with charts/metrics 'grid', // Spreadsheet-like data table @@ -118,10 +129,10 @@ export const PageTypeSchema = z.enum([ 'calendar', // Date-based scheduling 'timeline', // Gantt-like project timeline 'form', // Data entry form - 'record_detail', // Single record deep-dive + 'record_detail', // Auto-generated single record field display 'record_review', // Sequential record review/approval - 'overview', // Landing/navigation hub - 'blank', // Free-form canvas + 'overview', // Interface-level navigation/landing hub + 'blank', // Free-form canvas for custom composition ]).describe('Page type — platform or interface page types'); /**