diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e686f85
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+node_modules
+dist
+
+.git
+.github
+
+*.log
+
+.DS_Store
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..19305c1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+node_modules
+dist
+
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+.DS_Store
+
+node_modules/.tmp
+*.tsbuildinfo
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..385e966
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+# syntax=docker/dockerfile:1
+
+FROM node:lts-alpine AS build
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install --no-audit --no-fund --include=dev
+
+COPY . .
+RUN npm run build
+
+FROM node:lts-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV PORT=8080
+
+RUN npm install -g serve@14 --no-audit --no-fund
+
+COPY --from=build /app/dist ./dist
+
+EXPOSE 8080
+CMD ["sh", "-c", "serve -s dist -l $PORT"]
diff --git a/boltic.yaml b/boltic.yaml
index 7383667..f704e2d 100644
--- a/boltic.yaml
+++ b/boltic.yaml
@@ -1,70 +1,9 @@
-# ============================================================================
-# Boltic Configuration File - edith003
-# ============================================================================
-#
-# ⚠️ WARNING: Changes to this file will directly impact your deployed application!
-# - Env changes will update environment variables
-# - Scaling changes will affect auto-scaling behavior
-# - PortMap changes will modify exposed ports
-#
-# This file is synced with the Boltic UI. You can edit settings here or in the UI.
-#
-# ----------------------------------------------------------------------------
-# SERVERLESS CONFIG GUIDE
-# ----------------------------------------------------------------------------
-#
-# Environment Variables (Env):
-# serverlessConfig:
-# Env:
-# MY_VAR: "my_value"
-# DATABASE_URL: "postgres://..."
-# API_KEY: "secret-key"
-#
-# Port Mapping (PortMap):
-# serverlessConfig:
-# PortMap:
-# - Name: "testhttp"
-# Port: 8080
-# Protocol: "http"
-# - Name: "test https"
-# Port: 9090
-# Protocol: "https"
-#
-# Scaling Configuration:
-# serverlessConfig:
-# Scaling:
-# AutoStop: true # Auto-stop when idle
-# Min: 1 # Minimum instances
-# Max: 5 # Maximum instances
-# MaxIdleTime: 300 # Seconds before auto-stop
-#
-# Resources:
-# serverlessConfig:
-# Resources:
-# CPU: 0.5 # CPU cores (0.1 to 4)
-# MemoryMB: 256 # Memory in MB
-# MemoryMaxMB: 512 # Max memory in MB
-#
-# Timeout:
-# serverlessConfig:
-# Timeout: 120 # Request timeout in seconds
-#
-# For more info: https://docs.boltic.io/compute/serverless/application-config
-# ============================================================================
-
-app: edith003
-build:
- builtin: dockerfile
- ignorefile: .gitignore
-language: nodejs/18
+app: renderer-bundle
region: asia-south1
-serverlessConfig:
- Scaling:
- AutoStop: false
- Min: 1
- Max: 1
- MaxIdleTime: 300
- Resources:
- CPU: 0.1
- MemoryMB: 128
- MemoryMaxMB: 128
+
+build:
+ builtin: dockerfile
+ ignorefile: .gitignore
+
+env:
+ PORT: "8080"
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..3e1fe4a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ renderer-bundle
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0e56b63
--- /dev/null
+++ b/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "renderer-bundle",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "node ./node_modules/typescript/bin/tsc -b && node ./node_modules/vite/bin/vite.js build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.11.0",
+ "uuid": "^13.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "typescript": "~5.9.3",
+ "vite": "^7.2.4"
+ }
+}
diff --git a/public/pagesData.json b/public/pagesData.json
new file mode 100644
index 0000000..d17f9e1
--- /dev/null
+++ b/public/pagesData.json
@@ -0,0 +1 @@
+{"pages": [{"id": "lp-page-home", "name": "Home", "root": {"id": "lp-root", "type": "column", "styles": {"width": "100%", "display": "flex", "minHeight": "100vh", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "lp-navbar", "type": "row", "styles": {"width": "100%", "height": "64px", "display": "flex", "padding": "0 48px", "alignItems": "center", "flexDirection": "row", "justifyContent": "space-between", "backgroundColor": "#1a1a2e"}, "children": [{"id": "lp-logo", "type": "text", "props": {"text": "MyBrand", "elementType": "span"}, "styles": {"color": "#ffffff", "fontSize": "22px", "fontWeight": "700", "letterSpacing": "-0.5px"}, "layerName": "Logo"}, {"id": "lp-nav-links", "type": "row", "styles": {"gap": "32px", "display": "flex", "alignItems": "center", "flexDirection": "row"}, "children": [{"id": "lp-nav-home", "type": "text", "props": {"text": "Home", "elementType": "span"}, "styles": {"color": "#ffffff", "fontSize": "14px", "fontWeight": "500"}, "layerName": "Nav Home"}, {"id": "lp-nav-features", "type": "text", "props": {"text": "Features", "elementType": "span"}, "styles": {"color": "#94a3b8", "fontSize": "14px", "fontWeight": "500"}, "layerName": "Nav Features"}, {"id": "lp-nav-pricing", "type": "text", "props": {"text": "Pricing", "elementType": "span"}, "styles": {"color": "#94a3b8", "fontSize": "14px", "fontWeight": "500"}, "layerName": "Nav Pricing"}, {"id": "lp-nav-cta", "type": "button", "props": {"text": "Get Started"}, "styles": {"color": "#ffffff", "border": "none", "cursor": "pointer", "padding": "8px 20px", "fontSize": "14px", "fontWeight": "600", "borderRadius": "6px", "backgroundColor": "#FF592F"}, "layerName": "Nav CTA"}], "layerName": "Nav Links"}], "layerName": "Navbar"}, {"id": "lp-hero", "type": "column", "styles": {"gap": "0px", "width": "100%", "display": "flex", "padding": "96px 48px", "alignItems": "center", "flexDirection": "column", "justifyContent": "center", "backgroundColor": "#1a1a2e"}, "children": [{"id": "lp-hero-title", "type": "text", "props": {"text": "Build Something Amazing", "elementType": "h1"}, "styles": {"color": "#ffffff", "fontSize": "56px", "maxWidth": "760px", "textAlign": "center", "fontWeight": "800", "lineHeight": "1.1", "letterSpacing": "-1.5px"}, "layerName": "Hero Title"}, {"id": "lp-hero-subtitle", "type": "text", "props": {"text": "The platform for modern businesses to build, launch, and scale their applications with confidence.", "elementType": "p"}, "styles": {"color": "#94a3b8", "fontSize": "18px", "maxWidth": "580px", "marginTop": "24px", "textAlign": "center", "lineHeight": "1.7"}, "layerName": "Hero Subtitle"}, {"id": "lp-hero-actions", "type": "row", "styles": {"gap": "16px", "display": "flex", "marginTop": "40px", "alignItems": "center", "flexDirection": "row"}, "children": [{"id": "lp-hero-cta1", "type": "button", "props": {"text": "Get Started Free"}, "styles": {"color": "#ffffff", "border": "none", "cursor": "pointer", "padding": "14px 32px", "fontSize": "16px", "fontWeight": "600", "borderRadius": "8px", "backgroundColor": "#FF592F"}, "layerName": "Primary CTA"}, {"id": "lp-hero-cta2", "type": "button", "props": {"text": "Learn More"}, "styles": {"color": "#ffffff", "border": "1px solid rgba(255,255,255,0.3)", "cursor": "pointer", "padding": "14px 32px", "fontSize": "16px", "fontWeight": "600", "borderRadius": "8px", "backgroundColor": "transparent"}, "layerName": "Secondary CTA"}], "layerName": "Hero Actions"}], "layerName": "Hero"}, {"id": "lp-features", "type": "column", "styles": {"gap": "0px", "width": "100%", "display": "flex", "padding": "80px 48px", "alignItems": "center", "flexDirection": "column", "backgroundColor": "#f8f9fa"}, "children": [{"id": "lp-features-title", "type": "text", "props": {"text": "Everything You Need", "elementType": "h2"}, "styles": {"color": "#1a1a2e", "fontSize": "36px", "textAlign": "center", "fontWeight": "700"}, "layerName": "Features Title"}, {"id": "lp-features-subtitle", "type": "text", "props": {"text": "Powerful features to help your team move faster.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "16px", "maxWidth": "480px", "marginTop": "12px", "textAlign": "center"}, "layerName": "Features Subtitle"}, {"id": "lp-features-grid", "type": "grid", "styles": {"gap": "24px", "width": "100%", "display": "grid", "maxWidth": "960px", "marginTop": "48px", "gridTemplateColumns": "repeat(3, 1fr)"}, "children": [{"id": "lp-card-1", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 4px 16px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "lp-card-1-icon", "type": "text", "props": {"text": "⚡", "elementType": "span"}, "styles": {"fontSize": "28px", "marginBottom": "4px"}, "layerName": "Feature 1 Icon"}, {"id": "lp-card-1-title", "type": "text", "props": {"text": "Fast & Reliable", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "18px", "fontWeight": "700"}, "layerName": "Feature 1 Title"}, {"id": "lp-card-1-desc", "type": "text", "props": {"text": "Built for speed and reliability at any scale. Deploy in seconds, not hours.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Feature 1 Desc"}], "layerName": "Feature 1"}, {"id": "lp-card-2", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 4px 16px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "lp-card-2-icon", "type": "text", "props": {"text": "🎨", "elementType": "span"}, "styles": {"fontSize": "28px", "marginBottom": "4px"}, "layerName": "Feature 2 Icon"}, {"id": "lp-card-2-title", "type": "text", "props": {"text": "Easy to Customize", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "18px", "fontWeight": "700"}, "layerName": "Feature 2 Title"}, {"id": "lp-card-2-desc", "type": "text", "props": {"text": "Drag, drop, and style components to match your brand perfectly.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Feature 2 Desc"}], "layerName": "Feature 2"}, {"id": "lp-card-3", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 4px 16px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "lp-card-3-icon", "type": "text", "props": {"text": "🔄", "elementType": "span"}, "styles": {"fontSize": "28px", "marginBottom": "4px"}, "layerName": "Feature 3 Icon"}, {"id": "lp-card-3-title", "type": "text", "props": {"text": "Workflow Automation", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "18px", "fontWeight": "700"}, "layerName": "Feature 3 Title"}, {"id": "lp-card-3-desc", "type": "text", "props": {"text": "Connect to powerful workflows and automate repetitive tasks instantly.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Feature 3 Desc"}], "layerName": "Feature 3"}], "layerName": "Features Grid"}], "layerName": "Features"}, {"id": "lp-cta-banner", "type": "column", "styles": {"gap": "24px", "width": "100%", "display": "flex", "padding": "64px 48px", "alignItems": "center", "flexDirection": "column", "backgroundColor": "#FF592F"}, "children": [{"id": "lp-cta-title", "type": "text", "props": {"text": "Ready to get started?", "elementType": "h2"}, "styles": {"color": "#ffffff", "fontSize": "32px", "textAlign": "center", "fontWeight": "700"}, "layerName": "CTA Title"}, {"id": "lp-cta-sub", "type": "text", "props": {"text": "Join thousands of teams already building with MyBrand.", "elementType": "p"}, "styles": {"color": "rgba(255,255,255,0.85)", "fontSize": "16px", "textAlign": "center"}, "layerName": "CTA Subtitle"}, {"id": "lp-cta-btn", "type": "button", "props": {"text": "Start for Free"}, "styles": {"color": "#FF592F", "border": "none", "cursor": "pointer", "padding": "14px 36px", "fontSize": "16px", "fontWeight": "700", "borderRadius": "8px", "backgroundColor": "#ffffff"}, "layerName": "CTA Button"}], "layerName": "CTA Banner"}, {"id": "lp-footer", "type": "row", "styles": {"width": "100%", "display": "flex", "padding": "24px 48px", "alignItems": "center", "flexDirection": "row", "justifyContent": "space-between", "backgroundColor": "#1a1a2e"}, "children": [{"id": "lp-footer-brand", "type": "text", "props": {"text": "MyBrand", "elementType": "span"}, "styles": {"color": "#ffffff", "fontSize": "16px", "fontWeight": "700"}, "layerName": "Footer Brand"}, {"id": "lp-footer-copy", "type": "text", "props": {"text": "© 2024 MyBrand. All rights reserved.", "elementType": "span"}, "styles": {"color": "#94a3b8", "fontSize": "13px"}, "layerName": "Footer Copyright"}], "layerName": "Footer"}], "layerName": "Page"}, "route": "/", "isDefault": true}], "stateData": {"state": {}, "version": 1}, "currentPageId": "lp-page-home", "pageStateData": {}}
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..b84bd2e
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { RendererApp } from './renderer';
+
+export function App() {
+ const [json, setJson] = React.useState('');
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ fetch('/pagesData.json')
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to load pagesData.json (${response.status})`);
+ }
+ return response.text();
+ })
+ .then((rawJson) => {
+ let normalized = rawJson;
+ try {
+ const parsed = JSON.parse(rawJson) as { state?: unknown };
+ if (parsed?.state && typeof parsed.state === 'object' && !Array.isArray(parsed.state)) {
+ normalized = JSON.stringify(parsed.state);
+ }
+ } catch {
+ normalized = rawJson;
+ }
+ setJson(normalized);
+ })
+ .catch((fetchError) => {
+ setError(fetchError instanceof Error ? fetchError.message : String(fetchError));
+ });
+ }, []);
+
+ if (error) {
+ return Error: {error}
;
+ }
+
+ if (!json) {
+ return Loading renderer JSON\u2026
;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/common/events/EventExecutor.ts b/src/common/events/EventExecutor.ts
new file mode 100644
index 0000000..4a888a4
--- /dev/null
+++ b/src/common/events/EventExecutor.ts
@@ -0,0 +1,424 @@
+import type { EventHandler, Page } from '../types/component.types';
+import { evaluateExpression } from '../utils/expressionEvaluator';
+import { resolveRequestBody } from '../utils/stateBinding';
+import { normalizeWorkflowResultKey, suggestWorkflowResultKeyFromName } from '../utils/workflowResultKey';
+import { parseScopedPath } from '../state/scopedPath';
+
+/**
+ * Event types that components can dispatch
+ */
+export type EventType = 'click' | 'change' | 'submit' | 'focus' | 'blur';
+
+/**
+ * Action types that event handlers can execute
+ */
+export type ActionType = 'setState' | 'navigateToPage' | 'workflow';
+
+/**
+ * Context required for executing actions
+ */
+export interface ActionContext {
+ /** Set a state value by path */
+ setStateValue: (path: string, value: any) => boolean;
+ /** Set a state value by raw key */
+ setStateValueRaw: (key: string, value: any) => boolean;
+ /** Set a state value directly without type auto-detection (for expressions) */
+ setStateValueDirect: (path: string, value: any) => boolean;
+ /** Navigate to a page by ID */
+ navigateToPage: (pageId: string) => void;
+ /** All pages (for validation) */
+ pages: Page[];
+ /** Scoped runtime state (for expression evaluation) */
+ state: any;
+}
+
+/**
+ * Configuration for workflow execution
+ */
+export interface WorkflowConfig {
+ /** URL to call for workflow execution */
+ executeUrl: string;
+ /** Request body builder */
+ buildRequestBody?: (workflowName: string) => any;
+}
+
+/**
+ * Action executor function type
+ */
+export type ActionExecutor = (
+ handler: EventHandler,
+ context: ActionContext,
+ config: EventExecutorConfig
+) => Promise;
+
+/**
+ * Configuration for the EventExecutor
+ */
+export interface EventExecutorConfig {
+ workflow?: WorkflowConfig;
+}
+
+/**
+ * Validates a single event handler structure
+ */
+const isValidHandler = (handler: any): handler is EventHandler => {
+ if (!handler || typeof handler !== 'object') return false;
+ if (!handler.id || typeof handler.id !== 'string') return false;
+ if (!handler.action || !['setState', 'navigateToPage', 'workflow'].includes(handler.action)) return false;
+
+ if (handler.action === 'setState') {
+ return !!(handler.statePath && handler.value !== undefined);
+ } else if (handler.action === 'navigateToPage') {
+ return !!handler.pageId;
+ } else if (handler.action === 'workflow') {
+ return !!handler.workflowName;
+ }
+
+ return false;
+};
+
+/**
+ * Validates and filters event handlers array
+ */
+export const validateHandlers = (handlers: any[]): EventHandler[] => {
+ if (!Array.isArray(handlers)) return [];
+ return handlers.filter(isValidHandler);
+};
+
+// ============= Action Executors =============
+
+/**
+ * Execute setState action
+ */
+const executeSetState: ActionExecutor = async (handler, context) => {
+ if (!handler.statePath || typeof handler.statePath !== 'string') {
+ throw new Error(`Invalid state path for handler ${handler.id}`);
+ }
+ let parsedStatePath;
+ try {
+ parsedStatePath = parseScopedPath(handler.statePath);
+ } catch (error) {
+ throw new Error(
+ `Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+
+ // Evaluate expression if value is a string (JavaScript expression)
+ let evaluatedValue = handler.value;
+ if (typeof handler.value === 'string' && handler.value.trim().length > 0) {
+ try {
+ evaluatedValue = evaluateExpression(handler.value, { state: context.state });
+ } catch (error: any) {
+ throw new Error(`Expression evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`);
+ }
+ }
+
+ // Use setStateValueDirect to preserve expression result type
+ // This prevents "1" from being auto-converted to 1, which would break string methods like .slice()
+ const success = context.setStateValueDirect(parsedStatePath.fullPath, evaluatedValue);
+ if (!success) {
+ throw new Error(`Failed to set state value for handler ${handler.id}`);
+ }
+};
+
+/**
+ * Execute navigateToPage action
+ */
+const executeNavigateToPage: ActionExecutor = async (handler, context) => {
+ if (!handler.pageId) {
+ throw new Error(`Missing pageId for handler ${handler.id}`);
+ }
+
+ const pageExists = context.pages.some((p) => p.id === handler.pageId);
+ if (!pageExists) {
+ throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`);
+ }
+
+ context.navigateToPage(handler.pageId);
+};
+
+/**
+ * Execute workflow action
+ */
+const executeWorkflow: ActionExecutor = async (handler, context, config) => {
+ if (!handler.workflowName || typeof handler.workflowName !== 'string') {
+ throw new Error(`Missing workflow name for handler ${handler.id}`);
+ }
+
+ // Use handler's workflowUrl if available, otherwise fall back to config
+ const workflowUrl = handler.workflowUrl || config.workflow?.executeUrl;
+
+ if (!workflowUrl) {
+ throw new Error(
+ `Workflow handler ${handler.id} requires a workflow URL. Provide handler.workflowUrl or configure workflow.executeUrl.`
+ );
+ }
+
+ // Resolve request body with state bindings
+ let requestBody: any = {};
+
+ if (handler.requestBody) {
+ // Use handler's custom request body and resolve {{ }} bindings
+ try {
+ requestBody = resolveRequestBody(handler.requestBody, context.state);
+ } catch (error) {
+ throw new Error(`Failed to resolve request body for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ } else {
+ const buildRequestBody = config.workflow?.buildRequestBody;
+ if (buildRequestBody) {
+ // Fallback to config's buildRequestBody (for backward compatibility)
+ requestBody = buildRequestBody(handler.workflowName);
+ }
+ }
+
+ const response = await fetch(workflowUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Workflow execution failed: ${response.status}`);
+ }
+
+ const responseText = await response.text();
+ let workflowResult: any = null;
+
+ if (responseText) {
+ try {
+ workflowResult = JSON.parse(responseText);
+ } catch {
+ workflowResult = responseText;
+ }
+ }
+
+ let normalizedResultKey;
+ try {
+ const resultKeyInput = handler.workflowResultKey?.trim()
+ ? handler.workflowResultKey
+ : suggestWorkflowResultKeyFromName(handler.workflowName);
+ normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput);
+ } catch (error) {
+ throw new Error(
+ `Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+
+ const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult);
+ if (!success) {
+ throw new Error(`Failed to save workflow result for handler ${handler.id}`);
+ }
+};
+
+// ============= Action Registry =============
+
+/**
+ * Registry of action executors
+ */
+const actionExecutors: Map = new Map([
+ ['setState', executeSetState],
+ ['navigateToPage', executeNavigateToPage],
+ ['workflow', executeWorkflow],
+]);
+
+/**
+ * Register a custom action executor
+ */
+export const registerActionExecutor = (action: string, executor: ActionExecutor): void => {
+ actionExecutors.set(action as ActionType, executor);
+};
+
+/**
+ * Get an action executor by action type
+ */
+export const getActionExecutor = (action: ActionType): ActionExecutor | undefined => {
+ return actionExecutors.get(action);
+};
+
+// ============= EventExecutor Class =============
+
+/**
+ * EventExecutor - Centralized service for executing event handlers
+ */
+export class EventExecutor {
+ private config: EventExecutorConfig;
+ private context: ActionContext | null = null;
+
+ constructor(config?: Partial) {
+ this.config = {
+ workflow: config?.workflow,
+ };
+ }
+
+ /**
+ * Set the action context (called when context changes)
+ */
+ setContext(context: ActionContext): void {
+ this.context = context;
+ }
+
+ /**
+ * Evaluate a handler's condition (if present)
+ * @param handler - Event handler with optional condition
+ * @returns true if condition passes or no condition exists, false otherwise
+ * @throws Error if condition evaluation fails with error message
+ */
+ private evaluateCondition(handler: EventHandler): boolean {
+ // If no condition specified, always execute (backward compatibility)
+ if (!handler.condition || handler.condition.trim() === '') {
+ return true;
+ }
+
+ if (!this.context) {
+ return false;
+ }
+
+ try {
+ // Evaluate the condition expression
+ const result = evaluateExpression(handler.condition, { state: this.context.state });
+
+ // If condition is falsy, check if we should show an error
+ if (!result) {
+ if (handler.conditionErrorMessage) {
+ // Throw error with custom message - will be caught by executeWithErrorHandling
+ throw new Error(handler.conditionErrorMessage);
+ }
+ // No error message - silently skip this handler
+ return false;
+ }
+
+ return true;
+ } catch (error: any) {
+ // If there's a custom error message from condition failure, use it
+ if (handler.conditionErrorMessage && error.message === handler.conditionErrorMessage) {
+ throw error; // Re-throw to preserve custom message
+ }
+ // Otherwise, it's an expression evaluation error
+ throw new Error(`Condition evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`);
+ }
+ }
+
+ /**
+ * Execute event handlers sequentially
+ * @param handlers - Array of event handlers to execute
+ * @throws Error if execution fails
+ */
+ async execute(handlers: EventHandler[]): Promise {
+ if (!this.context) {
+ throw new Error('EventExecutor context not set. Call setContext() first.');
+ }
+
+ const validHandlers = validateHandlers(handlers);
+ if (validHandlers.length === 0) return;
+
+ for (const handler of validHandlers) {
+ // Evaluate condition before executing handler
+ const shouldExecute = this.evaluateCondition(handler);
+
+ // Skip this handler if condition failed (without error message)
+ if (!shouldExecute) {
+ console.log(`Skipping handler ${handler.id} - condition not met`);
+ continue;
+ }
+
+ const executor = actionExecutors.get(handler.action);
+ if (!executor) {
+ throw new Error(`Unknown action type: ${handler.action}`);
+ }
+
+ await executor(handler, this.context, this.config);
+ }
+ }
+
+ /**
+ * Execute handlers with error handling (shows alert on failure)
+ */
+ async executeWithErrorHandling(handlers: EventHandler[]): Promise {
+ try {
+ await this.execute(handlers);
+ return true;
+ } catch (error) {
+ console.error('Event execution failed:', error);
+ const message = error instanceof Error && error.message
+ ? error.message
+ : 'Event handler execution failed. Please check your workflow or state configuration.';
+ alert(message);
+ return false;
+ }
+ }
+}
+
+// ============= Singleton Instance =============
+
+/**
+ * Global EventExecutor instance
+ */
+export const eventExecutor = new EventExecutor();
+
+// ============= Legacy API (backward compatibility) =============
+
+/**
+ * Legacy function for backward compatibility
+ * @deprecated Use eventExecutor.execute() instead
+ */
+export const executeEventHandlers = async (
+ handlers: EventHandler[],
+ context: ActionContext & { runWorkflow?: (workflowName: string) => Promise }
+): Promise => {
+ const validHandlers = validateHandlers(handlers);
+ if (validHandlers.length === 0) return;
+
+ for (const handler of validHandlers) {
+ if (handler.action === 'setState') {
+ if (!handler.statePath || typeof handler.statePath !== 'string') {
+ throw new Error(`Invalid state path for handler ${handler.id}`);
+ }
+ let parsedStatePath;
+ try {
+ parsedStatePath = parseScopedPath(handler.statePath);
+ } catch (error) {
+ throw new Error(
+ `Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+
+ const success = context.setStateValue(parsedStatePath.fullPath, handler.value);
+ if (!success) {
+ throw new Error(`Failed to set state value for handler ${handler.id}`);
+ }
+ } else if (handler.action === 'navigateToPage') {
+ if (!handler.pageId) {
+ throw new Error(`Missing pageId for handler ${handler.id}`);
+ }
+ const pageExists = context.pages.some((p) => p.id === handler.pageId);
+ if (!pageExists) {
+ throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`);
+ }
+ context.navigateToPage(handler.pageId);
+ } else if (handler.action === 'workflow') {
+ if (!handler.workflowName || typeof handler.workflowName !== 'string') {
+ throw new Error(`Missing workflow name for handler ${handler.id}`);
+ }
+ if (context.runWorkflow) {
+ const workflowResult = await context.runWorkflow(handler.workflowName);
+ let normalizedResultKey;
+ try {
+ const resultKeyInput = handler.workflowResultKey?.trim()
+ ? handler.workflowResultKey
+ : suggestWorkflowResultKeyFromName(handler.workflowName);
+ normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput);
+ } catch (error) {
+ throw new Error(
+ `Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+
+ const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult);
+ if (!success) {
+ throw new Error(`Failed to save workflow result for handler ${handler.id}`);
+ }
+ }
+ }
+ }
+};
diff --git a/src/common/events/index.ts b/src/common/events/index.ts
new file mode 100644
index 0000000..6af615e
--- /dev/null
+++ b/src/common/events/index.ts
@@ -0,0 +1,36 @@
+/**
+ * Events Module
+ *
+ * Centralized event system for component event handling.
+ *
+ * Usage:
+ * 1. Import the eventExecutor singleton:
+ * import { eventExecutor } from './events';
+ *
+ * 2. Set context when runtime initializes:
+ * eventExecutor.setContext({ setStateValue, navigateToPage, pages });
+ *
+ * 3. Execute handlers:
+ * await eventExecutor.execute(handlers);
+ *
+ * 4. Register custom action executors:
+ * registerActionExecutor('customAction', async (handler, context) => { ... });
+ */
+
+export {
+ EventExecutor,
+ eventExecutor,
+ registerActionExecutor,
+ getActionExecutor,
+ validateHandlers,
+ executeEventHandlers,
+} from './EventExecutor';
+
+export type {
+ EventType,
+ ActionType,
+ ActionContext,
+ ActionExecutor,
+ WorkflowConfig,
+ EventExecutorConfig,
+} from './EventExecutor';
diff --git a/src/common/icons/ComponentIcons.tsx b/src/common/icons/ComponentIcons.tsx
new file mode 100644
index 0000000..c8369f3
--- /dev/null
+++ b/src/common/icons/ComponentIcons.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+
+interface IconProps {
+ size?: number;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+export const BoxIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const ColumnIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const RowIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const ButtonIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const TextIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const GridIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const ListIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const InputIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const MobileIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const TabletIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const DesktopIcon: React.FC = ({ size = 16, style }) => (
+
+);
+
+export const PlusIcon: React.FC = ({ size = 16, style }) => (
+
+);
diff --git a/src/common/registry/ComponentRegistry.ts b/src/common/registry/ComponentRegistry.ts
new file mode 100644
index 0000000..3ffd669
--- /dev/null
+++ b/src/common/registry/ComponentRegistry.ts
@@ -0,0 +1,113 @@
+import type { CSSProperties } from 'react';
+import type { ComponentDefinition } from './types';
+
+/**
+ * Centralized registry for all component definitions.
+ * Singleton pattern ensures a single source of truth.
+ */
+class ComponentRegistryClass {
+ private definitions: Map = new Map();
+
+ /**
+ * Register a component definition
+ * @param definition The component definition to register
+ * @throws Error if component type is already registered
+ */
+ register(definition: ComponentDefinition): void {
+ if (this.definitions.has(definition.type)) {
+ console.warn(`Component type "${definition.type}" is already registered. Overwriting.`);
+ }
+ this.definitions.set(definition.type, definition);
+ }
+
+ /**
+ * Get a component definition by type
+ * @param type The component type to look up
+ * @returns The component definition or undefined if not found
+ */
+ get(type: string): ComponentDefinition | undefined {
+ return this.definitions.get(type);
+ }
+
+ /**
+ * Check if a component type is registered
+ * @param type The component type to check
+ */
+ has(type: string): boolean {
+ return this.definitions.has(type);
+ }
+
+ /**
+ * Get all registered component definitions
+ * @returns Array of all component definitions
+ */
+ getAll(): ComponentDefinition[] {
+ return Array.from(this.definitions.values());
+ }
+
+ /**
+ * Get component definitions filtered by category
+ * @param category 'container' or 'leaf'
+ */
+ getByCategory(category: 'container' | 'leaf'): ComponentDefinition[] {
+ return this.getAll().filter((def) => def.category === category);
+ }
+
+ /**
+ * Get default styles for a component type
+ * @param type The component type
+ * @returns Default styles or empty object if not found
+ */
+ getDefaultStyles(type: string): CSSProperties {
+ const definition = this.get(type);
+ return definition?.defaultStyles ?? {};
+ }
+
+ /**
+ * Get default props for a component type
+ * @param type The component type
+ * @returns Default props or empty object if not found
+ */
+ getDefaultProps(type: string): Record {
+ const definition = this.get(type);
+ return definition?.defaultProps ?? {};
+ }
+
+ /**
+ * Check if a component type can have children
+ * @param type The component type
+ * @returns true if component can have children, false otherwise
+ */
+ canHaveChildren(type: string): boolean {
+ const definition = this.get(type);
+ return definition?.canHaveChildren ?? false;
+ }
+
+ /**
+ * Get icon component for a component type
+ * @param type The component type
+ * @returns Icon component or undefined if not found
+ */
+ getIcon(type: string): React.ComponentType | undefined {
+ const definition = this.get(type);
+ return definition?.icon;
+ }
+
+ /**
+ * Get all registered component types
+ * @returns Array of component type strings
+ */
+ getTypes(): string[] {
+ return Array.from(this.definitions.keys());
+ }
+
+ /**
+ * Clear all registered components (useful for testing)
+ */
+ clear(): void {
+ this.definitions.clear();
+ }
+}
+
+// Export singleton instance
+export const ComponentRegistry = new ComponentRegistryClass();
diff --git a/src/common/registry/definitions/box.ts b/src/common/registry/definitions/box.ts
new file mode 100644
index 0000000..ad76653
--- /dev/null
+++ b/src/common/registry/definitions/box.ts
@@ -0,0 +1,172 @@
+import React from 'react';
+import { BoxIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition } from '../types';
+
+export const boxDefinition: ComponentDefinition = {
+ type: 'box',
+ label: 'Box',
+ icon: BoxIcon,
+ category: 'container',
+ canHaveChildren: true,
+
+ defaultStyles: {
+ width: 'auto',
+ display: 'block',
+ },
+
+ defaultProps: {},
+
+ propertySchema: [],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ { key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
+ { key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
+ {
+ key: 'display',
+ label: 'Display',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'block', label: 'Block' },
+ { value: 'flex', label: 'Flex' },
+ { value: 'grid', label: 'Grid' },
+ { value: 'inline', label: 'Inline' },
+ { value: 'inline-block', label: 'Inline Block' },
+ { value: 'none', label: 'None' },
+ ],
+ },
+ {
+ key: 'alignSelf',
+ label: 'Self Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'auto', label: 'Auto' },
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'baseline', label: 'Baseline' },
+ ],
+ },
+ {
+ key: 'overflow',
+ label: 'Overflow',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'visible', label: 'Visible' },
+ { value: 'hidden', label: 'Hidden' },
+ { value: 'scroll', label: 'Scroll' },
+ { value: 'auto', label: 'Auto' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Spacing
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, children }) => {
+ const isEmpty = !component.children || component.children.length === 0;
+
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ minHeight: isEmpty && !previewMode ? '60px' : 'auto',
+ position: 'relative' as const,
+ },
+ onClick: previewMode
+ ? undefined
+ : (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ },
+ children,
+ isEmpty && !previewMode
+ ? React.createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute' as const,
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ color: '#999',
+ fontSize: '14px',
+ pointerEvents: 'none' as const,
+ },
+ },
+ 'Drop components here'
+ )
+ : null
+ );
+ },
+};
diff --git a/src/common/registry/definitions/button.ts b/src/common/registry/definitions/button.ts
new file mode 100644
index 0000000..f99e747
--- /dev/null
+++ b/src/common/registry/definitions/button.ts
@@ -0,0 +1,263 @@
+import React from 'react';
+import { ButtonIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition, ComponentRenderProps } from '../types';
+
+const ensureSpinnerStyles = () => {
+ if (typeof document === 'undefined') return;
+ if (document.getElementById('button-spinner-keyframes')) return;
+
+ const style = document.createElement('style');
+ style.id = 'button-spinner-keyframes';
+ style.textContent = `
+ @keyframes button-spin {
+ to { transform: rotate(360deg); }
+ }
+ `;
+ document.head.appendChild(style);
+};
+
+const ButtonComponent: React.FC = ({ component, isSelected, onSelect, previewMode, runtime }) => {
+ const rawTextContent = component.props?.text || 'Button';
+ const textContent = runtime.resolveText(rawTextContent);
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ React.useEffect(() => {
+ ensureSpinnerStyles();
+ }, []);
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ if (previewMode) {
+ if (isLoading) return;
+ setIsLoading(true);
+ try {
+ await runtime.dispatchEvent('click', component, 'eventHandlers');
+ } catch (error) {
+ console.error('Event execution failed:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ return;
+ }
+
+ onSelect(component.id);
+ };
+
+ const spinner = React.createElement('span', {
+ 'aria-hidden': true,
+ style: {
+ display: 'inline-block',
+ width: '12px',
+ height: '12px',
+ border: '2px solid currentColor',
+ borderTopColor: 'transparent',
+ borderRadius: '50%',
+ animation: 'button-spin 0.6s linear infinite',
+ marginRight: '6px',
+ },
+ });
+
+ const loadingContent = React.createElement(
+ 'span',
+ { style: { display: 'inline-flex', alignItems: 'center' } },
+ spinner,
+ textContent
+ );
+
+ return React.createElement(
+ 'button',
+ {
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ opacity: isLoading ? 0.7 : component.styles.opacity,
+ cursor: isLoading ? 'not-allowed' : (component.styles.cursor || 'pointer'),
+ },
+ onClick: handleClick,
+ disabled: isLoading,
+ 'aria-busy': isLoading,
+ },
+ isLoading ? loadingContent : textContent
+ );
+};
+
+/**
+ * Button component - fully decoupled from stores
+ * Uses runtime.resolveText() for text bindings
+ * Uses runtime.dispatchEvent() for event handling
+ */
+export const buttonDefinition: ComponentDefinition = {
+ type: 'button',
+ label: 'Button',
+ icon: ButtonIcon,
+ category: 'leaf',
+ canHaveChildren: false,
+
+ defaultStyles: {
+ padding: '8px 16px',
+ backgroundColor: 'rgb(255, 89, 47)',
+ color: '#fff',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ width: 'auto',
+ alignSelf: 'auto',
+ },
+
+ defaultProps: {
+ text: 'Button',
+ eventHandlers: [],
+ },
+
+ propertySchema: [
+ { key: 'text', label: 'Text', type: 'text-binding', category: 'content' },
+ { key: 'eventHandlers', label: 'Event Handlers', type: 'custom', customEditor: 'eventHandlers', category: 'events' },
+ ],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ {
+ key: 'alignSelf',
+ label: 'Self Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'auto', label: 'Auto' },
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'baseline', label: 'Baseline' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Spacing
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'color', label: 'Text Color', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Typography
+ { key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' },
+ {
+ key: 'fontFamily',
+ label: 'Font Family',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'inherit', label: 'Inherit' },
+ { value: 'Arial, sans-serif', label: 'Arial' },
+ { value: 'Helvetica, sans-serif', label: 'Helvetica' },
+ { value: 'Georgia, serif', label: 'Georgia' },
+ { value: '"Times New Roman", serif', label: 'Times New Roman' },
+ { value: '"Courier New", monospace', label: 'Courier New' },
+ { value: 'Verdana, sans-serif', label: 'Verdana' },
+ { value: 'system-ui, sans-serif', label: 'System UI' },
+ ],
+ },
+ {
+ key: 'fontWeight',
+ label: 'Font Weight',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'normal', label: 'Normal' },
+ { value: 'bold', label: 'Bold' },
+ { value: '100', label: '100 (Thin)' },
+ { value: '200', label: '200' },
+ { value: '300', label: '300 (Light)' },
+ { value: '400', label: '400 (Normal)' },
+ { value: '500', label: '500 (Medium)' },
+ { value: '600', label: '600 (Semibold)' },
+ { value: '700', label: '700 (Bold)' },
+ { value: '800', label: '800' },
+ { value: '900', label: '900 (Black)' },
+ ],
+ },
+ {
+ key: 'textTransform',
+ label: 'Text Transform',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'uppercase', label: 'Uppercase' },
+ { value: 'lowercase', label: 'Lowercase' },
+ { value: 'capitalize', label: 'Capitalize' },
+ ],
+ },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ // Event schema: what events this component supports
+ eventSchema: [
+ {
+ type: 'click',
+ label: 'On Click',
+ description: 'Triggered when the button is clicked',
+ handlersKey: 'eventHandlers',
+ },
+ ],
+
+ render: (props: ComponentRenderProps) => React.createElement(ButtonComponent, props),
+};
diff --git a/src/common/registry/definitions/column.ts b/src/common/registry/definitions/column.ts
new file mode 100644
index 0000000..b7c3d68
--- /dev/null
+++ b/src/common/registry/definitions/column.ts
@@ -0,0 +1,185 @@
+import React from 'react';
+import { ColumnIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition } from '../types';
+
+export const columnDefinition: ComponentDefinition = {
+ type: 'column',
+ label: 'Column',
+ icon: ColumnIcon,
+ category: 'container',
+ canHaveChildren: true,
+
+ defaultStyles: {
+ width: 'auto',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ },
+
+ defaultProps: {},
+
+ propertySchema: [],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ { key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
+ { key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
+ {
+ key: 'justifyContent',
+ label: 'Vertical Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'space-between', label: 'Space Between' },
+ { value: 'space-around', label: 'Space Around' },
+ { value: 'space-evenly', label: 'Space Evenly' },
+ ],
+ },
+ {
+ key: 'alignItems',
+ label: 'Horizontal Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'baseline', label: 'Baseline' },
+ ],
+ },
+ {
+ key: 'flexWrap',
+ label: 'Flex Wrap',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'nowrap', label: 'No Wrap' },
+ { value: 'wrap', label: 'Wrap' },
+ { value: 'wrap-reverse', label: 'Wrap Reverse' },
+ ],
+ },
+ {
+ key: 'overflow',
+ label: 'Overflow',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'visible', label: 'Visible' },
+ { value: 'hidden', label: 'Hidden' },
+ { value: 'scroll', label: 'Scroll' },
+ { value: 'auto', label: 'Auto' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Spacing
+ { key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, children }) => {
+ const isEmpty = !component.children || component.children.length === 0;
+
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ minHeight: isEmpty && !previewMode ? '60px' : 'auto',
+ position: 'relative' as const,
+ },
+ onClick: previewMode
+ ? undefined
+ : (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ },
+ children,
+ isEmpty && !previewMode
+ ? React.createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute' as const,
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ color: '#999',
+ fontSize: '14px',
+ pointerEvents: 'none' as const,
+ },
+ },
+ 'Drop components here'
+ )
+ : null
+ );
+ },
+};
diff --git a/src/common/registry/definitions/grid.ts b/src/common/registry/definitions/grid.ts
new file mode 100644
index 0000000..6a56e0c
--- /dev/null
+++ b/src/common/registry/definitions/grid.ts
@@ -0,0 +1,208 @@
+import React from 'react';
+import { GridIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition } from '../types';
+import { ComponentRegistry } from '../ComponentRegistry';
+
+export const gridDefinition: ComponentDefinition = {
+ type: 'grid',
+ label: 'Grid',
+ icon: GridIcon,
+ category: 'container',
+ canHaveChildren: true,
+
+ defaultStyles: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, 1fr)', // 3 equal columns by default
+ gridTemplateRows: 'auto', // Auto rows
+ gap: '8px',
+ width: 'auto',
+ },
+
+ defaultProps: {},
+
+ propertySchema: [],
+
+ styleSchema: [
+ // Grid Structure
+ { key: 'gridTemplateColumns', label: 'Grid Columns', type: 'text', category: 'grid' },
+ { key: 'gridTemplateRows', label: 'Grid Rows', type: 'text', category: 'grid' },
+ { key: 'gridAutoRows', label: 'Auto Rows', type: 'dimension', category: 'grid' },
+ { key: 'gridAutoColumns', label: 'Auto Columns', type: 'dimension', category: 'grid' },
+ {
+ key: 'gridAutoFlow',
+ label: 'Auto Flow',
+ type: 'select',
+ category: 'grid',
+ options: [
+ { value: 'row', label: 'Row' },
+ { value: 'column', label: 'Column' },
+ { value: 'dense', label: 'Dense' },
+ { value: 'row dense', label: 'Row Dense' },
+ { value: 'column dense', label: 'Column Dense' },
+ ],
+ },
+ // Gap/Spacing
+ { key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
+ { key: 'rowGap', label: 'Row Gap', type: 'dimension', category: 'spacing' },
+ { key: 'columnGap', label: 'Column Gap', type: 'dimension', category: 'spacing' },
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Grid Container Alignment
+ {
+ key: 'justifyItems',
+ label: 'Justify Items',
+ type: 'select',
+ category: 'alignment',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ {
+ key: 'alignItems',
+ label: 'Align Items',
+ type: 'select',
+ category: 'alignment',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ {
+ key: 'justifyContent',
+ label: 'Justify Content',
+ type: 'select',
+ category: 'alignment',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'space-between', label: 'Space Between' },
+ { value: 'space-around', label: 'Space Around' },
+ { value: 'space-evenly', label: 'Space Evenly' },
+ ],
+ },
+ {
+ key: 'alignContent',
+ label: 'Align Content',
+ type: 'select',
+ category: 'alignment',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'space-between', label: 'Space Between' },
+ { value: 'space-around', label: 'Space Around' },
+ { value: 'space-evenly', label: 'Space Evenly' },
+ ],
+ },
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ { key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
+ { key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
+ {
+ key: 'overflow',
+ label: 'Overflow',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'visible', label: 'Visible' },
+ { value: 'hidden', label: 'Hidden' },
+ { value: 'scroll', label: 'Scroll' },
+ { value: 'auto', label: 'Auto' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Appearance
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, children }) => {
+ const isEmpty = !component.children || component.children.length === 0;
+
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ minHeight: isEmpty && !previewMode ? '120px' : 'auto',
+ position: 'relative' as const,
+ },
+ onClick: previewMode
+ ? undefined
+ : (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ },
+ children,
+ isEmpty && !previewMode
+ ? React.createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute' as const,
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ color: '#999',
+ fontSize: '14px',
+ pointerEvents: 'none' as const,
+ },
+ },
+ 'Drop components here'
+ )
+ : null
+ );
+ },
+};
+
+// Auto-register on import
+ComponentRegistry.register(gridDefinition);
diff --git a/src/common/registry/definitions/index.ts b/src/common/registry/definitions/index.ts
new file mode 100644
index 0000000..5e74a49
--- /dev/null
+++ b/src/common/registry/definitions/index.ts
@@ -0,0 +1,42 @@
+/**
+ * Component Definitions Index
+ *
+ * This file auto-registers all component definitions with the ComponentRegistry.
+ * Import this file in the app entry point to ensure all components are available.
+ */
+
+import { ComponentRegistry } from '../ComponentRegistry';
+
+// Container components
+import { rowDefinition } from './row';
+import { columnDefinition } from './column';
+import { boxDefinition } from './box';
+import { gridDefinition } from './grid';
+
+// Leaf components
+import { buttonDefinition } from './button';
+import { inputDefinition } from './input';
+import { textDefinition } from './text';
+import { listDefinition } from './list';
+
+// Register all components
+ComponentRegistry.register(rowDefinition);
+ComponentRegistry.register(columnDefinition);
+ComponentRegistry.register(boxDefinition);
+ComponentRegistry.register(gridDefinition);
+ComponentRegistry.register(buttonDefinition);
+ComponentRegistry.register(inputDefinition);
+ComponentRegistry.register(textDefinition);
+ComponentRegistry.register(listDefinition);
+
+// Export definitions for direct access if needed
+export {
+ rowDefinition,
+ columnDefinition,
+ boxDefinition,
+ gridDefinition,
+ buttonDefinition,
+ inputDefinition,
+ textDefinition,
+ listDefinition,
+};
diff --git a/src/common/registry/definitions/input.ts b/src/common/registry/definitions/input.ts
new file mode 100644
index 0000000..d94737f
--- /dev/null
+++ b/src/common/registry/definitions/input.ts
@@ -0,0 +1,181 @@
+import React from 'react';
+import { InputIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition } from '../types';
+import { parseScopedPath } from '../../state/scopedPath';
+
+export const inputDefinition: ComponentDefinition = {
+ type: 'input',
+ label: 'Input',
+ icon: InputIcon,
+ category: 'leaf',
+ canHaveChildren: false,
+
+ defaultStyles: {
+ padding: '8px',
+ border: '1px solid #ccc',
+ borderRadius: '4px',
+ width: 'auto',
+ },
+
+ defaultProps: {
+ placeholder: 'Enter text...',
+ type: 'text',
+ value: '',
+ onChangeStatePath: '',
+ },
+
+ propertySchema: [
+ { key: 'value', label: 'Value', type: 'text-binding', category: 'content' },
+ { key: 'onChangeStatePath', label: 'On Change State Path', type: 'text-binding', category: 'content' },
+ { key: 'placeholder', label: 'Placeholder', type: 'text', category: 'content' },
+ {
+ key: 'type',
+ label: 'Input Type',
+ type: 'select',
+ category: 'content',
+ options: [
+ { value: 'text', label: 'Text' },
+ { value: 'email', label: 'Email' },
+ { value: 'password', label: 'Password' },
+ { value: 'number', label: 'Number' },
+ { value: 'tel', label: 'Tel' },
+ { value: 'url', label: 'URL' },
+ ],
+ },
+ ],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ {
+ key: 'alignSelf',
+ label: 'Self Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'auto', label: 'Auto' },
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'baseline', label: 'Baseline' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Spacing
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'color', label: 'Text Color', type: 'color', category: 'appearance' },
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Typography
+ { key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' },
+ {
+ key: 'fontFamily',
+ label: 'Font Family',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'inherit', label: 'Inherit' },
+ { value: 'Arial, sans-serif', label: 'Arial' },
+ { value: 'Helvetica, sans-serif', label: 'Helvetica' },
+ { value: 'Georgia, serif', label: 'Georgia' },
+ { value: '"Times New Roman", serif', label: 'Times New Roman' },
+ { value: '"Courier New", monospace', label: 'Courier New' },
+ { value: 'Verdana, sans-serif', label: 'Verdana' },
+ { value: 'system-ui, sans-serif', label: 'System UI' },
+ ],
+ },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, runtime }) => {
+ // Resolve value binding
+ const valueBinding = component.props?.value || '';
+ const displayValue = runtime.resolveText(valueBinding);
+
+ // Handle onChange
+ const handleChange = (e: React.ChangeEvent) => {
+ if (previewMode && component.props?.onChangeStatePath) {
+ const parsedPath = parseScopedPath(component.props.onChangeStatePath);
+ runtime.setStateValue(parsedPath.fullPath, e.target.value);
+ }
+ };
+
+ return React.createElement('input', {
+ type: component.props?.type || 'text',
+ placeholder: component.props?.placeholder || 'Enter text...',
+ value: displayValue || '',
+ onChange: handleChange,
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ },
+ onClick: previewMode
+ ? undefined
+ : (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ readOnly: !previewMode,
+ });
+ },
+};
diff --git a/src/common/registry/definitions/list.ts b/src/common/registry/definitions/list.ts
new file mode 100644
index 0000000..f84763f
--- /dev/null
+++ b/src/common/registry/definitions/list.ts
@@ -0,0 +1,381 @@
+import React from 'react';
+import { ListIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition, RuntimeValues } from '../types';
+import { ComponentRegistry } from '../ComponentRegistry';
+import { ComponentRenderer } from '../../utils/ComponentRenderer';
+import type { ComponentNode, ComponentType } from '../../types/component.types';
+import { parseScopedPath } from '../../state/scopedPath';
+import { RuntimeContext } from '../../runtime';
+import type { RuntimeContextValue } from '../../runtime';
+
+/** Traverse a dot-path on an object, returning the resolved value as a string */
+const resolveNestedValue = (obj: any, path: string): string => {
+ const parts = path.split('.');
+ let current = obj;
+ for (const part of parts) {
+ if (current == null || typeof current !== 'object') return '';
+ current = current[part];
+ if (current === undefined) return '';
+ }
+ return current == null ? '' : String(current);
+};
+
+/**
+ * Create a RuntimeContextValue for a single list iteration.
+ * Intercepts {{ dataSource.* }} and {{ index }} bindings before
+ * they reach the standard interpolateText (which only knows page/global).
+ */
+const createIterationContext = (
+ runtime: RuntimeValues,
+ item: any,
+ index: number,
+): RuntimeContextValue => {
+ const iterationResolveText = (text: string): string => {
+ // Pre-process: resolve dataSource / index bindings first
+ const preprocessed = text.replace(
+ /\{\{\s*([^}]+?)\s*\}\}/g,
+ (match, rawPath) => {
+ const trimmed = rawPath.trim();
+ if (trimmed === 'index') {
+ return String(index);
+ }
+ if (trimmed === 'dataSource') {
+ return item == null ? '' : String(item);
+ }
+ if (trimmed.startsWith('dataSource.')) {
+ const subPath = trimmed.slice('dataSource.'.length);
+ return resolveNestedValue(item, subPath);
+ }
+ // Not a dataSource binding — pass through for standard resolution
+ return match;
+ },
+ );
+ // Resolve remaining {{ page.* }} / {{ global.* }} bindings
+ return runtime.resolveText(preprocessed);
+ };
+
+ return {
+ state: runtime.state,
+ resolveText: iterationResolveText,
+ executeHandlers: runtime.executeHandlers,
+ dispatchEvent: runtime.dispatchEvent,
+ pages: runtime.pages,
+ navigateToPage: runtime.navigateToPage,
+ setStateValue: runtime.setStateValue,
+ previewMode: true,
+ };
+};
+
+export const listDefinition: ComponentDefinition = {
+ type: 'list',
+ label: 'List',
+ icon: ListIcon,
+ category: 'container',
+ canHaveChildren: true, // Changed: now accepts children directly for inline editing
+
+ defaultStyles: {
+ width: 'auto',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ },
+
+ defaultProps: {},
+
+ propertySchema: [
+ {
+ key: 'dataSource',
+ label: 'Data Source',
+ type: 'text-binding',
+ category: 'content',
+ description: 'Array data source (e.g., {{ page.cart.items }} or {{ global.products.items }})',
+ },
+ ],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ { key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
+ { key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
+ {
+ key: 'flexDirection',
+ label: 'Direction',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'column', label: 'Column' },
+ { value: 'row', label: 'Row' },
+ ],
+ },
+ {
+ key: 'alignItems',
+ label: 'Align Items',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ {
+ key: 'justifyContent',
+ label: 'Justify Content',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'space-between', label: 'Space Between' },
+ { value: 'space-around', label: 'Space Around' },
+ ],
+ },
+ {
+ key: 'overflow',
+ label: 'Overflow',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'visible', label: 'Visible' },
+ { value: 'hidden', label: 'Hidden' },
+ { value: 'scroll', label: 'Scroll' },
+ { value: 'auto', label: 'Auto' },
+ ],
+ },
+ // Spacing
+ { key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, children, runtime }) => {
+ const isEmpty = !component.children || component.children.length === 0;
+ const dataSourceBinding = (component.props?.dataSource || '').trim();
+
+ // Helper function to extract array from data binding
+ const getDataArray = (): { array: any[] | null; error: string | null } => {
+ if (!dataSourceBinding || !runtime) {
+ return { array: null, error: null };
+ }
+
+ let parsedPath;
+ try {
+ parsedPath = parseScopedPath(dataSourceBinding);
+ } catch (error) {
+ return {
+ array: null,
+ error: error instanceof Error ? error.message : `Invalid binding format: "${dataSourceBinding}"`,
+ };
+ }
+
+ const parts = parsedPath.fullPath.split('.');
+ let current: any = runtime.state;
+
+ for (const part of parts) {
+ if (current == null || typeof current !== 'object') {
+ return { array: null, error: `Path "${parsedPath.fullPath}" not found in state` };
+ }
+ current = current[part];
+ if (current === undefined) {
+ return { array: null, error: `Path "${parsedPath.fullPath}" not found in state` };
+ }
+ }
+
+ if (!Array.isArray(current)) {
+ return {
+ array: null,
+ error: `Data source "${parsedPath.fullPath}" is not an array (found ${typeof current})`,
+ };
+ }
+
+ return { array: current, error: null };
+ };
+
+ // EDITOR MODE - always show 1 instance for design
+ if (!previewMode) {
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ outline: isSelected ? '2px solid rgb(255, 89, 47)' : 'none',
+ minHeight: isEmpty ? '100px' : 'auto',
+ position: 'relative' as const,
+ },
+ onClick: (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ },
+ isEmpty
+ ? React.createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute' as const,
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ color: '#999',
+ fontSize: '14px',
+ textAlign: 'center' as const,
+ pointerEvents: 'none' as const,
+ },
+ },
+ 'Drop components here to design list item'
+ )
+ : children
+ );
+ }
+
+ // PREVIEW MODE - render based on data source
+ if (isEmpty) {
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ color: '#999',
+ padding: '16px',
+ textAlign: 'center' as const,
+ },
+ },
+ 'No list items configured'
+ );
+ }
+
+ // If no data source, hide the list in preview
+ if (!dataSourceBinding) {
+ return null;
+ }
+
+ // Get data array
+ const { array: dataArray, error } = getDataArray();
+
+ // Show error if data source is invalid
+ if (error) {
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ color: '#d32f2f',
+ padding: '16px',
+ backgroundColor: '#ffebee',
+ borderRadius: '4px',
+ border: '1px solid #ef5350',
+ },
+ },
+ `Error: ${error}`
+ );
+ }
+
+ // If array is empty, render nothing
+ if (!dataArray || dataArray.length === 0) {
+ return null;
+ }
+
+ // Create a recursive renderChildren function for nested components
+ const renderChildren = (children: ComponentNode[], parentType: ComponentType): React.ReactNode => {
+ return children.map((child) =>
+ React.createElement(
+ ComponentRenderer,
+ {
+ key: child.id,
+ component: child,
+ isSelected: null,
+ onSelect: () => { }, // Items in preview are not selectable
+ previewMode: true,
+ parentType: parentType,
+ renderChildren: (childChildren, childParentType) =>
+ renderChildren(childChildren, childParentType),
+ }
+ )
+ );
+ };
+
+ // Render N instances based on array length, each wrapped in an
+ // iteration-scoped RuntimeContext so children can resolve {{ dataSource.* }}
+ return React.createElement(
+ 'div',
+ {
+ style: component.styles,
+ },
+ dataArray.map((item, index) => {
+ const iterationContext = createIterationContext(runtime, item, index);
+ return React.createElement(
+ RuntimeContext.Provider,
+ {
+ key: index,
+ value: iterationContext,
+ },
+ React.createElement(
+ 'div',
+ null,
+ component.children!.map((child) =>
+ React.createElement(
+ ComponentRenderer,
+ {
+ key: `${child.id}-${index}`,
+ component: child,
+ isSelected: null,
+ onSelect: () => { },
+ previewMode: true,
+ parentType: component.type,
+ renderChildren: (childChildren, childParentType) =>
+ renderChildren(childChildren, childParentType),
+ }
+ )
+ )
+ )
+ );
+ })
+ );
+ },
+};
+
+// Auto-register on import
+ComponentRegistry.register(listDefinition);
diff --git a/src/common/registry/definitions/row.ts b/src/common/registry/definitions/row.ts
new file mode 100644
index 0000000..f2bf339
--- /dev/null
+++ b/src/common/registry/definitions/row.ts
@@ -0,0 +1,185 @@
+import React from 'react';
+import { RowIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition } from '../types';
+
+export const rowDefinition: ComponentDefinition = {
+ type: 'row',
+ label: 'Row',
+ icon: RowIcon,
+ category: 'container',
+ canHaveChildren: true,
+
+ defaultStyles: {
+ width: 'auto',
+ display: 'flex',
+ flexDirection: 'row',
+ gap: '8px',
+ },
+
+ defaultProps: {},
+
+ propertySchema: [],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ { key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
+ { key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
+ {
+ key: 'justifyContent',
+ label: 'Horizontal Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'space-between', label: 'Space Between' },
+ { value: 'space-around', label: 'Space Around' },
+ { value: 'space-evenly', label: 'Space Evenly' },
+ ],
+ },
+ {
+ key: 'alignItems',
+ label: 'Vertical Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'baseline', label: 'Baseline' },
+ ],
+ },
+ {
+ key: 'flexWrap',
+ label: 'Flex Wrap',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'nowrap', label: 'No Wrap' },
+ { value: 'wrap', label: 'Wrap' },
+ { value: 'wrap-reverse', label: 'Wrap Reverse' },
+ ],
+ },
+ {
+ key: 'overflow',
+ label: 'Overflow',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'visible', label: 'Visible' },
+ { value: 'hidden', label: 'Hidden' },
+ { value: 'scroll', label: 'Scroll' },
+ { value: 'auto', label: 'Auto' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Spacing
+ { key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, children }) => {
+ const isEmpty = !component.children || component.children.length === 0;
+
+ return React.createElement(
+ 'div',
+ {
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ minHeight: isEmpty && !previewMode ? '60px' : 'auto',
+ position: 'relative' as const,
+ },
+ onClick: previewMode
+ ? undefined
+ : (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ },
+ children,
+ isEmpty && !previewMode
+ ? React.createElement(
+ 'div',
+ {
+ style: {
+ position: 'absolute' as const,
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ color: '#999',
+ fontSize: '14px',
+ pointerEvents: 'none' as const,
+ },
+ },
+ 'Drop components here'
+ )
+ : null
+ );
+ },
+};
diff --git a/src/common/registry/definitions/text.ts b/src/common/registry/definitions/text.ts
new file mode 100644
index 0000000..8284b93
--- /dev/null
+++ b/src/common/registry/definitions/text.ts
@@ -0,0 +1,231 @@
+import React from 'react';
+import { TextIcon } from '../../icons/ComponentIcons';
+import type { ComponentDefinition, ComponentRenderProps } from '../types';
+
+/**
+ * Text component - now decoupled from stores
+ * Uses runtime.resolveText() instead of direct store access
+ */
+export const textDefinition: ComponentDefinition = {
+ type: 'text',
+ label: 'Text',
+ icon: TextIcon,
+ category: 'leaf',
+ canHaveChildren: false,
+
+ defaultStyles: {
+ width: 'auto',
+ alignSelf: 'auto',
+ },
+
+ defaultProps: {
+ text: 'Text',
+ elementType: 'p',
+ },
+
+ propertySchema: [
+ { key: 'text', label: 'Text', type: 'text-binding', category: 'content' },
+ {
+ key: 'elementType',
+ label: 'Element Type',
+ type: 'select',
+ category: 'content',
+ options: [
+ { value: 'p', label: 'Paragraph (p)' },
+ { value: 'span', label: 'Span' },
+ { value: 'div', label: 'Div' },
+ { value: 'h1', label: 'Heading 1 (h1)' },
+ { value: 'h2', label: 'Heading 2 (h2)' },
+ { value: 'h3', label: 'Heading 3 (h3)' },
+ { value: 'h4', label: 'Heading 4 (h4)' },
+ { value: 'h5', label: 'Heading 5 (h5)' },
+ { value: 'h6', label: 'Heading 6 (h6)' },
+ ],
+ },
+ ],
+
+ styleSchema: [
+ // Layout
+ { key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
+ { key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
+ { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
+ { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
+ {
+ key: 'alignSelf',
+ label: 'Self Align',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'auto', label: 'Auto' },
+ { value: 'flex-start', label: 'Start' },
+ { value: 'center', label: 'Center' },
+ { value: 'flex-end', label: 'End' },
+ { value: 'stretch', label: 'Stretch' },
+ { value: 'baseline', label: 'Baseline' },
+ ],
+ },
+ // Positioning
+ {
+ key: 'position',
+ label: 'Position',
+ type: 'select',
+ category: 'layout',
+ options: [
+ { value: 'static', label: 'Static' },
+ { value: 'relative', label: 'Relative' },
+ { value: 'absolute', label: 'Absolute' },
+ { value: 'fixed', label: 'Fixed' },
+ { value: 'sticky', label: 'Sticky' },
+ ],
+ },
+ { key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
+ { key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
+ { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
+ { key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
+ { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
+ // Spacing
+ { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
+ { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
+ // Appearance
+ { key: 'color', label: 'Text Color', type: 'color', category: 'appearance' },
+ { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
+ { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
+ // Typography
+ { key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' },
+ {
+ key: 'fontFamily',
+ label: 'Font Family',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'inherit', label: 'Inherit' },
+ { value: 'Arial, sans-serif', label: 'Arial' },
+ { value: 'Helvetica, sans-serif', label: 'Helvetica' },
+ { value: 'Georgia, serif', label: 'Georgia' },
+ { value: '"Times New Roman", serif', label: 'Times New Roman' },
+ { value: '"Courier New", monospace', label: 'Courier New' },
+ { value: 'Verdana, sans-serif', label: 'Verdana' },
+ { value: 'system-ui, sans-serif', label: 'System UI' },
+ ],
+ },
+ {
+ key: 'fontWeight',
+ label: 'Font Weight',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'normal', label: 'Normal' },
+ { value: 'bold', label: 'Bold' },
+ { value: '100', label: '100 (Thin)' },
+ { value: '200', label: '200' },
+ { value: '300', label: '300 (Light)' },
+ { value: '400', label: '400 (Normal)' },
+ { value: '500', label: '500 (Medium)' },
+ { value: '600', label: '600 (Semibold)' },
+ { value: '700', label: '700 (Bold)' },
+ { value: '800', label: '800' },
+ { value: '900', label: '900 (Black)' },
+ ],
+ },
+ { key: 'lineHeight', label: 'Line Height', type: 'dimension', category: 'appearance' },
+ { key: 'letterSpacing', label: 'Letter Spacing', type: 'dimension', category: 'appearance' },
+ {
+ key: 'textAlign',
+ label: 'Text Align',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'left', label: 'Left' },
+ { value: 'center', label: 'Center' },
+ { value: 'right', label: 'Right' },
+ { value: 'justify', label: 'Justify' },
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ ],
+ },
+ {
+ key: 'textDecoration',
+ label: 'Text Decoration',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'underline', label: 'Underline' },
+ { value: 'line-through', label: 'Line Through' },
+ { value: 'overline', label: 'Overline' },
+ ],
+ },
+ {
+ key: 'textTransform',
+ label: 'Text Transform',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'uppercase', label: 'Uppercase' },
+ { value: 'lowercase', label: 'Lowercase' },
+ { value: 'capitalize', label: 'Capitalize' },
+ ],
+ },
+ // Border
+ { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
+ {
+ key: 'borderStyle',
+ label: 'Border Style',
+ type: 'select',
+ category: 'appearance',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+ { value: 'double', label: 'Double' },
+ ],
+ },
+ { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
+ { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
+ // Grid Item (when inside grid container)
+ { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
+ { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
+ { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
+ { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
+ { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
+ {
+ key: 'justifySelf',
+ label: 'Justify Self',
+ type: 'select',
+ category: 'grid-item',
+ options: [
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ { value: 'center', label: 'Center' },
+ { value: 'stretch', label: 'Stretch' },
+ ],
+ },
+ ],
+
+ render: ({ component, isSelected, onSelect, previewMode, runtime }: ComponentRenderProps) => {
+ const elementType = component.props?.elementType || 'p';
+ const rawTextContent = component.props?.text || 'Text';
+
+ // Use runtime.resolveText instead of directly accessing stores
+ const textContent = runtime.resolveText(rawTextContent);
+
+ const commonProps = {
+ style: {
+ ...component.styles,
+ outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
+ },
+ onClick: previewMode
+ ? undefined
+ : (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(component.id);
+ },
+ };
+
+ return React.createElement(elementType, commonProps, textContent);
+ },
+};
diff --git a/src/common/registry/index.ts b/src/common/registry/index.ts
new file mode 100644
index 0000000..b5442db
--- /dev/null
+++ b/src/common/registry/index.ts
@@ -0,0 +1,36 @@
+/**
+ * Component Registry
+ *
+ * Centralized registry for all component definitions.
+ *
+ * Usage:
+ * 1. Import the registry to access component definitions:
+ * import { ComponentRegistry } from './registry';
+ *
+ * 2. Import with definitions auto-registration (do this once in app entry):
+ * import './registry/definitions';
+ *
+ * 3. Get a component definition:
+ * const buttonDef = ComponentRegistry.get('button');
+ *
+ * 4. Get all components for component library:
+ * const allComponents = ComponentRegistry.getAll();
+ *
+ * 5. Get defaults when creating components:
+ * const styles = ComponentRegistry.getDefaultStyles('button');
+ * const props = ComponentRegistry.getDefaultProps('button');
+ */
+
+export { ComponentRegistry } from './ComponentRegistry';
+export type {
+ ComponentDefinition,
+ ComponentRenderProps,
+ EditorRenderProps,
+ PropertySchema,
+ StyleSchema,
+ PropertyEditorProps,
+ StyleEditorProps,
+ RuntimeValues,
+ ComponentEventType,
+ EventSchema,
+} from './types';
diff --git a/src/common/registry/types.ts b/src/common/registry/types.ts
new file mode 100644
index 0000000..7282df5
--- /dev/null
+++ b/src/common/registry/types.ts
@@ -0,0 +1,173 @@
+import type { CSSProperties, ReactNode } from 'react';
+import type { ComponentNode, Page, EventHandler } from '../types/component.types';
+
+/**
+ * Supported event types for components
+ */
+export type ComponentEventType = 'click' | 'change' | 'submit' | 'focus' | 'blur';
+
+/**
+ * Event schema defining what events a component supports
+ */
+export interface EventSchema {
+ /** Event type (e.g., 'click', 'change') */
+ type: ComponentEventType;
+ /** Display label for the event */
+ label: string;
+ /** Description of when this event fires */
+ description?: string;
+ /** Property key where handlers are stored (defaults to 'eventHandlers') */
+ handlersKey?: string;
+}
+
+/**
+ * Runtime values available to components during rendering
+ */
+export interface RuntimeValues {
+ /** Current scoped runtime state */
+ state: {
+ page: Record;
+ global: Record;
+ };
+ /** Resolve text with scoped state bindings ({{ page.* }} / {{ global.* }} syntax) */
+ resolveText: (text: string) => string;
+ /** Execute event handlers */
+ executeHandlers: (handlers: EventHandler[]) => Promise;
+ /** Dispatch an event for a component */
+ dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise;
+ /** All pages (for navigation) */
+ pages: Page[];
+ /** Navigate to a page by ID */
+ navigateToPage: (pageId: string) => void;
+ /** Update state value at a given path */
+ setStateValue: (path: string, value: any) => void;
+}
+
+/**
+ * Schema definition for a component property (used in Properties Panel)
+ */
+export interface PropertySchema {
+ /** Property key in component.props */
+ key: string;
+ /** Display label in properties panel */
+ label: string;
+ /** Editor type to use */
+ type: 'text' | 'text-binding' | 'number' | 'color' | 'select' | 'boolean' | 'custom';
+ /** Default value */
+ default?: any;
+ /** Options for select type */
+ options?: { value: string; label: string }[];
+ /** Category for grouping properties */
+ category?: 'content' | 'layout' | 'style' | 'events';
+ /** For custom type: the custom editor key */
+ customEditor?: string;
+ /** Description/hint text */
+ description?: string;
+}
+
+/**
+ * Schema definition for component styles
+ */
+export interface StyleSchema {
+ /** CSS property key */
+ key: string;
+ /** Display label */
+ label: string;
+ /** Editor type */
+ type: 'text' | 'number' | 'color' | 'select' | 'dimension' | 'boxShadow' | 'range';
+ /** Default value */
+ default?: any;
+ /** Options for select type */
+ options?: { value: string; label: string }[];
+ /** Category for grouping */
+ category?: 'layout' | 'spacing' | 'appearance' | 'grid' | 'grid-item' | 'alignment';
+}
+
+/**
+ * Props passed to component render functions
+ */
+export interface ComponentRenderProps {
+ component: ComponentNode;
+ isSelected: boolean;
+ onSelect: (id: string) => void;
+ previewMode: boolean;
+ children?: ReactNode;
+
+ /** Runtime values from context (decouples components from stores) */
+ runtime: RuntimeValues;
+}
+
+/**
+ * Props specific to editor render (extends ComponentRenderProps)
+ */
+export interface EditorRenderProps extends ComponentRenderProps {
+ // Editor-specific props can be added here
+}
+
+/**
+ * Props passed to property editors
+ */
+export interface PropertyEditorProps {
+ value: any;
+ onChange: (value: any, isIntermediate?: boolean) => void;
+ schema: PropertySchema;
+ component: ComponentNode;
+ pages?: Page[];
+}
+
+/**
+ * Props passed to style editors
+ */
+export interface StyleEditorProps {
+ value: any;
+ onChange: (value: any, isIntermediate?: boolean) => void;
+ schema: StyleSchema;
+ component: ComponentNode;
+}
+
+/**
+ * Complete component definition for the registry
+ */
+export interface ComponentDefinition {
+ /** Unique type identifier (e.g., 'button', 'row') */
+ type: string;
+
+ /** Display label in component library */
+ label: string;
+
+ /** Optional icon component for visual identification in outline */
+ icon?: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>;
+
+ /** Category: container (can have children) or leaf (cannot) */
+ category: 'container' | 'leaf';
+
+ /** Whether this component can accept child components */
+ canHaveChildren: boolean;
+
+ /** Default CSS styles applied when component is created */
+ defaultStyles: CSSProperties;
+
+ /** Default props applied when component is created */
+ defaultProps: Record;
+
+ /** Schema for component-specific properties */
+ propertySchema: PropertySchema[];
+
+ /** Schema for style properties */
+ styleSchema: StyleSchema[];
+
+ /** Schema for events the component supports */
+ eventSchema?: EventSchema[];
+
+ /**
+ * Render function for the component
+ * Receives standardized props, returns React node
+ */
+ render: (props: ComponentRenderProps) => ReactNode;
+
+ /**
+ * Optional: Editor-specific render override
+ * If not provided, uses render() with previewMode=false
+ */
+ editorRender?: (props: EditorRenderProps) => ReactNode;
+}
diff --git a/src/common/runtime/RuntimeContext.tsx b/src/common/runtime/RuntimeContext.tsx
new file mode 100644
index 0000000..3aca26c
--- /dev/null
+++ b/src/common/runtime/RuntimeContext.tsx
@@ -0,0 +1,58 @@
+import { createContext, useContext } from 'react';
+import type { EventHandler, Page, ComponentNode } from '../types/component.types';
+import type { ComponentEventType } from '../registry/types';
+
+/**
+ * Runtime context value interface
+ * Components receive these values instead of accessing stores directly.
+ */
+export interface RuntimeContextValue {
+ /** Current scoped runtime state: only `page` and `global` are available. */
+ state: {
+ page: Record;
+ global: Record;
+ };
+
+ /** Resolve scoped bindings in text ({{ page.* }} / {{ global.* }} syntax) */
+ resolveText: (text: string) => string;
+
+ /** Execute event handlers (for button clicks, etc.) */
+ executeHandlers: (handlers: EventHandler[]) => Promise;
+
+ /** Dispatch an event for a component */
+ dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise;
+
+ /** All pages (for navigation) */
+ pages: Page[];
+
+ /** Navigate to a page by ID */
+ navigateToPage: (pageId: string) => void;
+
+ /** Update state value at a given path */
+ setStateValue: (path: string, value: any) => void;
+
+ /** Whether we're in preview/runtime mode */
+ previewMode: boolean;
+}
+
+export const RuntimeContext = createContext(null);
+
+/**
+ * Hook to access runtime context
+ * Throws if used outside a provider.
+ */
+export const useRuntime = (): RuntimeContextValue => {
+ const context = useContext(RuntimeContext);
+ if (!context) {
+ throw new Error('useRuntime must be used within a RuntimeContext provider');
+ }
+ return context;
+};
+
+/**
+ * Hook to access runtime context (returns null if outside provider)
+ * Useful for components that may render outside runtime context.
+ */
+export const useRuntimeOptional = (): RuntimeContextValue | null => {
+ return useContext(RuntimeContext);
+};
diff --git a/src/common/runtime/index.ts b/src/common/runtime/index.ts
new file mode 100644
index 0000000..57dde7f
--- /dev/null
+++ b/src/common/runtime/index.ts
@@ -0,0 +1,3 @@
+export { RuntimeContext, useRuntime, useRuntimeOptional } from './RuntimeContext';
+export type { RuntimeContextValue } from './RuntimeContext';
+
diff --git a/src/common/state/scopedPath.ts b/src/common/state/scopedPath.ts
new file mode 100644
index 0000000..a59bf0d
--- /dev/null
+++ b/src/common/state/scopedPath.ts
@@ -0,0 +1,88 @@
+export type ScopedScope = 'page' | 'global';
+
+export type ScopedStatePath = `page.${string}` | `global.${string}`;
+
+export interface ParsedScopedPath {
+ scope: ScopedScope;
+ relativePath: string;
+ fullPath: ScopedStatePath;
+}
+
+const BINDING_PATTERN = /^\{\{\s*([^{}]+?)\s*\}\}$/;
+const SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+const INDEX_SEGMENT_PATTERN = /^\d+$/;
+
+const normalizeInput = (rawPath: unknown): string => {
+ if (typeof rawPath !== 'string') {
+ throw new Error('State path must be a string');
+ }
+
+ const input = rawPath.trim();
+ if (!input) {
+ throw new Error('State path is required');
+ }
+
+ if (input.includes('{{') || input.includes('}}')) {
+ const bindingMatch = input.match(BINDING_PATTERN);
+ if (!bindingMatch) {
+ throw new Error('State path binding must use valid {{ ... }} format');
+ }
+ const boundValue = bindingMatch[1].trim();
+ if (!boundValue) {
+ throw new Error('State path binding cannot be empty');
+ }
+ return boundValue;
+ }
+
+ return input;
+};
+
+const validateRelativePath = (relativePath: string): void => {
+ if (!relativePath) {
+ throw new Error('State path must include a property after scope');
+ }
+ if (relativePath.startsWith('.') || relativePath.endsWith('.') || relativePath.includes('..')) {
+ throw new Error('State path must not start/end with dots or contain consecutive dots');
+ }
+
+ const segments = relativePath.split('.');
+ for (const segment of segments) {
+ if (!SEGMENT_PATTERN.test(segment) && !INDEX_SEGMENT_PATTERN.test(segment)) {
+ throw new Error(
+ 'State path segments must be alphanumeric/underscore and start with a letter or underscore (indexes can be numeric)'
+ );
+ }
+ }
+};
+
+export const parseScopedPath = (rawPath: unknown): ParsedScopedPath => {
+ const normalized = normalizeInput(rawPath);
+ let scope: ScopedScope;
+
+ if (normalized.startsWith('page.')) {
+ scope = 'page';
+ } else if (normalized.startsWith('global.')) {
+ scope = 'global';
+ } else {
+ throw new Error('State path must start with "page." or "global."');
+ }
+
+ const relativePath = normalized.slice(`${scope}.`.length);
+ validateRelativePath(relativePath);
+ const fullPath = `${scope}.${relativePath}` as ScopedStatePath;
+
+ return {
+ scope,
+ relativePath,
+ fullPath,
+ };
+};
+
+export const isScopedPath = (rawPath: unknown): rawPath is ScopedStatePath => {
+ try {
+ parseScopedPath(rawPath);
+ return true;
+ } catch {
+ return false;
+ }
+};
diff --git a/src/common/state/statePathUtils.ts b/src/common/state/statePathUtils.ts
new file mode 100644
index 0000000..cdb4e0d
--- /dev/null
+++ b/src/common/state/statePathUtils.ts
@@ -0,0 +1,91 @@
+const isIndexSegment = (segment: string | undefined): boolean => !!segment && /^\d+$/.test(segment);
+
+const ensureContainerForPath = (nextSegment: string | undefined) =>
+ nextSegment && isIndexSegment(nextSegment) ? [] : {};
+
+export const updateStateAtPath = (currentState: any, pathParts: string[], newValue: any): any => {
+ if (pathParts.length === 0) {
+ return newValue;
+ }
+
+ const [first, ...rest] = pathParts;
+ const nextContainer = ensureContainerForPath(rest[0]);
+
+ if (Array.isArray(currentState)) {
+ const updatedArray = [...currentState];
+ if (isIndexSegment(first)) {
+ const index = Number(first);
+ const currentValue = updatedArray[index];
+ if (rest.length === 0) {
+ updatedArray[index] = newValue;
+ } else {
+ const child = currentValue ?? nextContainer;
+ updatedArray[index] = updateStateAtPath(child, rest, newValue);
+ }
+ } else {
+ const currentValue = (updatedArray as any)[first];
+ if (rest.length === 0) {
+ (updatedArray as any)[first] = newValue;
+ } else {
+ const child = currentValue ?? nextContainer;
+ (updatedArray as any)[first] = updateStateAtPath(child, rest, newValue);
+ }
+ }
+ return updatedArray;
+ }
+
+ const baseObject = currentState && typeof currentState === 'object' ? currentState : {};
+ const updatedObject: Record = Array.isArray(baseObject)
+ ? [...baseObject]
+ : { ...baseObject };
+
+ if (rest.length === 0) {
+ updatedObject[first] = newValue;
+ return updatedObject;
+ }
+
+ const currentValue = updatedObject[first];
+ const child = currentValue ?? nextContainer;
+ updatedObject[first] = updateStateAtPath(child, rest, newValue);
+ return updatedObject;
+};
+
+export const deleteStateAtPath = (currentState: any, pathParts: string[]): any => {
+ if (!currentState || pathParts.length === 0) {
+ return currentState;
+ }
+
+ const [first, ...rest] = pathParts;
+
+ if (Array.isArray(currentState)) {
+ const updatedArray = [...currentState];
+ if (isIndexSegment(first)) {
+ const index = Number(first);
+ if (rest.length === 0) {
+ updatedArray.splice(index, 1);
+ return updatedArray;
+ }
+ updatedArray[index] = deleteStateAtPath(updatedArray[index], rest);
+ return updatedArray;
+ }
+
+ (updatedArray as any)[first] = rest.length === 0
+ ? undefined
+ : deleteStateAtPath((updatedArray as any)[first], rest);
+ return updatedArray;
+ }
+
+ if (typeof currentState !== 'object') {
+ return currentState;
+ }
+
+ const updatedObject: Record = { ...currentState };
+ if (rest.length === 0) {
+ delete updatedObject[first];
+ return updatedObject;
+ }
+
+ updatedObject[first] = deleteStateAtPath(updatedObject[first], rest);
+ return updatedObject;
+};
+
diff --git a/src/common/state/validateScopedBindings.ts b/src/common/state/validateScopedBindings.ts
new file mode 100644
index 0000000..0598dea
--- /dev/null
+++ b/src/common/state/validateScopedBindings.ts
@@ -0,0 +1,266 @@
+import type { ComponentNode, EventHandler, PagesData } from '../types/component.types';
+import { parseScopedPath } from './scopedPath';
+import { normalizeWorkflowResultKey } from '../utils/workflowResultKey';
+
+export const MAX_VIOLATIONS_TO_SHOW = 20;
+
+export interface ScopedBindingViolation {
+ pageId: string;
+ pageName: string;
+ componentId: string;
+ fieldPath: string;
+ value: string;
+ reason: string;
+}
+
+const BINDING_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g;
+
+/** Returns true if the expression is a valid list-iteration binding (dataSource, dataSource.*, index) */
+const isIterationBinding = (expression: string): boolean =>
+ expression === 'index' || expression === 'dataSource' || expression.startsWith('dataSource.');
+
+const addViolation = (
+ violations: ScopedBindingViolation[],
+ pageId: string,
+ pageName: string,
+ componentId: string,
+ fieldPath: string,
+ value: string,
+ reason: string
+) => {
+ violations.push({
+ pageId,
+ pageName,
+ componentId,
+ fieldPath,
+ value,
+ reason,
+ });
+};
+
+const validateBindingTokens = (
+ text: string,
+ violations: ScopedBindingViolation[],
+ pageId: string,
+ pageName: string,
+ componentId: string,
+ fieldPath: string,
+ insideList: boolean = false
+) => {
+ const matches = text.matchAll(BINDING_PATTERN);
+ for (const match of matches) {
+ const expression = (match[1] || '').trim();
+ if (!expression) {
+ addViolation(violations, pageId, pageName, componentId, fieldPath, match[0], 'Empty binding expression');
+ continue;
+ }
+ // Allow dataSource.* and index bindings inside list components
+ if (insideList && isIterationBinding(expression)) {
+ continue;
+ }
+ try {
+ parseScopedPath(expression);
+ } catch (error) {
+ addViolation(
+ violations,
+ pageId,
+ pageName,
+ componentId,
+ fieldPath,
+ match[0],
+ error instanceof Error ? error.message : 'Invalid scoped binding'
+ );
+ }
+ }
+};
+
+const scanValueForBindings = (
+ value: unknown,
+ violations: ScopedBindingViolation[],
+ pageId: string,
+ pageName: string,
+ componentId: string,
+ fieldPath: string,
+ insideList: boolean = false
+) => {
+ if (typeof value === 'string') {
+ validateBindingTokens(value, violations, pageId, pageName, componentId, fieldPath, insideList);
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach((item, index) => {
+ scanValueForBindings(item, violations, pageId, pageName, componentId, `${fieldPath}[${index}]`, insideList);
+ });
+ return;
+ }
+
+ if (value && typeof value === 'object') {
+ Object.entries(value as Record).forEach(([key, item]) => {
+ scanValueForBindings(item, violations, pageId, pageName, componentId, `${fieldPath}.${key}`, insideList);
+ });
+ }
+};
+
+const validateHandler = (
+ handler: EventHandler,
+ violations: ScopedBindingViolation[],
+ pageId: string,
+ pageName: string,
+ componentId: string,
+ handlerPath: string,
+ insideList: boolean = false
+) => {
+ if (handler.action === 'setState') {
+ if (!handler.statePath || typeof handler.statePath !== 'string') {
+ addViolation(
+ violations,
+ pageId,
+ pageName,
+ componentId,
+ `${handlerPath}.statePath`,
+ String(handler.statePath ?? ''),
+ 'Missing statePath for setState handler'
+ );
+ } else {
+ try {
+ parseScopedPath(handler.statePath);
+ } catch (error) {
+ addViolation(
+ violations,
+ pageId,
+ pageName,
+ componentId,
+ `${handlerPath}.statePath`,
+ handler.statePath,
+ error instanceof Error ? error.message : 'Invalid scoped state path'
+ );
+ }
+ }
+ }
+
+ if (handler.action === 'workflow' && typeof handler.workflowResultKey === 'string' && handler.workflowResultKey.trim()) {
+ try {
+ const normalized = normalizeWorkflowResultKey(handler.workflowResultKey);
+ if (!normalized.fullPath.startsWith('global.workflows.')) {
+ addViolation(
+ violations,
+ pageId,
+ pageName,
+ componentId,
+ `${handlerPath}.workflowResultKey`,
+ handler.workflowResultKey,
+ 'Workflow result must resolve under global.workflows.*'
+ );
+ }
+ } catch (error) {
+ addViolation(
+ violations,
+ pageId,
+ pageName,
+ componentId,
+ `${handlerPath}.workflowResultKey`,
+ handler.workflowResultKey,
+ error instanceof Error ? error.message : 'Invalid workflow result key'
+ );
+ }
+ }
+
+ if (typeof handler.value === 'string') {
+ validateBindingTokens(handler.value, violations, pageId, pageName, componentId, `${handlerPath}.value`, insideList);
+ }
+ if (typeof handler.condition === 'string') {
+ validateBindingTokens(handler.condition, violations, pageId, pageName, componentId, `${handlerPath}.condition`, insideList);
+ }
+ if (typeof handler.requestBody === 'string') {
+ validateBindingTokens(handler.requestBody, violations, pageId, pageName, componentId, `${handlerPath}.requestBody`, insideList);
+ }
+};
+
+const scanComponent = (
+ component: ComponentNode,
+ violations: ScopedBindingViolation[],
+ pageId: string,
+ pageName: string,
+ pathPrefix: string,
+ insideList: boolean = false
+) => {
+ const componentPath = `${pathPrefix}.component(${component.id})`;
+ const props = component.props ?? {};
+ // Children of a list component are inside a list iteration scope
+ const childInsideList = insideList || component.type === 'list';
+
+ Object.entries(props).forEach(([key, value]) => {
+ const propPath = `${componentPath}.props.${key}`;
+
+ if (key === 'onChangeStatePath' && typeof value === 'string' && value.trim()) {
+ try {
+ parseScopedPath(value);
+ } catch (error) {
+ addViolation(
+ violations,
+ pageId,
+ pageName,
+ component.id,
+ propPath,
+ value,
+ error instanceof Error ? error.message : 'Invalid scoped state path'
+ );
+ }
+ }
+
+ if (key.endsWith('Handlers') && Array.isArray(value)) {
+ value.forEach((handler, index) => {
+ if (!handler || typeof handler !== 'object') return;
+ validateHandler(
+ handler as EventHandler,
+ violations,
+ pageId,
+ pageName,
+ component.id,
+ `${propPath}[${index}]`,
+ insideList
+ );
+ });
+ }
+
+ scanValueForBindings(value, violations, pageId, pageName, component.id, propPath, insideList);
+ });
+
+ (component.children ?? []).forEach((child, index) => {
+ scanComponent(child, violations, pageId, pageName, `${componentPath}.children[${index}]`, childInsideList);
+ });
+};
+
+export const validatePagesDataScopedBindings = (pagesData: PagesData): ScopedBindingViolation[] => {
+ const violations: ScopedBindingViolation[] = [];
+ if (!pagesData || !Array.isArray(pagesData.pages)) {
+ return violations;
+ }
+
+ pagesData.pages.forEach((page, pageIndex) => {
+ if (!page?.root) return;
+ scanComponent(page.root, violations, page.id, page.name, `pages[${pageIndex}]`);
+ });
+
+ return violations;
+};
+
+export const formatScopedBindingViolations = (
+ violations: ScopedBindingViolation[],
+ limit: number = MAX_VIOLATIONS_TO_SHOW
+): string => {
+ if (!violations.length) return '';
+
+ const visible = violations.slice(0, limit);
+ const lines = visible.map((violation, index) => {
+ return `${index + 1}. [${violation.pageName}] component=${violation.componentId} field=${violation.fieldPath} value="${violation.value}" reason=${violation.reason}`;
+ });
+
+ const remaining = violations.length - visible.length;
+ if (remaining > 0) {
+ lines.push(`...and ${remaining} more violation(s).`);
+ }
+
+ return lines.join('\n');
+};
diff --git a/src/common/types/component.types.ts b/src/common/types/component.types.ts
new file mode 100644
index 0000000..c2ae17e
--- /dev/null
+++ b/src/common/types/component.types.ts
@@ -0,0 +1,54 @@
+import type { CSSProperties } from 'react';
+import type { ScopedStatePath } from '../state/scopedPath';
+
+export type ComponentType = 'row' | 'column' | 'box' | 'button' | 'input' | 'text' | 'list' | 'grid';
+
+export type Viewport = 'mobile' | 'tablet' | 'desktop';
+
+export interface ComponentNode {
+ id: string;
+ type: ComponentType;
+ styles: CSSProperties;
+ props?: Record;
+ children?: ComponentNode[];
+ layerName?: string;
+}
+
+export interface Page {
+ id: string;
+ name: string;
+ route: string;
+ root: ComponentNode | null;
+ isDefault: boolean;
+}
+
+export interface PagesData {
+ pages: Page[];
+ currentPageId: string | null;
+ stateData?: StateData;
+ pageStateData?: Record;
+}
+
+export interface StateData {
+ state: Record;
+ version: number;
+}
+
+export interface EventHandler {
+ id: string; // UUID for each handler
+ action: 'setState' | 'navigateToPage' | 'workflow';
+ // Conditional execution:
+ condition?: string; // JavaScript expression that must evaluate to true for handler to execute (e.g., "page.user.mobileNumber && page.user.mobileNumber.length >= 10")
+ conditionErrorMessage?: string; // Error message to show if condition fails (optional)
+ // For setState:
+ statePath?: ScopedStatePath; // e.g., "page.user.customerName" or "global.user.customerName"
+ value?: any; // JavaScript expression as string (e.g., "page.user.age + 1", "global.user.name.toUpperCase()")
+ // For navigateToPage:
+ pageId?: string; // Target page ID
+ // For workflow:
+ workflowId?: string; // Workflow ID from API
+ workflowName?: string; // Selected workflow name
+ workflowUrl?: string; // Workflow execution URL
+ requestBody?: string; // JSON string for workflow request body (supports {{ page.* }} / {{ global.* }} bindings)
+ workflowResultKey?: string; // Relative key under global.workflows.* to store workflow results
+}
diff --git a/src/common/utils/ComponentRenderer.tsx b/src/common/utils/ComponentRenderer.tsx
new file mode 100644
index 0000000..c2ad14d
--- /dev/null
+++ b/src/common/utils/ComponentRenderer.tsx
@@ -0,0 +1,122 @@
+import React, { type CSSProperties } from "react";
+import type { ComponentNode, ComponentType } from '../types/component.types';
+import { ComponentRegistry } from '../registry';
+import type { RuntimeValues } from '../registry/types';
+import { normalizeComponentStyles } from './styleNormalization';
+import { useRuntimeOptional } from '../runtime';
+
+interface ComponentRendererProps {
+ component: ComponentNode;
+ isSelected: string | null;
+ lastModifiedId?: string | null;
+ onSelect: (id: string) => void;
+ renderChildren?: (children: ComponentNode[], parentType: ComponentType, parentStyles?: CSSProperties) => React.ReactNode;
+ previewMode?: boolean;
+ parentType?: ComponentType | null;
+ parentStyles?: CSSProperties | null;
+}
+
+// Default no-op runtime values for when context is not available
+const defaultRuntime: RuntimeValues = {
+ state: { page: {}, global: {} },
+ resolveText: (text: string) => text,
+ executeHandlers: async () => { },
+ dispatchEvent: async () => { },
+ pages: [],
+ navigateToPage: () => { },
+ setStateValue: () => { },
+};
+
+export const ComponentRenderer: React.FC = ({
+ component,
+ isSelected,
+ lastModifiedId,
+ onSelect,
+ renderChildren,
+ previewMode = false,
+ parentType = null,
+ parentStyles = null,
+}) => {
+ const isSelectedComponent = isSelected === component.id;
+ const isModified = lastModifiedId === component.id;
+ const domRef = React.useRef(null);
+
+ // Get runtime context (may be null if not wrapped in provider)
+ const runtimeContext = useRuntimeOptional();
+
+ // Scroll into view and pulse when modified and selected (e.g. undo/redo)
+ React.useEffect(() => {
+ if (isSelectedComponent && isModified && !previewMode) {
+ // Use requestAnimationFrame to ensure the DOM is ready (especially for restored components)
+ requestAnimationFrame(() => {
+ if (domRef.current) {
+ domRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ domRef.current.classList.remove('pulse-highlight');
+ // Trigger reflow to restart animation
+ void domRef.current.offsetWidth;
+ domRef.current.classList.add('pulse-highlight');
+ }
+ });
+ }
+ }, [isSelectedComponent, isModified, previewMode]);
+
+ // Normalize styles to prevent flexbox stretching, passing parent styles so the
+ // normalizer can skip injection when the parent has explicit alignment intent.
+ const normalizedStyles = normalizeComponentStyles(component.type, component.styles, parentType, parentStyles);
+ const normalizedComponent = { ...component, styles: normalizedStyles };
+
+ // Helper to render children with parent context.
+ // We pass normalizedStyles so children can see this component's effective alignment.
+ const renderChildrenWithContext = (children: ComponentNode[]) => {
+ if (!renderChildren) return null;
+ return renderChildren(children, component.type, normalizedStyles);
+ };
+
+ // Build runtime values from context or defaults
+ const runtime: RuntimeValues = runtimeContext
+ ? {
+ state: runtimeContext.state,
+ resolveText: runtimeContext.resolveText,
+ executeHandlers: runtimeContext.executeHandlers,
+ dispatchEvent: runtimeContext.dispatchEvent,
+ pages: runtimeContext.pages,
+ navigateToPage: runtimeContext.navigateToPage,
+ setStateValue: runtimeContext.setStateValue,
+ }
+ : defaultRuntime;
+
+ const renderComponent = () => {
+ // Get component definition from registry
+ const definition = ComponentRegistry.get(component.type);
+
+ if (!definition) {
+ console.warn(`Unknown component type: ${component.type}`);
+ return null;
+ }
+
+ // Prepare children for containers
+ const childrenContent = definition.canHaveChildren && renderChildren && component.children
+ ? renderChildrenWithContext(component.children)
+ : undefined;
+
+ // Call the render function from the definition with runtime values
+ return definition.render({
+ component: normalizedComponent,
+ isSelected: isSelectedComponent,
+ onSelect,
+ previewMode,
+ children: childrenContent,
+ runtime,
+ });
+ };
+
+ if (previewMode) {
+ return <>{renderComponent()}>;
+ }
+
+ return (
+
+ {renderComponent()}
+
+ );
+};
diff --git a/src/common/utils/expressionEvaluator.ts b/src/common/utils/expressionEvaluator.ts
new file mode 100644
index 0000000..c865fd6
--- /dev/null
+++ b/src/common/utils/expressionEvaluator.ts
@@ -0,0 +1,155 @@
+/**
+ * Safe JavaScript expression evaluator with state context
+ */
+
+export interface EvaluationContext {
+ state: any;
+}
+
+/**
+ * Create a deep proxy that returns safe defaults for undefined properties
+ *
+ * This prevents "undefined" from appearing in expressions when properties don't exist.
+ * Instead of `undefined + "1"` becoming `"undefined1"`, it becomes `"" + "1"` = `"1"`.
+ *
+ * @param state - The state object to wrap
+ * @returns A proxied state object that returns "" for undefined properties
+ *
+ * @example
+ * const safeState = createSafeStateProxy({ page: { user: { name: "Jon" } }, global: {} });
+ * safeState.page.user.mobileNumber // Returns "" instead of undefined
+ * safeState.page.user.name // Returns "Jon" (existing values unchanged)
+ */
+const createSafeStateProxy = (state: any): any => {
+ // Handle null/undefined at the root
+ if (state === null || state === undefined) {
+ return "";
+ }
+
+ // Don't proxy primitives or special objects
+ if (typeof state !== 'object' || state instanceof Date || state instanceof RegExp) {
+ return state;
+ }
+
+ // Handle arrays specially
+ if (Array.isArray(state)) {
+ return new Proxy(state, {
+ get(target, prop) {
+ // Array methods and properties
+ if (prop === 'length' || typeof target[prop as any] === 'function') {
+ return target[prop as any];
+ }
+
+ const value = target[prop as any];
+
+ // If index exists, return proxied value
+ if (value !== undefined) {
+ if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
+ return createSafeStateProxy(value);
+ }
+ return value;
+ }
+
+ // Out of bounds index - return empty string
+ return "";
+ }
+ });
+ }
+
+ // Proxy objects
+ return new Proxy(state, {
+ get(target, prop) {
+ const value = target[prop];
+
+ // If property exists, return it (wrapped in proxy if it's an object)
+ if (value !== undefined) {
+ // Recursively wrap objects/arrays in proxy
+ if (typeof value === 'object' && value !== null && !(value instanceof Date) && !(value instanceof RegExp)) {
+ return createSafeStateProxy(value);
+ }
+ return value;
+ }
+
+ // Property is undefined - return empty string as safe default
+ // This prevents "undefined" from appearing in string concatenations
+ return "";
+ }
+ });
+};
+
+/**
+ * Evaluate a JavaScript expression with state context
+ *
+ * @param expression - JavaScript expression as string (e.g., "page.user.age + 1")
+ * @param context - Evaluation context with state object
+ * @returns Evaluated result
+ * @throws Error if evaluation fails
+ *
+ * @example
+ * evaluateExpression("page.user.name", { state: { page: { user: { name: "Jon" } }, global: {} } })
+ * // Returns: "Jon"
+ *
+ * evaluateExpression("global.counter + 1", { state: { page: {}, global: { counter: 25 } } })
+ * // Returns: 26
+ *
+ * evaluateExpression("state.page.user.name.toUpperCase()", { state: { page: { user: { name: "Jon" } }, global: {} } })
+ * // Returns: "JON"
+ */
+export const evaluateExpression = (
+ expression: string,
+ context: EvaluationContext
+): any => {
+ // Validate input
+ if (!expression || typeof expression !== 'string') {
+ throw new Error('Expression must be a non-empty string');
+ }
+
+ const trimmedExpression = expression.trim();
+ if (!trimmedExpression) {
+ throw new Error('Expression cannot be empty');
+ }
+
+ try {
+ // Wrap state and aliases in safe proxies that return defaults for undefined properties
+ // This prevents "undefined" from appearing in expressions
+ // Example: state.page.user.mobileNumber returns "" instead of undefined
+ const safeState = createSafeStateProxy(context.state);
+ const safePage = createSafeStateProxy(context?.state?.page ?? {});
+ const safeGlobal = createSafeStateProxy(context?.state?.global ?? {});
+
+ // Create a function with scoped aliases in scope
+ // This is safe because:
+ // 1. This is a builder tool (not end-user facing)
+ // 2. Expressions are written by trusted builder users
+ // 3. Expressions run in isolated preview context
+ const func = new Function('state', 'page', 'global', `return (${trimmedExpression});`);
+ return func(safeState, safePage, safeGlobal);
+ } catch (error: any) {
+ throw new Error(`Expression evaluation failed: ${error?.message || String(error)}`);
+ }
+};
+
+/**
+ * Validate if an expression is syntactically correct (without executing it)
+ *
+ * @param expression - JavaScript expression to validate
+ * @returns true if valid, false otherwise
+ */
+export const validateExpression = (expression: string): boolean => {
+ if (!expression || typeof expression !== 'string') {
+ return false;
+ }
+
+ const trimmedExpression = expression.trim();
+ if (!trimmedExpression) {
+ return false;
+ }
+
+ try {
+ // Try to create the function without executing it
+ new Function('state', 'page', 'global', `return (${trimmedExpression});`);
+ return true;
+ } catch {
+ return false;
+ }
+};
diff --git a/src/common/utils/serialization.ts b/src/common/utils/serialization.ts
new file mode 100644
index 0000000..1ee2f0f
--- /dev/null
+++ b/src/common/utils/serialization.ts
@@ -0,0 +1,92 @@
+import type { ComponentNode, PagesData, StateData } from '../types/component.types';
+import { v4 as uuidv4 } from 'uuid';
+
+const STATE_VERSION = 1;
+const PAGE_STATE_VERSION = 1;
+
+const safeObject = (value: unknown): Record =>
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {};
+
+const createStateData = (state: unknown): StateData => {
+ return {
+ state: safeObject(state),
+ version: STATE_VERSION,
+ };
+};
+
+const createPageStateData = (pageStateById: Record = {}): Record => {
+ const entries = Object.entries(pageStateById).map(([pageId, state]) => {
+ return [pageId, { state: safeObject(state), version: PAGE_STATE_VERSION } satisfies StateData] as const;
+ });
+ return Object.fromEntries(entries);
+};
+
+export const exportToJSON = (
+ pagesData: PagesData,
+ globalState: Record = {},
+ pageStateById: Record> = {}
+): string => {
+ const payload: PagesData = {
+ ...pagesData,
+ stateData: createStateData(globalState),
+ pageStateData: createPageStateData(pageStateById),
+ };
+ return JSON.stringify(payload, null, 2);
+};
+
+export const importFromJSON = (json: string): PagesData | null => {
+ try {
+ const parsed = JSON.parse(json);
+
+ // Check if it's the new PagesData format
+ if (parsed.pages && Array.isArray(parsed.pages)) {
+ return parsed as PagesData;
+ }
+
+ // Backward compatibility: if it's just a ComponentNode, convert to PagesData
+ if (parsed.id && parsed.type) {
+ const defaultPage = {
+ id: uuidv4(),
+ name: 'Home',
+ route: '/',
+ root: parsed as ComponentNode,
+ isDefault: true,
+ };
+ return {
+ pages: [defaultPage],
+ currentPageId: defaultPage.id,
+ };
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Failed to parse JSON:', error);
+ return null;
+ }
+};
+
+export const deserializeStateData = (pagesData: PagesData | null): Record | null => {
+ if (!pagesData?.stateData?.state) {
+ return null;
+ }
+
+ return pagesData.stateData.state;
+};
+
+export const deserializePageStateData = (pagesData: PagesData | null): Record> | null => {
+ const pageStateData = pagesData?.pageStateData;
+ if (!pageStateData || typeof pageStateData !== 'object') {
+ return null;
+ }
+
+ const byPageId: Record> = {};
+ for (const [pageId, stateData] of Object.entries(pageStateData)) {
+ if (!stateData || typeof stateData !== 'object') continue;
+ const state = (stateData as any).state;
+ if (state && typeof state === 'object') {
+ byPageId[pageId] = state as Record;
+ }
+ }
+
+ return byPageId;
+};
diff --git a/src/common/utils/stateBinding.ts b/src/common/utils/stateBinding.ts
new file mode 100644
index 0000000..391be91
--- /dev/null
+++ b/src/common/utils/stateBinding.ts
@@ -0,0 +1,220 @@
+import { parseScopedPath } from '../state/scopedPath';
+
+/**
+ * Parse all state bindings from text
+ * Supports flexible whitespace: both {{ property }} and {{property}} formats
+ */
+export const parseStateBindings = (text: string): string[] => {
+ // Match {{ property }} or {{property}} patterns
+ const regex = /\{\{\s*([^}]+?)\s*\}\}/g;
+ const matches: string[] = [];
+ let match;
+
+ while ((match = regex.exec(text)) !== null) {
+ const property = match[1].trim();
+ if (property) {
+ matches.push(property);
+ }
+ }
+
+ return matches;
+};
+
+/**
+ * Resolve nested property path from state object
+ * Returns empty string if property doesn't exist
+ */
+export const resolveStateValue = (state: any, path: string): any => {
+ if (!path || !state) {
+ return '';
+ }
+
+ const parts = path.split('.');
+ let current: any = state;
+
+ for (const part of parts) {
+ if (current == null || typeof current !== 'object') {
+ return '';
+ }
+ current = current[part];
+ if (current === undefined) {
+ return '';
+ }
+ }
+
+ if (current && typeof current === 'object' && typeof current.__ref === 'string') {
+ return resolveStateValue(state, current.__ref);
+ }
+
+ // Convert to string for display, but preserve null/undefined as empty string
+ if (current == null) {
+ return '';
+ }
+
+ return String(current);
+};
+
+/**
+ * Interpolate text by replacing all {{ property }} bindings with actual values
+ * Strict mode: all bindings must be scoped (`page.*` or `global.*`)
+ */
+export const interpolateText = (
+ text: string,
+ state: any,
+ options?: { strict?: boolean }
+): string => {
+ if (!text) {
+ return text || '';
+ }
+
+ const strict = options?.strict ?? true;
+
+ // Match {{ property }} or {{property}} patterns and replace
+ return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, propertyPath) => {
+ const trimmedPath = propertyPath.trim();
+ if (!trimmedPath) {
+ if (strict) {
+ throw new Error('Empty state binding is not allowed');
+ }
+ return match;
+ }
+
+ let parsedPath;
+ try {
+ parsedPath = parseScopedPath(trimmedPath);
+ } catch (error) {
+ if (!strict) {
+ return match;
+ }
+ const message = error instanceof Error ? error.message : 'Invalid binding path';
+ throw new Error(`Invalid state binding "${trimmedPath}": ${message}`);
+ }
+
+ return resolveStateValue(state, parsedPath.fullPath);
+ });
+};
+
+/**
+ * Get keys of an object
+ * Returns empty array for non-objects, arrays, null, undefined
+ */
+export const getObjectKeys = (obj: any): string[] => {
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
+ return [];
+ }
+ return Object.keys(obj);
+};
+
+/**
+ * Navigate to a nested path in an object
+ * Returns the value at the path, or undefined if not found
+ */
+export const navigatePath = (obj: any, path: string): any => {
+ if (!path || !obj) {
+ return obj;
+ }
+
+ const parts = path.split('.');
+ let current = obj;
+
+ for (const part of parts) {
+ if (current == null || typeof current !== 'object') {
+ return undefined;
+ }
+ current = current[part];
+ if (current === undefined) {
+ return undefined;
+ }
+ }
+
+ return current;
+};
+
+/**
+ * Get suggestions for autocomplete based on current path
+ * Uses a scoped state object (`page` + `global`)
+ *
+ * @param unifiedState - Object containing available scoped bindings
+ * @param currentPath - Current path being typed (e.g., "page.user.cart")
+ * @returns Array of property names for the next level
+ */
+export const getStateSuggestions = (
+ unifiedState: any,
+ currentPath?: string
+): string[] => {
+ if (!unifiedState || typeof unifiedState !== 'object') {
+ return [];
+ }
+
+ // If no path, return top-level keys
+ if (!currentPath) {
+ return getObjectKeys(unifiedState);
+ }
+
+ // Navigate to the path and return keys of that object
+ const target = navigatePath(unifiedState, currentPath);
+ return getObjectKeys(target);
+};
+
+/**
+ * Recursively resolve {{ }} bindings in an object
+ * Walks through all values and resolves bindings using interpolateText
+ *
+ * @param obj - Object to resolve (can be nested objects/arrays)
+ * @param state - Global state for resolving bindings
+ * @returns Resolved object with all bindings replaced
+ */
+const resolveObjectBindings = (obj: any, state: any): any => {
+ // Base case: primitive values
+ if (obj === null || obj === undefined) {
+ return obj;
+ }
+
+ // If it's a string, resolve any bindings
+ if (typeof obj === 'string') {
+ return interpolateText(obj, state);
+ }
+
+ // If it's an array, recursively resolve each element
+ if (Array.isArray(obj)) {
+ return obj.map((item) => resolveObjectBindings(item, state));
+ }
+
+ // If it's an object, recursively resolve each property
+ if (typeof obj === 'object') {
+ const resolved: Record = {};
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ resolved[key] = resolveObjectBindings(obj[key], state);
+ }
+ }
+ return resolved;
+ }
+
+ // Other types (number, boolean, etc.) - return as is
+ return obj;
+};
+
+/**
+ * Parse JSON request body and resolve all {{ page.* }} / {{ global.* }} bindings
+ *
+ * @param requestBodyJson - JSON string containing request body (may have {{ }} bindings)
+ * @param state - Scoped runtime state object
+ * @returns Resolved request body object
+ * @throws Error if JSON is invalid
+ */
+export const resolveRequestBody = (requestBodyJson: string, state: any): any => {
+ // Empty or whitespace-only strings return empty object
+ if (!requestBodyJson || !requestBodyJson.trim()) {
+ return {};
+ }
+
+ let parsed: any;
+ try {
+ parsed = JSON.parse(requestBodyJson);
+ } catch (error) {
+ throw new Error(`Failed to parse request body JSON: ${error instanceof Error ? error.message : String(error)}`);
+ }
+
+ return resolveObjectBindings(parsed, state);
+};
diff --git a/src/common/utils/styleNormalization.ts b/src/common/utils/styleNormalization.ts
new file mode 100644
index 0000000..2df2b9e
--- /dev/null
+++ b/src/common/utils/styleNormalization.ts
@@ -0,0 +1,41 @@
+import type { CSSProperties } from 'react';
+import type { ComponentType } from '../types/component.types';
+
+/**
+ * Normalizes component styles to prevent unwanted flexbox stretching.
+ * For components without explicit width in flex containers, applies
+ * alignSelf: 'flex-start' to prevent stretching — BUT only when the parent
+ * has not already set a non-stretch alignment (e.g. alignItems: 'center').
+ * When the parent explicitly aligns its children, we trust that and leave
+ * alignSelf untouched so the parent's intent is preserved in both editor and preview.
+ */
+export const normalizeComponentStyles = (
+ componentType: ComponentType,
+ styles: CSSProperties,
+ parentType?: ComponentType | null,
+ parentStyles?: CSSProperties | null,
+): CSSProperties => {
+ // Check if parent is a flex container
+ const isInFlexContainer = parentType === 'row' || parentType === 'column';
+
+ if (isInFlexContainer) {
+ const hasExplicitWidth = styles.width !== undefined && styles.width !== null;
+ const hasExplicitAlignSelf = styles.alignSelf !== undefined && styles.alignSelf !== null;
+
+ // Only inject alignSelf: flex-start when the parent hasn't explicitly overridden
+ // the default 'stretch' behaviour. If the parent sets alignItems to 'center',
+ // 'flex-end', 'flex-start', etc., we leave children alone so that intent is honoured.
+ const parentAlignItems = parentStyles?.alignItems;
+ const parentIsStretching =
+ !parentAlignItems || parentAlignItems === 'stretch' || parentAlignItems === 'normal';
+
+ if (!hasExplicitWidth && !hasExplicitAlignSelf && componentType !== 'input' && parentIsStretching) {
+ return {
+ ...styles,
+ alignSelf: 'flex-start',
+ };
+ }
+ }
+
+ return styles;
+};
diff --git a/src/common/utils/workflowResultKey.ts b/src/common/utils/workflowResultKey.ts
new file mode 100644
index 0000000..f1fc1a4
--- /dev/null
+++ b/src/common/utils/workflowResultKey.ts
@@ -0,0 +1,116 @@
+export interface NormalizedWorkflowResultKey {
+ relativeKey: string;
+ fullPath: string;
+}
+
+const SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+
+const ensureValidKey = (key: string): void => {
+ if (!key) {
+ throw new Error('Result key is required');
+ }
+ if (key.startsWith('state.') || key.startsWith('workflows.') || key.startsWith('global.workflows.')) {
+ throw new Error('Result key must be relative (do not include state./workflows./global.workflows. prefixes)');
+ }
+ if (key.startsWith('.') || key.endsWith('.') || key.includes('..')) {
+ throw new Error('Result key must not start or end with a dot, or contain consecutive dots');
+ }
+
+ const segments = key.split('.');
+ for (const segment of segments) {
+ if (!SEGMENT_PATTERN.test(segment)) {
+ throw new Error('Each segment must start with a letter or underscore and contain only letters, numbers, or underscores');
+ }
+ }
+};
+
+export const normalizeWorkflowResultKey = (input: string): NormalizedWorkflowResultKey => {
+ if (typeof input !== 'string') {
+ throw new Error('Result key must be a string');
+ }
+
+ const key = input.trim();
+ ensureValidKey(key);
+
+ return {
+ relativeKey: key,
+ fullPath: `global.workflows.${key}`,
+ };
+};
+
+const tokenize = (value: string): string[] =>
+ value
+ .split(/[^a-zA-Z0-9]+/)
+ .map((token) => token.trim())
+ .filter(Boolean);
+
+const toLowerCamel = (value: string): string => {
+ const tokens = tokenize(value);
+ if (tokens.length === 0) {
+ return '';
+ }
+
+ const [first, ...rest] = tokens;
+ const firstLower = first.toLowerCase();
+ const restCamel = rest.map((token) => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase());
+ return [firstLower, ...restCamel].join('');
+};
+
+const stableHash = (input: string): number => {
+ let hash = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+};
+
+const buildFallbackKey = (seed: string): string => {
+ const suffix = stableHash(seed || 'workflow').toString(36).slice(0, 6) || '0';
+ const candidate = `workflow.result.r${suffix}`;
+ try {
+ normalizeWorkflowResultKey(candidate);
+ return candidate;
+ } catch {
+ return 'workflow.result.r0';
+ }
+};
+
+export const suggestWorkflowResultKeyFromName = (workflowName: string): string => {
+ const trimmed = typeof workflowName === 'string' ? workflowName.trim() : '';
+ if (!trimmed) {
+ return buildFallbackKey('workflow');
+ }
+
+ const parts = trimmed
+ .split(':')
+ .map((part) => part.trim())
+ .filter(Boolean);
+
+ const namespaceParts = parts.length > 1 ? parts.slice(0, -1) : [];
+ const actionPart = parts.length > 0 ? parts[parts.length - 1] : trimmed;
+
+ const segments = [
+ ...namespaceParts.map((part) => toLowerCamel(part)).filter(Boolean),
+ toLowerCamel(actionPart),
+ ].filter(Boolean);
+
+ const candidate = segments.join('.');
+ if (candidate) {
+ try {
+ normalizeWorkflowResultKey(candidate);
+ return candidate;
+ } catch {
+ // fall through to fallback
+ }
+ }
+
+ return buildFallbackKey(trimmed);
+};
+
+export const getWorkflowResultKeyPreview = (input: string): string => {
+ if (typeof input !== 'string') {
+ return '';
+ }
+
+ return input.trim();
+};
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..3c840bd
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,14 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+}
+
+* {
+ box-sizing: border-box;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..8d4ac42
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './index.css';
+import { App } from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/src/renderer/RendererApp.tsx b/src/renderer/RendererApp.tsx
new file mode 100644
index 0000000..9be1c1c
--- /dev/null
+++ b/src/renderer/RendererApp.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { RendererRoutes } from './RendererRoutes';
+
+// Ensure component definitions are registered once per bundle load.
+import '../common/registry/definitions';
+
+export interface RendererAppProps {
+ json: string;
+ workflow?: { executeUrl: string };
+ initialRoute?: string;
+}
+
+export const RendererApp: React.FC = ({ json, workflow, initialRoute }) => {
+ return (
+
+ );
+};
diff --git a/src/renderer/RendererRoutes.tsx b/src/renderer/RendererRoutes.tsx
new file mode 100644
index 0000000..20a30ef
--- /dev/null
+++ b/src/renderer/RendererRoutes.tsx
@@ -0,0 +1,94 @@
+import React, { useMemo, useState, useCallback, useEffect } from 'react';
+import type { PagesData } from '../common/types/component.types';
+import { importFromJSON } from '../common/utils/serialization';
+import { formatScopedBindingViolations, validatePagesDataScopedBindings } from '../common/state/validateScopedBindings';
+import { RendererRuntimeProvider } from './runtime';
+import { Preview } from './components/Preview';
+
+// Ensure component definitions are registered once per bundle load.
+import '../common/registry/definitions';
+
+export interface RendererRoutesProps {
+ json: string;
+ workflow?: { executeUrl: string };
+ initialRoute?: string;
+}
+
+export const RendererRoutes: React.FC = ({ json, workflow, initialRoute }) => {
+ const parseResult = useMemo<{ pagesData: PagesData | null; error: string | null }>(() => {
+ const parsed = importFromJSON(json);
+ if (!parsed || !Array.isArray(parsed.pages)) {
+ return { pagesData: null, error: 'Invalid renderer JSON.' };
+ }
+
+ const violations = validatePagesDataScopedBindings(parsed);
+ if (violations.length > 0) {
+ return {
+ pagesData: null,
+ error: `Invalid scoped bindings detected:\n${formatScopedBindingViolations(violations)}`,
+ };
+ }
+
+ return { pagesData: parsed, error: null };
+ }, [json]);
+
+ const defaultRoute = useMemo(() => {
+ if (!parseResult.pagesData) return initialRoute || '/';
+ const defaultPage = parseResult.pagesData.pages.find((p) => p.isDefault) || parseResult.pagesData.pages[0];
+ return initialRoute || defaultPage?.route || '/';
+ }, [parseResult.pagesData, initialRoute]);
+
+ const getHashRoute = (): string | null => {
+ const hash = window.location.hash;
+ return hash ? hash.slice(1) : null;
+ };
+
+ const [currentRoute, setCurrentRoute] = useState(
+ getHashRoute() || defaultRoute
+ );
+
+ // Set initial hash on mount
+ useEffect(() => {
+ if (!window.location.hash) {
+ window.location.hash = defaultRoute;
+ }
+ }, []);
+
+ const handleNavigate = useCallback((route: string) => {
+ window.location.hash = route;
+ setCurrentRoute(route);
+ }, []);
+
+ // Sync state with browser back/forward
+ useEffect(() => {
+ const onHashChange = () => {
+ const route = getHashRoute();
+ if (route) setCurrentRoute(route);
+ };
+ window.addEventListener('hashchange', onHashChange);
+ return () => window.removeEventListener('hashchange', onHashChange);
+ }, []);
+
+ if (!parseResult.pagesData || !Array.isArray(parseResult.pagesData.pages)) {
+ return (
+
+ {parseResult.error || 'Invalid renderer JSON.'}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/src/renderer/components/NavigationHeader.tsx b/src/renderer/components/NavigationHeader.tsx
new file mode 100644
index 0000000..1b67976
--- /dev/null
+++ b/src/renderer/components/NavigationHeader.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import type { Page } from '../../common/types/component.types';
+
+interface NavigationHeaderProps {
+ pages: Page[];
+}
+
+export const NavigationHeader: React.FC = ({ pages }) => {
+ const location = useLocation();
+
+ return (
+
+ );
+};
diff --git a/src/renderer/components/Preview.tsx b/src/renderer/components/Preview.tsx
new file mode 100644
index 0000000..be6616f
--- /dev/null
+++ b/src/renderer/components/Preview.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import type { CSSProperties } from 'react';
+import type { ComponentNode, ComponentType, Page } from '../../common/types/component.types';
+import { ComponentRenderer } from '../../common/utils/ComponentRenderer';
+
+interface PreviewProps {
+ pages: Page[];
+ currentRoute: string;
+ onNavigate: (route: string) => void;
+}
+
+const PreviewComponent: React.FC<{
+ component: ComponentNode;
+ parentType?: ComponentType | null;
+ parentStyles?: CSSProperties | null;
+}> = ({ component, parentType = null, parentStyles = null }) => {
+ const renderChildren = (
+ children: ComponentNode[],
+ currentParentType: ComponentType,
+ currentParentStyles?: CSSProperties,
+ ) => {
+ return children.map((child) => (
+
+ ));
+ };
+
+ return (
+ {}}
+ renderChildren={renderChildren}
+ previewMode={true}
+ parentType={parentType}
+ parentStyles={parentStyles}
+ />
+ );
+};
+
+const PageContent: React.FC<{ root: ComponentNode | null }> = ({ root }) => {
+ return (
+
+ {root ? (
+
+ ) : (
+
+ No components to preview. Add components in the editor.
+
+ )}
+
+ );
+};
+
+export const Preview: React.FC = ({ pages, currentRoute }) => {
+ const defaultPage = pages.find((p) => p.isDefault) || pages[0];
+ const activePage = pages.find((p) => p.route === currentRoute) || defaultPage;
+
+ return (
+
+ );
+};
diff --git a/src/renderer/index.ts b/src/renderer/index.ts
new file mode 100644
index 0000000..2e25ee2
--- /dev/null
+++ b/src/renderer/index.ts
@@ -0,0 +1,5 @@
+export { RendererApp } from './RendererApp';
+export { RendererRoutes } from './RendererRoutes';
+export { NavigationHeader } from './components/NavigationHeader';
+export { importFromJSON } from '../common/utils/serialization';
+
diff --git a/src/renderer/runtime/RendererRuntimeProvider.tsx b/src/renderer/runtime/RendererRuntimeProvider.tsx
new file mode 100644
index 0000000..13effae
--- /dev/null
+++ b/src/renderer/runtime/RendererRuntimeProvider.tsx
@@ -0,0 +1,247 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type { EventHandler, PagesData, ComponentNode } from '../../common/types/component.types';
+import type { ComponentEventType } from '../../common/registry/types';
+import { interpolateText } from '../../common/utils/stateBinding';
+import { updateStateAtPath } from '../../common/state/statePathUtils';
+import { parseScopedPath } from '../../common/state/scopedPath';
+import { EventExecutor } from '../../common/events';
+import { RuntimeContext } from '../../common/runtime';
+import type { RuntimeContextValue } from '../../common/runtime';
+
+const isPathValidForNested = (path: string): boolean => {
+ if (!path || typeof path !== 'string') return false;
+ const trimmed = path.trim();
+ if (!trimmed || trimmed.startsWith('.') || trimmed.endsWith('.') || trimmed.includes('..')) return false;
+ const pathPattern = /^[a-zA-Z0-9_. -]+$/;
+ return pathPattern.test(trimmed);
+};
+
+const safeObject = (value: unknown): Record =>
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {};
+
+const deserializePageStateSnapshot = (pagesData: PagesData): Record> => {
+ const raw = pagesData.pageStateData;
+ if (!raw || typeof raw !== 'object') return {};
+
+ const byPageId: Record> = {};
+ for (const [pageId, stateData] of Object.entries(raw)) {
+ const state = (stateData as any)?.state;
+ byPageId[pageId] = safeObject(state);
+ }
+ return byPageId;
+};
+
+export interface RendererRuntimeProviderProps {
+ children: React.ReactNode;
+ pagesData: PagesData;
+ workflow?: { executeUrl: string };
+ currentRoute: string;
+ onNavigate: (route: string) => void;
+}
+
+export const RendererRuntimeProvider: React.FC = ({
+ children,
+ pagesData,
+ workflow,
+ currentRoute,
+ onNavigate,
+}) => {
+ const pages = pagesData.pages ?? [];
+
+ const [globalState, setGlobalState] = useState>(() => safeObject(pagesData.stateData?.state));
+ const [pageStateById, setPageStateById] = useState>>(() =>
+ deserializePageStateSnapshot(pagesData)
+ );
+
+ const currentPageId = useMemo(() => {
+ const matched = pages.find((p) => p.route === currentRoute);
+ if (matched) return matched.id;
+ const fallback = pages.find((p) => p.isDefault) || pages[0];
+ return fallback?.id ?? null;
+ }, [currentRoute, pages]);
+
+ const currentPageState = useMemo(() => {
+ if (!currentPageId) return {};
+ return pageStateById[currentPageId] ?? {};
+ }, [currentPageId, pageStateById]);
+
+ const scopedState = useMemo(() => {
+ return {
+ page: currentPageState,
+ global: globalState,
+ };
+ }, [globalState, currentPageState]);
+
+ const setScopedStateValueDirect = useCallback(
+ (path: string, value: any): boolean => {
+ let parsedPath;
+ try {
+ parsedPath = parseScopedPath(path);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+
+ if (!isPathValidForNested(parsedPath.relativePath)) return false;
+ const parts = parsedPath.relativePath.split('.');
+
+ if (parsedPath.scope === 'page') {
+ if (!currentPageId) return false;
+ setPageStateById((prev) => {
+ const current = prev[currentPageId] ?? {};
+ const next = updateStateAtPath(current, parts, value);
+ return { ...prev, [currentPageId]: next };
+ });
+ return true;
+ }
+
+ setGlobalState((prev) => updateStateAtPath(prev, parts, value));
+ return true;
+ },
+ [currentPageId]
+ );
+
+ const setScopedStateValue = useCallback(
+ (path: string, value: any): boolean => {
+ let parsedPath;
+ try {
+ parsedPath = parseScopedPath(path);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+
+ if (!isPathValidForNested(parsedPath.relativePath)) return false;
+
+ let processedValue: any = value;
+ if (typeof value === 'string') {
+ const trimmedValue = value.trim();
+ if (trimmedValue.toLowerCase() === 'true') {
+ processedValue = true;
+ } else if (trimmedValue.toLowerCase() === 'false') {
+ processedValue = false;
+ } else if (trimmedValue === 'null') {
+ processedValue = null;
+ } else {
+ const numberMatch = trimmedValue.match(/^-?\d+\.?\d*$/);
+ if (numberMatch) {
+ processedValue = Number(trimmedValue);
+ if (isNaN(processedValue)) {
+ processedValue = value;
+ }
+ } else {
+ processedValue = value;
+ }
+ }
+ }
+
+ return setScopedStateValueDirect(parsedPath.fullPath, processedValue);
+ },
+ [setScopedStateValueDirect]
+ );
+
+ const setScopedStateValueRaw = useCallback(
+ (key: string, value: any): boolean => {
+ let parsedPath;
+ try {
+ parsedPath = parseScopedPath(key);
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+
+ if (!parsedPath.relativePath) return false;
+
+ if (parsedPath.scope === 'page') {
+ if (!currentPageId) return false;
+ setPageStateById((prev) => {
+ const current = prev[currentPageId] ?? {};
+ return { ...prev, [currentPageId]: { ...current, [parsedPath.relativePath]: value } };
+ });
+ return true;
+ }
+
+ setGlobalState((prev) => ({ ...prev, [parsedPath.relativePath]: value }));
+ return true;
+ },
+ [currentPageId]
+ );
+
+ const navigateToPage = useCallback(
+ (pageId: string) => {
+ const page = pages.find((p) => p.id === pageId);
+ if (page) {
+ onNavigate(page.route);
+ }
+ },
+ [onNavigate, pages]
+ );
+
+ const eventExecutor = useMemo(() => new EventExecutor({ workflow }), [workflow?.executeUrl]);
+
+ useEffect(() => {
+ eventExecutor.setContext({
+ setStateValue: setScopedStateValue,
+ setStateValueRaw: setScopedStateValueRaw,
+ setStateValueDirect: setScopedStateValueDirect,
+ navigateToPage,
+ pages,
+ state: scopedState,
+ });
+ }, [
+ eventExecutor,
+ navigateToPage,
+ pages,
+ scopedState,
+ setScopedStateValue,
+ setScopedStateValueDirect,
+ setScopedStateValueRaw,
+ ]);
+
+ const resolveText = useCallback(
+ (text: string): string => {
+ return interpolateText(text, scopedState);
+ },
+ [scopedState]
+ );
+
+ const executeHandlers = useCallback(
+ async (handlers: EventHandler[]) => {
+ if (handlers.length === 0) return;
+ await eventExecutor.executeWithErrorHandling(handlers);
+ },
+ [eventExecutor]
+ );
+
+ const dispatchEvent = useCallback(
+ async (_eventType: ComponentEventType, component: ComponentNode, handlersKey = 'eventHandlers') => {
+ const handlers = (component.props?.[handlersKey] as EventHandler[]) || [];
+ if (handlers.length === 0) return;
+ await eventExecutor.executeWithErrorHandling(handlers);
+ },
+ [eventExecutor]
+ );
+
+ const setStateValue = useCallback(
+ (path: string, value: any) => {
+ setScopedStateValueDirect(path, value);
+ },
+ [setScopedStateValueDirect]
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ state: scopedState,
+ resolveText,
+ executeHandlers,
+ dispatchEvent,
+ pages,
+ navigateToPage,
+ setStateValue,
+ previewMode: true,
+ }),
+ [dispatchEvent, executeHandlers, navigateToPage, pages, resolveText, scopedState, setStateValue]
+ );
+
+ return {children};
+};
diff --git a/src/renderer/runtime/index.ts b/src/renderer/runtime/index.ts
new file mode 100644
index 0000000..a02b55f
--- /dev/null
+++ b/src/renderer/runtime/index.ts
@@ -0,0 +1,2 @@
+export { RendererRuntimeProvider } from './RendererRuntimeProvider';
+
diff --git a/src/renderer/utils/navigation.ts b/src/renderer/utils/navigation.ts
new file mode 100644
index 0000000..a6b60b5
--- /dev/null
+++ b/src/renderer/utils/navigation.ts
@@ -0,0 +1,20 @@
+import type { Page } from '../../common/types/component.types';
+import type { NavigateFunction } from 'react-router-dom';
+
+/**
+ * Creates a navigation handler function for event handlers
+ * Finds page by ID and navigates to its route
+ */
+export const createNavigateHandler = (
+ pages: Page[],
+ navigate: NavigateFunction
+): ((pageId: string) => void) => {
+ return (pageId: string) => {
+ const page = pages.find((p) => p.id === pageId);
+ if (page) {
+ navigate(page.route);
+ } else {
+ console.warn(`[Navigation] Page with ID ${pageId} not found`);
+ }
+ };
+};
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..8f204d2
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,36 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": [
+ "ES2022",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "types": [
+ "vite/client"
+ ],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": [
+ "src"
+ ],
+ "exclude": [
+ "**/*.test.ts",
+ "**/*.spec.ts"
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ea9d0cd
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..195c696
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": [
+ "ES2023"
+ ],
+ "module": "ESNext",
+ "types": [
+ "node"
+ ],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": [
+ "vite.config.ts"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..0466183
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+});