diff --git a/ROADMAP.md b/ROADMAP.md index aa9aff088..ca596d3ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ObjectStack Protocol — Road Map -> **Last Updated:** 2026-02-22 +> **Last Updated:** 2026-02-24 > **Current Version:** v3.0.8 > **Status:** Protocol Specification Complete · Runtime Implementation In Progress @@ -696,6 +696,14 @@ Protocol enhancements and core component implementations for dashboard feature p - [x] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712)) - [x] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714)) - [x] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714)) +- [x] Add required `id` field (SnakeCaseIdentifier) to `DashboardWidgetSchema` for `targetWidgets` referencing +- [x] Unify `WidgetActionTypeSchema` with `ActionSchema.type` — add `script` and `api` types +- [x] Add `.superRefine` conditional validation to `PageSchema` (`recordReview` required for `record_review`, `blankLayout` for `blank`) +- [x] Unify easing naming in `AnimationSchema` (theme.zod) to snake_case (`ease_in`, `ease_out`, `ease_in_out`) +- [x] Add `themeToken` reference to `TransitionConfigSchema` for theme animation token binding +- [x] Add `ResponsiveConfigSchema` and `PerformanceConfigSchema` to `ListViewSchema` +- [x] Migrate `HttpMethodSchema` / `HttpRequestSchema` from `view.zod.ts` to `shared/http.zod.ts` (re-exported for backward compat) +- [x] Rename `ThemeMode`→`ThemeModeSchema`, `DensityMode`→`DensityModeSchema`, `WcagContrastLevel`→`WcagContrastLevelSchema` (deprecated aliases kept) **ObjectUI Component Implementations:** - [ ] Implement `DashboardFilterBar` component for global filters ([objectui#588](https://github.com/objectstack-ai/objectui/issues/588)) diff --git a/packages/spec/src/shared/http.test.ts b/packages/spec/src/shared/http.test.ts index 1c0336163..d811fe8fb 100644 --- a/packages/spec/src/shared/http.test.ts +++ b/packages/spec/src/shared/http.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { HttpMethod, + HttpMethodSchema, + HttpRequestSchema, CorsConfigSchema, RateLimitConfigSchema, StaticMountSchema, @@ -134,3 +136,44 @@ describe('StaticMountSchema', () => { expect(() => StaticMountSchema.parse({ path: '/static' })).toThrow(); }); }); + +// ============================================================================ +// Issue #8: HttpMethodSchema and HttpRequestSchema migrated to shared +// ============================================================================ +describe('HttpMethodSchema (migrated from view.zod)', () => { + it('should accept common HTTP methods', () => { + const valid = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + valid.forEach(m => { + expect(HttpMethodSchema.parse(m)).toBe(m); + }); + }); + + it('should reject HEAD and OPTIONS (subset for UI data sources)', () => { + expect(() => HttpMethodSchema.parse('HEAD')).toThrow(); + expect(() => HttpMethodSchema.parse('OPTIONS')).toThrow(); + }); +}); + +describe('HttpRequestSchema (migrated from view.zod)', () => { + it('should accept minimal request with url only', () => { + const result = HttpRequestSchema.parse({ url: 'https://api.example.com/data' }); + expect(result.url).toBe('https://api.example.com/data'); + expect(result.method).toBe('GET'); + }); + + it('should accept full request with all fields', () => { + const result = HttpRequestSchema.parse({ + url: 'https://api.example.com/data', + method: 'POST', + headers: { 'Authorization': 'Bearer token' }, + params: { page: 1 }, + body: { name: 'test' }, + }); + expect(result.method).toBe('POST'); + expect(result.headers?.['Authorization']).toBe('Bearer token'); + }); + + it('should reject request without url', () => { + expect(() => HttpRequestSchema.parse({})).toThrow(); + }); +}); diff --git a/packages/spec/src/shared/http.zod.ts b/packages/spec/src/shared/http.zod.ts index 72c22b3b7..825c2e9c5 100644 --- a/packages/spec/src/shared/http.zod.ts +++ b/packages/spec/src/shared/http.zod.ts @@ -28,6 +28,30 @@ export const HttpMethod = z.enum([ export type HttpMethod = z.infer; +/** + * HTTP Method Schema (subset for UI/View data sources) + * Common HTTP methods used in view data source configurations. + * Migrated from ui/view.zod.ts to shared for reuse across modules. + */ +export const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); + +export type HttpMethodType = z.infer; + +/** + * HTTP Request Configuration Schema + * Defines a complete HTTP request configuration used by API data providers. + * Migrated from ui/view.zod.ts to shared for reuse across modules. + */ +export const HttpRequestSchema = z.object({ + url: z.string().describe('API endpoint URL'), + method: HttpMethodSchema.optional().default('GET').describe('HTTP method'), + headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers'), + params: z.record(z.string(), z.unknown()).optional().describe('Query parameters'), + body: z.unknown().optional().describe('Request body for POST/PUT/PATCH'), +}); + +export type HttpRequest = z.infer; + // ========================================== // CORS Configuration // ========================================== diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 90190bcec..8a49c310f 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -1013,8 +1013,8 @@ describe('defineStack - Example-Level Strict Validation', () => { name: 'task_overview', label: 'Task Overview', widgets: [ - { title: 'Total Tasks', type: 'metric', object: 'task', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } }, - { title: 'By Status', type: 'pie', object: 'task', categoryField: 'status', aggregate: 'count', layout: { x: 3, y: 0, w: 6, h: 4 } }, + { id: 'total_tasks', title: 'Total Tasks', type: 'metric', object: 'task', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'by_status', title: 'By Status', type: 'pie', object: 'task', categoryField: 'status', aggregate: 'count', layout: { x: 3, y: 0, w: 6, h: 4 } }, ], }, ], @@ -1094,7 +1094,7 @@ describe('defineStack - Example-Level Strict Validation', () => { name: 'sales_overview', label: 'Sales Overview', widgets: [ - { title: 'Pipeline Value', type: 'metric', object: 'opportunity', valueField: 'amount', aggregate: 'sum', layout: { x: 0, y: 0, w: 4, h: 2 } }, + { id: 'pipeline_value', title: 'Pipeline Value', type: 'metric', object: 'opportunity', valueField: 'amount', aggregate: 'sum', layout: { x: 0, y: 0, w: 4, h: 2 } }, ], }, ], diff --git a/packages/spec/src/ui/animation.test.ts b/packages/spec/src/ui/animation.test.ts index 8d7b4474e..c08ef46fb 100644 --- a/packages/spec/src/ui/animation.test.ts +++ b/packages/spec/src/ui/animation.test.ts @@ -235,3 +235,31 @@ describe('I18n and ARIA integration', () => { expect(result.role).toBeUndefined(); }); }); + +// ============================================================================ +// Issue #6: TransitionConfigSchema themeToken support +// ============================================================================ +describe('TransitionConfigSchema - themeToken', () => { + it('should accept transition with themeToken reference', () => { + const result = TransitionConfigSchema.parse({ + themeToken: 'animation.duration.fast', + }); + expect(result.themeToken).toBe('animation.duration.fast'); + }); + + it('should accept transition combining themeToken with explicit values', () => { + const result = TransitionConfigSchema.parse({ + preset: 'fade', + duration: 200, + easing: 'ease_in_out', + themeToken: 'animation.timing.ease_in_out', + }); + expect(result.themeToken).toBe('animation.timing.ease_in_out'); + expect(result.duration).toBe(200); + }); + + it('should leave themeToken undefined when not provided', () => { + const result = TransitionConfigSchema.parse({ duration: 300 }); + expect(result.themeToken).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/ui/animation.zod.ts b/packages/spec/src/ui/animation.zod.ts index 98f2791ba..1ecb8d819 100644 --- a/packages/spec/src/ui/animation.zod.ts +++ b/packages/spec/src/ui/animation.zod.ts @@ -46,6 +46,7 @@ export const TransitionConfigSchema = z.object({ easing: EasingFunctionSchema.optional().describe('Easing function for the transition'), delay: z.number().optional().describe('Delay before transition starts in milliseconds'), customKeyframes: z.string().optional().describe('CSS @keyframes name for custom animations'), + themeToken: z.string().optional().describe('Reference to a theme animation token (e.g. "animation.duration.fast")'), }).describe('Animation transition configuration'); export type TransitionConfig = z.infer; diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index d3ef43e86..09d17a086 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -38,6 +38,7 @@ describe('ChartTypeSchema', () => { describe('DashboardWidgetSchema', () => { it('should accept minimal widget with layout', () => { const widget: DashboardWidget = { + id: 'widget_1', layout: { x: 0, y: 0, @@ -53,6 +54,7 @@ describe('DashboardWidgetSchema', () => { it('should accept metric widget', () => { const widget: DashboardWidget = { + id: 'total_opportunities', title: 'Total Opportunities', type: 'metric', object: 'opportunity', @@ -65,6 +67,7 @@ describe('DashboardWidgetSchema', () => { it('should accept bar chart widget', () => { const widget: DashboardWidget = { + id: 'opportunities_by_stage', title: 'Opportunities by Stage', type: 'bar', object: 'opportunity', @@ -79,6 +82,7 @@ describe('DashboardWidgetSchema', () => { it('should accept line chart widget', () => { const widget: DashboardWidget = { + id: 'revenue_trend', title: 'Revenue Trend', type: 'line', object: 'opportunity', @@ -93,6 +97,7 @@ describe('DashboardWidgetSchema', () => { it('should accept pie chart widget', () => { const widget: DashboardWidget = { + id: 'opportunities_by_type', title: 'Opportunities by Type', type: 'pie', object: 'opportunity', @@ -106,6 +111,7 @@ describe('DashboardWidgetSchema', () => { it('should accept pivot widget', () => { const widget: DashboardWidget = { + id: 'revenue_by_region_product', title: 'Revenue by Region × Product', type: 'pivot', object: 'order', @@ -124,6 +130,7 @@ describe('DashboardWidgetSchema', () => { it('should accept funnel widget', () => { const widget: DashboardWidget = { + id: 'sales_funnel', title: 'Sales Funnel', type: 'funnel', object: 'opportunity', @@ -139,6 +146,7 @@ describe('DashboardWidgetSchema', () => { it('should accept grouped-bar widget', () => { const widget: DashboardWidget = { + id: 'quarterly_revenue_by_region', title: 'Quarterly Revenue by Region', type: 'grouped-bar', object: 'order', @@ -154,6 +162,7 @@ describe('DashboardWidgetSchema', () => { it('should accept table widget', () => { const widget: DashboardWidget = { + id: 'top_accounts', title: 'Top Accounts', type: 'table', object: 'account', @@ -166,6 +175,7 @@ describe('DashboardWidgetSchema', () => { it('should accept widget with filter', () => { const widget: DashboardWidget = { + id: 'active_opportunities', title: 'Active Opportunities', type: 'metric', object: 'opportunity', @@ -182,6 +192,7 @@ describe('DashboardWidgetSchema', () => { aggregates.forEach(aggregate => { const widget: DashboardWidget = { + id: 'aggregate_test', type: 'metric', aggregate, layout: { x: 0, y: 0, w: 3, h: 2 }, @@ -192,6 +203,7 @@ describe('DashboardWidgetSchema', () => { it('should accept widget with custom options', () => { const widget: DashboardWidget = { + id: 'custom_chart', title: 'Custom Chart', type: 'bar', object: 'opportunity', @@ -210,6 +222,7 @@ describe('DashboardWidgetSchema', () => { it('should accept metric widget for text/markdown content', () => { const widget: DashboardWidget = { + id: 'welcome_message', title: 'Welcome Message', type: 'metric', layout: { x: 0, y: 0, w: 12, h: 2 }, @@ -264,6 +277,7 @@ describe('DashboardSchema', () => { description: 'Overview of sales opportunities and pipeline health', widgets: [ { + id: 'total_pipeline_value', title: 'Total Pipeline Value', type: 'metric', object: 'opportunity', @@ -273,6 +287,7 @@ describe('DashboardSchema', () => { layout: { x: 0, y: 0, w: 3, h: 2 }, }, { + id: 'open_opportunities', title: 'Open Opportunities', type: 'metric', object: 'opportunity', @@ -281,6 +296,7 @@ describe('DashboardSchema', () => { layout: { x: 3, y: 0, w: 3, h: 2 }, }, { + id: 'win_rate', title: 'Win Rate', type: 'metric', object: 'opportunity', @@ -291,6 +307,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'avg_deal_size', title: 'Avg Deal Size', type: 'metric', object: 'opportunity', @@ -300,6 +317,7 @@ describe('DashboardSchema', () => { layout: { x: 9, y: 0, w: 3, h: 2 }, }, { + id: 'pipeline_by_stage', title: 'Pipeline by Stage', type: 'bar', object: 'opportunity', @@ -314,6 +332,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'opps_by_type', title: 'Opportunities by Type', type: 'pie', object: 'opportunity', @@ -322,6 +341,7 @@ describe('DashboardSchema', () => { layout: { x: 8, y: 2, w: 4, h: 4 }, }, { + id: 'revenue_trend_12m', title: 'Revenue Trend (Last 12 Months)', type: 'line', object: 'opportunity', @@ -348,6 +368,7 @@ describe('DashboardSchema', () => { description: 'Customer support metrics and case tracking', widgets: [ { + id: 'open_cases', title: 'Open Cases', type: 'metric', object: 'case', @@ -359,6 +380,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'cases_closed_today', title: 'Cases Closed Today', type: 'metric', object: 'case', @@ -370,6 +392,7 @@ describe('DashboardSchema', () => { layout: { x: 3, y: 0, w: 3, h: 2 }, }, { + id: 'avg_response_time', title: 'Avg Response Time', type: 'metric', object: 'case', @@ -381,6 +404,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'customer_satisfaction', title: 'Customer Satisfaction', type: 'metric', object: 'case', @@ -394,6 +418,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'cases_by_priority', title: 'Cases by Priority', type: 'funnel', object: 'case', @@ -403,6 +428,7 @@ describe('DashboardSchema', () => { layout: { x: 0, y: 2, w: 6, h: 4 }, }, { + id: 'cases_by_status', title: 'Cases by Status', type: 'pie', object: 'case', @@ -411,6 +437,7 @@ describe('DashboardSchema', () => { layout: { x: 6, y: 2, w: 6, h: 4 }, }, { + id: 'recent_high_priority_cases', title: 'Recent High Priority Cases', type: 'table', object: 'case', @@ -434,6 +461,7 @@ describe('DashboardSchema', () => { description: 'Key business metrics at a glance', widgets: [ { + id: 'quarterly_revenue', title: 'Quarterly Revenue', type: 'metric', object: 'opportunity', @@ -451,6 +479,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'new_customers', title: 'New Customers', type: 'metric', object: 'account', @@ -459,6 +488,7 @@ describe('DashboardSchema', () => { layout: { x: 4, y: 0, w: 4, h: 3 }, }, { + id: 'active_users', title: 'Active Users', type: 'metric', object: 'user', @@ -467,6 +497,7 @@ describe('DashboardSchema', () => { layout: { x: 8, y: 0, w: 4, h: 3 }, }, { + id: 'revenue_by_product_line', title: 'Revenue by Product Line', type: 'bar', object: 'opportunity', @@ -477,6 +508,7 @@ describe('DashboardSchema', () => { layout: { x: 0, y: 3, w: 8, h: 4 }, }, { + id: 'team_performance', title: 'Team Performance', type: 'table', object: 'user', @@ -486,6 +518,7 @@ describe('DashboardSchema', () => { }, }, { + id: 'welcome', title: 'Welcome', type: 'metric', layout: { x: 0, y: 7, w: 12, h: 1 }, @@ -508,6 +541,7 @@ describe('Dashboard Factory', () => { label: 'Test Dashboard', widgets: [ { + id: 'test_widget', title: 'Test Widget', type: 'table', object: 'account', @@ -527,6 +561,7 @@ describe('Dashboard Factory', () => { label: 'Sales Dashboard', widgets: [ { + id: 'total_revenue', title: 'Total Revenue', type: 'metric', object: 'opportunity', @@ -558,6 +593,7 @@ describe('Dashboard I18n Integration', () => { }); it('should accept i18n object as widget title', () => { expect(() => DashboardWidgetSchema.parse({ + id: 'total_revenue', title: { key: 'widgets.revenue', defaultValue: 'Total Revenue' }, type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 }, @@ -588,6 +624,7 @@ describe('Dashboard ARIA Integration', () => { }); it('should accept widget with ARIA attributes', () => { expect(() => DashboardWidgetSchema.parse({ + id: 'revenue_metric', title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 }, @@ -599,6 +636,7 @@ describe('Dashboard ARIA Integration', () => { describe('Dashboard Responsive Integration', () => { it('should accept widget with responsive config', () => { expect(() => DashboardWidgetSchema.parse({ + id: 'responsive_metric', type: 'metric', layout: { x: 0, y: 0, w: 6, h: 2 }, responsive: { hiddenOn: ['xs'] }, @@ -691,14 +729,13 @@ describe('WidgetColorVariantSchema', () => { describe('WidgetActionTypeSchema', () => { it('should accept all action types', () => { - const types = ['url', 'modal', 'flow']; + const types = ['script', 'url', 'modal', 'flow', 'api']; types.forEach(type => { expect(() => WidgetActionTypeSchema.parse(type)).not.toThrow(); }); }); it('should reject invalid action types', () => { - expect(() => WidgetActionTypeSchema.parse('script')).toThrow(); expect(() => WidgetActionTypeSchema.parse('invalid')).toThrow(); }); }); @@ -706,6 +743,7 @@ describe('WidgetActionTypeSchema', () => { describe('DashboardWidgetSchema - colorVariant', () => { it('should accept widget with colorVariant', () => { const widget: DashboardWidget = { + id: 'total_revenue', title: 'Total Revenue', type: 'metric', colorVariant: 'teal', @@ -717,6 +755,7 @@ describe('DashboardWidgetSchema - colorVariant', () => { it('should accept widget without colorVariant (optional)', () => { const result = DashboardWidgetSchema.parse({ + id: 'metric_widget', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 }, }); @@ -735,6 +774,7 @@ describe('DashboardWidgetSchema - colorVariant', () => { describe('DashboardWidgetSchema - description', () => { it('should accept widget with string description', () => { const result = DashboardWidgetSchema.parse({ + id: 'revenue_widget', title: 'Revenue', description: 'Year-to-date total revenue', type: 'metric', @@ -745,6 +785,7 @@ describe('DashboardWidgetSchema - description', () => { it('should accept widget with i18n description', () => { const result = DashboardWidgetSchema.parse({ + id: 'revenue_i18n', title: 'Revenue', description: { key: 'widgets.revenue.desc', defaultValue: 'Total revenue' }, type: 'metric', @@ -755,6 +796,7 @@ describe('DashboardWidgetSchema - description', () => { it('should accept widget without description (optional)', () => { const result = DashboardWidgetSchema.parse({ + id: 'no_desc_widget', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 }, }); @@ -765,6 +807,7 @@ describe('DashboardWidgetSchema - description', () => { describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => { it('should accept widget with actionUrl and actionType', () => { const result = DashboardWidgetSchema.parse({ + id: 'open_tickets', title: 'Open Tickets', type: 'metric', actionUrl: 'https://example.com/tickets', @@ -777,6 +820,7 @@ describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => { it('should accept widget with actionIcon', () => { const result = DashboardWidgetSchema.parse({ + id: 'details_widget', title: 'Details', type: 'metric', actionUrl: '/details', @@ -789,6 +833,7 @@ describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => { it('should accept widget with modal action type', () => { const result = DashboardWidgetSchema.parse({ + id: 'breakdown_widget', title: 'Breakdown', type: 'metric', actionUrl: 'revenue_breakdown', @@ -800,6 +845,7 @@ describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => { it('should accept widget with flow action type', () => { const result = DashboardWidgetSchema.parse({ + id: 'refresh_data', title: 'Refresh Data', type: 'metric', actionUrl: 'refresh_pipeline_flow', @@ -811,6 +857,7 @@ describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => { it('should accept widget without action fields (optional)', () => { const result = DashboardWidgetSchema.parse({ + id: 'no_action_widget', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 }, }); @@ -831,6 +878,7 @@ describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => { describe('DashboardWidgetSchema - combined new fields', () => { it('should accept KPI widget with all new fields', () => { const widget: DashboardWidget = { + id: 'revenue_kpi', title: 'Revenue', description: 'Q4 total revenue across all regions', type: 'metric', @@ -858,6 +906,7 @@ describe('DashboardWidgetSchema - combined new fields', () => { label: 'KPI Dashboard', widgets: [ { + id: 'kpi_revenue', title: 'Revenue', description: 'Total quarterly revenue', type: 'metric', @@ -868,6 +917,7 @@ describe('DashboardWidgetSchema - combined new fields', () => { layout: { x: 0, y: 0, w: 3, h: 2 }, }, { + id: 'open_issues', title: 'Open Issues', description: 'Unresolved support tickets', type: 'metric', @@ -879,6 +929,7 @@ describe('DashboardWidgetSchema - combined new fields', () => { layout: { x: 3, y: 0, w: 3, h: 2 }, }, { + id: 'critical_bugs', title: 'Critical Bugs', description: 'P0/P1 bugs requiring attention', type: 'metric', @@ -891,6 +942,7 @@ describe('DashboardWidgetSchema - combined new fields', () => { layout: { x: 6, y: 0, w: 3, h: 2 }, }, { + id: 'team_velocity', title: 'Team Velocity', type: 'bar', colorVariant: 'blue', @@ -1137,8 +1189,8 @@ describe('DashboardSchema - enhanced globalFilters', () => { name: 'targeted_filter_dash', label: 'Targeted Filters', widgets: [ - { title: 'Chart A', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } }, - { title: 'Chart B', type: 'line', layout: { x: 6, y: 0, w: 6, h: 4 } }, + { id: 'chart_a', title: 'Chart A', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } }, + { id: 'chart_b', title: 'Chart B', type: 'line', layout: { x: 6, y: 0, w: 6, h: 4 } }, ], globalFilters: [ { @@ -1164,6 +1216,7 @@ describe('DashboardSchema - enhanced globalFilters', () => { label: 'Airtable Style Dashboard', widgets: [ { + id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar', object: 'opportunity', @@ -1342,7 +1395,7 @@ describe('DashboardSchema - header', () => { ], }, widgets: [ - { title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'revenue_widget', title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 } }, ], }); expect(dashboard.header!.showTitle).toBe(true); @@ -1401,6 +1454,7 @@ describe('WidgetMeasureSchema', () => { describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { it('should accept pivot widget with measures', () => { const widget = DashboardWidgetSchema.parse({ + id: 'sales_by_region_product', title: 'Sales by Region and Product', type: 'pivot', object: 'opportunity', @@ -1420,6 +1474,7 @@ describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { it('should accept widget without measures (backward compat)', () => { const result = DashboardWidgetSchema.parse({ + id: 'bar_widget', type: 'bar', object: 'opportunity', valueField: 'amount', @@ -1431,6 +1486,7 @@ describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { it('should accept table widget with measures for multi-aggregate', () => { const widget = DashboardWidgetSchema.parse({ + id: 'regional_summary', title: 'Regional Summary', type: 'table', object: 'order', @@ -1456,6 +1512,7 @@ describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { }, widgets: [ { + id: 'revenue_metric', title: 'Revenue', type: 'metric', object: 'order', @@ -1464,6 +1521,7 @@ describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { layout: { x: 0, y: 0, w: 4, h: 2 }, }, { + id: 'sales_pivot_analysis', title: 'Sales Pivot Analysis', type: 'pivot', object: 'opportunity', @@ -1493,6 +1551,7 @@ describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { it('should accept funnel widget with chartConfig', () => { const widget = DashboardWidgetSchema.parse({ + id: 'lead_conversion_funnel', title: 'Lead Conversion Funnel', type: 'funnel', object: 'lead', @@ -1512,6 +1571,7 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { it('should accept grouped-bar widget with chartConfig', () => { const widget = DashboardWidgetSchema.parse({ + id: 'revenue_by_region_quarter', title: 'Revenue by Region & Quarter', type: 'grouped-bar', object: 'order', @@ -1534,6 +1594,7 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { it('should accept pivot widget with measures and chartConfig', () => { const widget = DashboardWidgetSchema.parse({ + id: 'sales_cross_tab', title: 'Sales Cross-Tab Analysis', type: 'pivot', object: 'opportunity', @@ -1559,6 +1620,7 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { description: 'Dashboard combining pivot, funnel, and grouped-bar widgets', widgets: [ { + id: 'sales_funnel', title: 'Sales Funnel', type: 'funnel', object: 'lead', @@ -1567,6 +1629,7 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { layout: { x: 0, y: 0, w: 6, h: 4 }, }, { + id: 'revenue_region_quarter', title: 'Revenue by Region & Quarter', type: 'grouped-bar', object: 'order', @@ -1576,6 +1639,7 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { layout: { x: 6, y: 0, w: 6, h: 4 }, }, { + id: 'regional_pivot_analysis', title: 'Regional Pivot Analysis', type: 'pivot', object: 'opportunity', @@ -1685,3 +1749,79 @@ describe('GlobalFilterSchema - Negative Validation', () => { })).toThrow(); }); }); + +// ============================================================================ +// Issue #2: DashboardWidget required `id` field +// ============================================================================ +describe('DashboardWidgetSchema - required id field', () => { + it('should reject widget without id', () => { + expect(() => DashboardWidgetSchema.parse({ + type: 'metric', + layout: { x: 0, y: 0, w: 3, h: 2 }, + })).toThrow(); + }); + + it('should enforce snake_case for widget id', () => { + expect(() => DashboardWidgetSchema.parse({ + id: 'revenue_widget', + type: 'metric', + layout: { x: 0, y: 0, w: 3, h: 2 }, + })).not.toThrow(); + + expect(() => DashboardWidgetSchema.parse({ + id: 'revenueWidget', + type: 'metric', + layout: { x: 0, y: 0, w: 3, h: 2 }, + })).toThrow(); + }); + + it('should allow targetWidgets to reference widget ids', () => { + const dashboard = DashboardSchema.parse({ + name: 'filter_ref_dash', + label: 'Filter Reference Dashboard', + widgets: [ + { id: 'revenue_chart', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } }, + { id: 'pipeline_table', type: 'table', layout: { x: 6, y: 0, w: 6, h: 4 } }, + ], + globalFilters: [{ + field: 'region', + scope: 'widget', + targetWidgets: ['revenue_chart'], + }], + }); + expect(dashboard.widgets[0].id).toBe('revenue_chart'); + expect(dashboard.globalFilters![0].targetWidgets).toEqual(['revenue_chart']); + }); +}); + +// ============================================================================ +// Issue #3: WidgetActionTypeSchema unified with ActionSchema.type +// ============================================================================ +describe('WidgetActionTypeSchema - unified action types', () => { + it('should accept all five action types matching ActionSchema.type', () => { + const types = ['script', 'url', 'modal', 'flow', 'api'] as const; + types.forEach(type => { + expect(() => WidgetActionTypeSchema.parse(type)).not.toThrow(); + }); + }); + + it('should accept widget with script action type', () => { + expect(() => DashboardWidgetSchema.parse({ + id: 'script_widget', + type: 'metric', + actionType: 'script', + actionUrl: 'refresh_data', + layout: { x: 0, y: 0, w: 3, h: 2 }, + })).not.toThrow(); + }); + + it('should accept widget with api action type', () => { + expect(() => DashboardWidgetSchema.parse({ + id: 'api_widget', + type: 'metric', + actionType: 'api', + actionUrl: '/api/refresh', + layout: { x: 0, y: 0, w: 3, h: 2 }, + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 62865846b..c651402a3 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -25,9 +25,11 @@ export const WidgetColorVariantSchema = z.enum([ * Action type for widget action buttons. */ export const WidgetActionTypeSchema = z.enum([ + 'script', 'url', 'modal', 'flow', + 'api', ]).describe('Widget action type'); /** @@ -86,6 +88,9 @@ export const WidgetMeasureSchema = z.object({ * A single component on the dashboard grid. */ export const DashboardWidgetSchema = z.object({ + /** Unique widget identifier (snake_case, used for targetWidgets references) */ + id: SnakeCaseIdentifierSchema.describe('Unique widget identifier (snake_case)'), + /** Widget Title */ title: I18nLabelSchema.optional().describe('Widget title'), diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 9f0fe4448..34517b115 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -203,7 +203,7 @@ describe('PageSchema', () => { const types: Array = [ 'record', 'home', 'app', 'utility', 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', - 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + 'timeline', 'form', 'record_detail', 'overview', ]; types.forEach(type => { @@ -215,6 +215,29 @@ describe('PageSchema', () => { }); expect(page.type).toBe(type); }); + + // record_review requires recordReview config + const reviewPage = PageSchema.parse({ + name: 'test_page', + label: 'Test Page', + type: 'record_review', + regions: [], + recordReview: { + object: 'case', + actions: [{ label: 'Approve', type: 'approve' }], + }, + }); + expect(reviewPage.type).toBe('record_review'); + + // blank requires blankLayout config + const blankPage = PageSchema.parse({ + name: 'test_page', + label: 'Test Page', + type: 'blank', + regions: [], + blankLayout: { items: [] }, + }); + expect(blankPage.type).toBe('blank'); }); it('should accept record page', () => { @@ -564,6 +587,7 @@ describe('PageSchema with page types', () => { name: 'page_overview', label: 'Overview', type: 'blank', + blankLayout: { items: [] }, regions: [], }); @@ -616,6 +640,7 @@ describe('PageSchema with page types', () => { name: 'page_filtered', label: 'Filtered View', type: 'blank', + blankLayout: { items: [] }, variables: [ { name: 'selectedId', type: 'string' }, { name: 'showArchived', type: 'boolean', defaultValue: false }, @@ -627,12 +652,12 @@ describe('PageSchema with page types', () => { }); it('should accept all interface page types', () => { - const types = [ + const basicTypes = [ 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', - 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + 'timeline', 'form', 'record_detail', 'overview', ]; - types.forEach(type => { + basicTypes.forEach(type => { expect(() => PageSchema.parse({ name: 'test_page', label: 'Test', @@ -640,6 +665,27 @@ describe('PageSchema with page types', () => { regions: [], })).not.toThrow(); }); + + // record_review requires recordReview config + expect(() => PageSchema.parse({ + name: 'test_page', + label: 'Test', + type: 'record_review', + regions: [], + recordReview: { + object: 'case', + actions: [{ label: 'Approve', type: 'approve' }], + }, + })).not.toThrow(); + + // blank requires blankLayout config + expect(() => PageSchema.parse({ + name: 'test_page', + label: 'Test', + type: 'blank', + regions: [], + blankLayout: { items: [] }, + })).not.toThrow(); }); it('should accept page with icon', () => { @@ -839,6 +885,7 @@ describe('PageVariableSchema record_id type', () => { name: 'blank_picker', label: 'Picker Page', type: 'blank', + blankLayout: { items: [] }, variables: [ { name: 'selected_id', type: 'record_id', source: 'account_picker' }, { name: 'show_details', type: 'boolean', defaultValue: false }, @@ -1174,3 +1221,67 @@ describe('RecordReviewConfigSchema - Negative Validation', () => { })).toThrow(); }); }); + +// ============================================================================ +// Issue #5: PageSchema conditional validation (superRefine) +// ============================================================================ +describe('PageSchema - conditional validation', () => { + it('should reject record_review page without recordReview config', () => { + expect(() => PageSchema.parse({ + name: 'review_page', + label: 'Review', + type: 'record_review', + regions: [], + })).toThrow(); + }); + + it('should accept record_review page with recordReview config', () => { + expect(() => PageSchema.parse({ + name: 'review_page', + label: 'Review', + type: 'record_review', + regions: [], + recordReview: { + object: 'order', + actions: [{ label: 'Approve', type: 'approve' }], + }, + })).not.toThrow(); + }); + + it('should reject blank page without blankLayout config', () => { + expect(() => PageSchema.parse({ + name: 'blank_page', + label: 'Blank', + type: 'blank', + regions: [], + })).toThrow(); + }); + + it('should accept blank page with blankLayout config', () => { + expect(() => PageSchema.parse({ + name: 'blank_page', + label: 'Blank', + type: 'blank', + regions: [], + blankLayout: { items: [] }, + })).not.toThrow(); + }); + + it('should not require recordReview for non-record_review types', () => { + expect(() => PageSchema.parse({ + name: 'grid_page', + label: 'Grid', + type: 'grid', + regions: [], + })).not.toThrow(); + }); + + it('should not require blankLayout for non-blank types', () => { + expect(() => PageSchema.parse({ + name: 'dashboard_page', + label: 'Dashboard', + type: 'dashboard', + regions: [], + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 956f9dc19..395117b52 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -300,6 +300,21 @@ export const PageSchema = z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}).superRefine((data, ctx) => { + if (data.type === 'record_review' && !data.recordReview) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['recordReview'], + message: 'recordReview is required when type is "record_review"', + }); + } + if (data.type === 'blank' && !data.blankLayout) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['blankLayout'], + message: 'blankLayout is required when type is "blank"', + }); + } }); export type Page = z.infer; diff --git a/packages/spec/src/ui/theme.test.ts b/packages/spec/src/ui/theme.test.ts index 3e071dd23..01542909d 100644 --- a/packages/spec/src/ui/theme.test.ts +++ b/packages/spec/src/ui/theme.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { ThemeSchema, ThemeMode, + ThemeModeSchema, + DensityModeSchema, + WcagContrastLevelSchema, ColorPaletteSchema, TypographySchema, SpacingSchema, @@ -379,9 +382,9 @@ describe('ThemeSchema', () => { }, timing: { ease: 'cubic-bezier(0.4, 0, 0.2, 1)', - easeIn: 'cubic-bezier(0.4, 0, 1, 1)', - easeOut: 'cubic-bezier(0, 0, 0.2, 1)', - easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)', + ease_in: 'cubic-bezier(0.4, 0, 1, 1)', + ease_out: 'cubic-bezier(0, 0, 0.2, 1)', + ease_in_out: 'cubic-bezier(0.4, 0, 0.2, 1)', }, }, }; @@ -541,3 +544,66 @@ describe('Theme Density, WCAG, and RTL', () => { expect(theme.rtl).toBe(false); }); }); + +// ============================================================================ +// Issue #6: Easing naming unified to snake_case in theme animation tokens +// ============================================================================ +describe('AnimationSchema - snake_case timing keys', () => { + it('should accept snake_case easing keys', () => { + const theme = ThemeSchema.parse({ + name: 'snake_case_timing', + label: 'Snake Case Timing', + colors: { primary: '#000' }, + animation: { + timing: { + linear: 'linear', + ease: 'ease', + ease_in: 'ease-in', + ease_out: 'ease-out', + ease_in_out: 'ease-in-out', + }, + }, + }); + expect(theme.animation?.timing?.ease_in).toBe('ease-in'); + expect(theme.animation?.timing?.ease_out).toBe('ease-out'); + expect(theme.animation?.timing?.ease_in_out).toBe('ease-in-out'); + }); +}); + +// ============================================================================ +// Issue #9: ThemeModeSchema / DensityModeSchema / WcagContrastLevelSchema +// ============================================================================ +describe('ThemeModeSchema (canonical name)', () => { + it('should accept valid theme modes', () => { + expect(() => ThemeModeSchema.parse('light')).not.toThrow(); + expect(() => ThemeModeSchema.parse('dark')).not.toThrow(); + expect(() => ThemeModeSchema.parse('auto')).not.toThrow(); + }); + + it('should be the same as deprecated ThemeMode alias', () => { + expect(ThemeModeSchema).toBe(ThemeMode); + }); +}); + +describe('DensityModeSchema (canonical name)', () => { + it('should accept valid density modes', () => { + expect(() => DensityModeSchema.parse('compact')).not.toThrow(); + expect(() => DensityModeSchema.parse('regular')).not.toThrow(); + expect(() => DensityModeSchema.parse('spacious')).not.toThrow(); + }); + + it('should be the same as deprecated DensityMode alias', () => { + expect(DensityModeSchema).toBe(DensityMode); + }); +}); + +describe('WcagContrastLevelSchema (canonical name)', () => { + it('should accept AA and AAA', () => { + expect(() => WcagContrastLevelSchema.parse('AA')).not.toThrow(); + expect(() => WcagContrastLevelSchema.parse('AAA')).not.toThrow(); + }); + + it('should be the same as deprecated WcagContrastLevel alias', () => { + expect(WcagContrastLevelSchema).toBe(WcagContrastLevel); + }); +}); diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts index 75e52e6d5..1ae668568 100644 --- a/packages/spec/src/ui/theme.zod.ts +++ b/packages/spec/src/ui/theme.zod.ts @@ -156,9 +156,9 @@ export const AnimationSchema = z.object({ timing: z.object({ linear: z.string().optional().describe('Linear timing function'), ease: z.string().optional().describe('Ease timing function'), - easeIn: z.string().optional().describe('Ease-in timing function'), - easeOut: z.string().optional().describe('Ease-out timing function'), - easeInOut: z.string().optional().describe('Ease-in-out timing function'), + ease_in: z.string().optional().describe('Ease-in timing function'), + ease_out: z.string().optional().describe('Ease-out timing function'), + ease_in_out: z.string().optional().describe('Ease-in-out timing function'), }).optional(), }); @@ -178,21 +178,30 @@ export const ZIndexSchema = z.object({ }); /** - * Theme Mode Enum + * Theme Mode Schema */ -export const ThemeMode = z.enum(['light', 'dark', 'auto']); +export const ThemeModeSchema = z.enum(['light', 'dark', 'auto']); + +/** @deprecated Use ThemeModeSchema instead */ +export const ThemeMode = ThemeModeSchema; /** - * Density Mode Enum + * Density Mode Schema * Controls spacing and sizing for different use cases. */ -export const DensityMode = z.enum(['compact', 'regular', 'spacious']); +export const DensityModeSchema = z.enum(['compact', 'regular', 'spacious']); + +/** @deprecated Use DensityModeSchema instead */ +export const DensityMode = DensityModeSchema; /** - * WCAG Contrast Level + * WCAG Contrast Level Schema * Web Content Accessibility Guidelines color contrast requirements. */ -export const WcagContrastLevel = z.enum(['AA', 'AAA']); +export const WcagContrastLevelSchema = z.enum(['AA', 'AAA']); + +/** @deprecated Use WcagContrastLevelSchema instead */ +export const WcagContrastLevel = WcagContrastLevelSchema; /** * Theme Configuration Schema @@ -204,7 +213,7 @@ export const ThemeSchema = z.object({ description: z.string().optional().describe('Theme description'), /** Theme mode */ - mode: ThemeMode.default('light').describe('Theme mode (light, dark, or auto)'), + mode: ThemeModeSchema.default('light').describe('Theme mode (light, dark, or auto)'), /** Color system */ colors: ColorPaletteSchema.describe('Color palette configuration'), @@ -244,10 +253,10 @@ export const ThemeSchema = z.object({ extends: z.string().optional().describe('Base theme to extend from'), /** Display density mode */ - density: DensityMode.optional().describe('Display density: compact, regular, or spacious'), + density: DensityModeSchema.optional().describe('Display density: compact, regular, or spacious'), /** WCAG contrast level requirement */ - wcagContrast: WcagContrastLevel.optional().describe('WCAG color contrast level (AA or AAA)'), + wcagContrast: WcagContrastLevelSchema.optional().describe('WCAG color contrast level (AA or AAA)'), /** Right-to-left language support */ rtl: z.boolean().optional().describe('Enable right-to-left layout direction'), @@ -268,6 +277,6 @@ export type Shadow = z.infer; export type Breakpoints = z.infer; export type Animation = z.infer; export type ZIndex = z.infer; -export type ThemeMode = z.infer; -export type DensityMode = z.infer; -export type WcagContrastLevel = z.infer; +export type ThemeMode = z.infer; +export type DensityMode = z.infer; +export type WcagContrastLevel = z.infer; diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 7cca2f839..54e1399c1 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2369,3 +2369,62 @@ describe('ListViewSchema filter field', () => { })).toThrow(); }); }); + +// ============================================================================ +// Issue #7: ListView responsive and performance config +// ============================================================================ +describe('ListViewSchema - responsive and performance', () => { + it('should accept list view with responsive config', () => { + const view = ListViewSchema.parse({ + type: 'grid', + columns: ['name', 'status'], + responsive: { + hiddenOn: ['xs'], + columns: { xs: 12, md: 6, lg: 4 }, + }, + }); + expect(view.responsive?.hiddenOn).toEqual(['xs']); + expect(view.responsive?.columns?.md).toBe(6); + }); + + it('should accept list view with performance config', () => { + const view = ListViewSchema.parse({ + type: 'grid', + columns: ['name'], + performance: { + lazyLoad: true, + virtualScroll: { enabled: true, itemHeight: 40, overscan: 5 }, + cacheStrategy: 'stale-while-revalidate', + prefetch: true, + }, + }); + expect(view.performance?.lazyLoad).toBe(true); + expect(view.performance?.virtualScroll?.enabled).toBe(true); + expect(view.performance?.cacheStrategy).toBe('stale-while-revalidate'); + }); + + it('should accept list view without responsive/performance (optional)', () => { + const view = ListViewSchema.parse({ + type: 'grid', + columns: ['name'], + }); + expect(view.responsive).toBeUndefined(); + expect(view.performance).toBeUndefined(); + }); +}); + +// ============================================================================ +// Issue #8: HttpMethodSchema/HttpRequestSchema re-exported from view.zod +// ============================================================================ +describe('HttpMethodSchema/HttpRequestSchema backward compat', () => { + it('should still be importable from view.zod', () => { + expect(HttpMethodSchema).toBeDefined(); + expect(HttpRequestSchema).toBeDefined(); + }); + + it('should still parse correctly when imported from view.zod', () => { + expect(HttpMethodSchema.parse('GET')).toBe('GET'); + const result = HttpRequestSchema.parse({ url: '/api/test' }); + expect(result.method).toBe('GET'); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 06076ef3a..1eaef1898 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4,22 +4,14 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { SharingConfigSchema } from './sharing.zod'; +import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod'; /** - * HTTP Method Enum + * HTTP Method Enum & HTTP Request Schema + * Migrated to shared/http.zod.ts. Re-exported here for backward compatibility. */ -export const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); - -/** - * HTTP Request Configuration for API Provider - */ -export const HttpRequestSchema = z.object({ - url: z.string().describe('API endpoint URL'), - method: HttpMethodSchema.optional().default('GET').describe('HTTP method'), - headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers'), - params: z.record(z.string(), z.unknown()).optional().describe('Query parameters'), - body: z.unknown().optional().describe('Request body for POST/PUT/PATCH'), -}); +import { HttpMethodSchema, HttpRequestSchema } from '../shared/http.zod'; +export { HttpMethodSchema, HttpRequestSchema }; /** * View Data Source Configuration @@ -491,6 +483,12 @@ export const ListViewSchema = z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the list view'), + + /** Responsive layout overrides per breakpoint */ + responsive: ResponsiveConfigSchema.optional().describe('Responsive layout configuration'), + + /** Performance optimization settings */ + performance: PerformanceConfigSchema.optional().describe('Performance optimization settings'), }); /**