diff --git a/apps/cms/PLAN.md b/apps/cms/PLAN.md
new file mode 100644
index 0000000000..f5d0ce3566
--- /dev/null
+++ b/apps/cms/PLAN.md
@@ -0,0 +1,124 @@
+# DecoCMS - Content Management System
+
+> Port of admin-cx (admin.deco.cx) to the new React/Vite stack
+
+## Overview
+
+This app provides the **Content Management System** for deco sites, complementing the **Context Management System** (mesh/MCP). It enables visual editing of pages, sections, loaders, actions, and other blocks that power deco-based websites.
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ apps/cms │
+├─────────────────────────────────────────────────────────────┤
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
+│ │ Shell │ │ Spaces │ │ Editor │ │
+│ │ (Layout) │ │ (Views) │ │ (Form + Preview) │ │
+│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
+│ │ │ │
+│ ▼ ▼ │
+│ ┌─────────────────────────────────────────────────────┐ │
+│ │ packages/cms-sdk │ │
+│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │
+│ │ │ Daemon │ │ Blocks │ │ Schema │ │ Preview │ │ │
+│ │ │ Client │ │ CRUD │ │ Fetcher │ │ URLs │ │ │
+│ │ └─────────┘ └─────────┘ └─────────┘ └───────────┘ │ │
+│ └─────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+ │
+ ▼
+ ┌─────────────────────────┐
+ │ deco site runtime │
+ │ /.deco/blocks/*.json │
+ │ /live/_meta │
+ │ /live/previews/* │
+ └─────────────────────────┘
+```
+
+## Key Dependencies
+
+- **@deco/ui** - Shared UI components (buttons, inputs, tables, etc.)
+- **@deco/sdk** - Auth, API client, React Query hooks
+- **packages/cms-sdk** - NEW: CMS-specific SDK (daemon, blocks, schema)
+- **react-router** - Client-side routing
+- **react-hook-form** - Form state management
+- **ajv** - JSON Schema validation
+
+## Features to Port from admin-cx
+
+### P0 - MVP (Must Have)
+- [ ] Pages list and editor
+- [ ] Sections list and editor
+- [ ] JSON Schema form with core widgets
+- [ ] Preview iframe with viewport controls
+- [ ] Real-time daemon sync
+- [ ] Block CRUD operations
+
+### P1 - Core Features
+- [ ] Loaders list and editor
+- [ ] Actions list and editor
+- [ ] Apps management (install/uninstall)
+- [ ] Assets upload and management
+- [ ] Releases and git operations
+- [ ] Analytics integration (Plausible)
+- [ ] Logs viewer (HyperDX)
+- [ ] SEO settings
+- [ ] Redirects management
+
+### P2 - Advanced Features
+- [ ] Themes editor
+- [ ] Segments and experiments
+- [ ] Blog management
+- [ ] Records (Drizzle Studio)
+
+## Implementation Phases
+
+### Phase 1: Foundation (Week 1-2)
+1. Create packages/cms-sdk with daemon client
+2. Setup apps/cms with basic routing
+3. Implement site connection flow
+
+### Phase 2: Core Editor (Week 2-3)
+1. Port JSON Schema form system
+2. Implement preview iframe
+3. Create block editor component
+
+### Phase 3: Spaces (Week 4-6)
+1. Pages space
+2. Sections space
+3. Loaders/Actions spaces
+4. Apps space
+5. Assets space
+
+### Phase 4: Operations (Week 6-7)
+1. Releases and git operations
+2. Settings (domains, team)
+3. Navigation between mesh and cms
+
+### Phase 5: Analytics & Observability (Week 8)
+1. Analytics integration
+2. Logs viewer
+3. Error monitoring
+
+## File Structure
+
+See individual `PLAN.md` files in each subdirectory for detailed implementation plans.
+
+## Running Locally
+
+```bash
+# From repo root
+npm run dev:cms
+
+# Or directly
+cd apps/cms && npm run dev
+```
+
+## Environment Variables
+
+```env
+VITE_API_URL=http://localhost:3000
+VITE_SITE_DOMAIN=.deco.site
+```
+
diff --git a/apps/cms/index.html b/apps/cms/index.html
new file mode 100644
index 0000000000..006b0d9e5a
--- /dev/null
+++ b/apps/cms/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ Deco CMS
+
+
+
+
+
+
+
+
diff --git a/apps/cms/package.json b/apps/cms/package.json
new file mode 100644
index 0000000000..e16982ae5f
--- /dev/null
+++ b/apps/cms/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@deco/cms",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Deco Content Management System",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "biome lint src/",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@deco/cms-sdk": "workspace:*",
+ "@deco/sdk": "workspace:*",
+ "@deco/ui": "workspace:*",
+ "@dnd-kit/core": "^6.1.0",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@hookform/resolvers": "^3.3.0",
+ "@tanstack/react-query": "^5.0.0",
+ "ajv": "^8.12.0",
+ "ajv-formats": "^2.1.1",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "react-hook-form": "^7.50.0",
+ "react-router": "^7.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.2.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.4.0",
+ "vite": "^5.2.0"
+ }
+}
+
diff --git a/apps/cms/src/PLAN.md b/apps/cms/src/PLAN.md
new file mode 100644
index 0000000000..7dede5c53d
--- /dev/null
+++ b/apps/cms/src/PLAN.md
@@ -0,0 +1,79 @@
+# apps/cms/src - Source Structure
+
+## Directory Layout
+
+```
+src/
+├── main.tsx # App entry point
+├── routes/ # Route components
+├── components/ # React components
+│ ├── shell/ # Layout components
+│ ├── spaces/ # Space views (pages, sections, etc.)
+│ ├── editor/ # Form and preview
+│ └── common/ # Shared components
+├── hooks/ # React hooks
+├── providers/ # Context providers
+├── stores/ # Zustand stores (if needed)
+└── utils/ # Utility functions
+```
+
+## Entry Point (main.tsx)
+
+```tsx
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { RouterProvider } from "react-router";
+import { DecoQueryClientProvider } from "@deco/sdk";
+import { router } from "./routes";
+
+import "@deco/ui/styles/global.css";
+import "./styles.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+);
+```
+
+## Routing Structure
+
+The CMS will be accessible at `/:org/:project/cms/*` or as a standalone app.
+
+Routes:
+- `/:org/:site` - Site dashboard
+- `/:org/:site/pages` - Pages list
+- `/:org/:site/pages/:pageId` - Page editor
+- `/:org/:site/sections` - Sections list
+- `/:org/:site/sections/:sectionId` - Section editor
+- `/:org/:site/loaders` - Loaders list
+- `/:org/:site/actions` - Actions list
+- `/:org/:site/apps` - Apps management
+- `/:org/:site/assets` - Asset library
+- `/:org/:site/releases` - Release management
+- `/:org/:site/analytics` - Analytics dashboard
+- `/:org/:site/logs` - Logs viewer
+- `/:org/:site/settings` - Site settings
+
+## Key Patterns
+
+### 1. Site Context Provider
+All routes under `/:org/:site` will be wrapped with `SiteProvider` that:
+- Establishes daemon connection
+- Fetches site metadata (`/live/_meta`)
+- Provides block access via React Query
+
+### 2. Space Pattern
+Each "space" (pages, sections, etc.) follows the same pattern:
+- List view with table/grid
+- Detail/edit view with form + preview
+- Uses shared `BlockEditor` component
+
+### 3. Real-time Updates
+The daemon connection provides real-time file system updates:
+- File changes trigger query invalidation
+- Optimistic updates for better UX
+- Conflict resolution for concurrent edits
+
diff --git a/apps/cms/src/components/PLAN.md b/apps/cms/src/components/PLAN.md
new file mode 100644
index 0000000000..655fedf484
--- /dev/null
+++ b/apps/cms/src/components/PLAN.md
@@ -0,0 +1,79 @@
+# Components Structure
+
+## Overview
+
+Components are organized by function:
+
+```
+components/
+├── shell/ # App shell and navigation
+├── spaces/ # Main content areas (pages, sections, etc.)
+├── editor/ # Block editing (form + preview)
+└── common/ # Shared/reusable components
+```
+
+## Component Guidelines
+
+### 1. Use @deco/ui Components
+Always prefer `@deco/ui` components over creating new ones:
+- `Button`, `Input`, `Select` from `@deco/ui/components`
+- `ResourceTable` for data lists
+- `Dialog`, `Sheet` for modals
+- `Tabs`, `Accordion` for organization
+
+### 2. Colocation
+Keep related files together:
+```
+components/spaces/pages/
+├── index.tsx # Main export
+├── PagesList.tsx # List component
+├── PageEditor.tsx # Editor component
+├── use-pages.ts # Page-specific hooks
+└── types.ts # Type definitions
+```
+
+### 3. Props Pattern
+Use explicit prop interfaces:
+```tsx
+interface PageEditorProps {
+ pageId: string;
+ onSave?: (page: Page) => void;
+ onCancel?: () => void;
+}
+
+export function PageEditor({ pageId, onSave, onCancel }: PageEditorProps) {
+ // ...
+}
+```
+
+## Key Components to Implement
+
+### Shell Components
+- `CMSLayout` - Main app shell with sidebar + topbar
+- `Sidebar` - Navigation between spaces
+- `Topbar` - Breadcrumbs, site selector, user menu
+- `SpaceContainer` - Container for space content
+
+### Space Components
+- `PagesSpace` - Pages list and management
+- `SectionsSpace` - Sections list and management
+- `LoadersSpace` - Loaders list
+- `ActionsSpace` - Actions list
+- `AppsSpace` - App installation
+- `AssetsSpace` - Asset library
+- `ReleasesSpace` - Git releases
+- `SettingsSpace` - Site configuration
+
+### Editor Components
+- `BlockEditor` - Combined form + preview layout
+- `JSONSchemaForm` - Form rendered from JSON Schema
+- `Preview` - iframe preview with controls
+- `Addressbar` - URL input with viewport switcher
+
+### Common Components
+- `BlockCard` - Card displaying block info
+- `BlockSelector` - Modal for selecting blocks
+- `AssetPicker` - Modal for selecting/uploading assets
+- `ColorPicker` - Color input with picker
+- `CodeEditor` - Monaco-based code editor
+
diff --git a/apps/cms/src/components/editor/PLAN.md b/apps/cms/src/components/editor/PLAN.md
new file mode 100644
index 0000000000..4680dfe694
--- /dev/null
+++ b/apps/cms/src/components/editor/PLAN.md
@@ -0,0 +1,126 @@
+# Editor Components
+
+## Overview
+
+The editor system consists of two main parts:
+1. **JSON Schema Form** - Dynamic form generation from JSON Schema
+2. **Preview** - Live iframe preview of the block being edited
+
+## Components
+
+### BlockEditor.tsx
+
+Main editor component combining form and preview.
+
+```tsx
+interface BlockEditorProps {
+ blockId: string;
+ block: Block;
+ schema: JSONSchema;
+ showPreview?: boolean;
+ previewPath?: string;
+ onSave?: (block: Block) => void;
+}
+
+export function BlockEditor({
+ blockId,
+ block,
+ schema,
+ showPreview = true,
+ previewPath,
+ onSave,
+}: BlockEditorProps) {
+ const [formData, setFormData] = useState(block);
+
+ const handleChange = (data: Block) => {
+ setFormData(data);
+ onSave?.(data);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {showPreview && (
+ <>
+
+
+
+
+ >
+ )}
+
+ );
+}
+```
+
+## Directory Structure
+
+```
+editor/
+├── BlockEditor.tsx # Main editor layout
+├── json-schema/ # Form system
+│ ├── Form.tsx # Main form component
+│ ├── widgets/ # Input widgets
+│ ├── templates/ # Field/array/object templates
+│ └── utils/ # Schema utilities
+└── preview/ # Preview system
+ ├── Preview.tsx # Main preview component
+ ├── Addressbar.tsx # URL bar with viewport
+ └── ViewportSelector.tsx
+```
+
+## Implementation Priority
+
+### P0 - Required for MVP
+1. `BlockEditor.tsx` - Layout component
+2. `json-schema/Form.tsx` - Basic form rendering
+3. `json-schema/widgets/StringField.tsx`
+4. `json-schema/widgets/NumberField.tsx`
+5. `json-schema/widgets/BooleanField.tsx`
+6. `json-schema/widgets/ArrayField.tsx`
+7. `json-schema/widgets/ObjectField.tsx`
+8. `json-schema/widgets/SelectField.tsx`
+9. `preview/Preview.tsx`
+10. `preview/Addressbar.tsx`
+
+### P1 - Core Widgets
+11. `widgets/BlockSelector.tsx` - Select sections/blocks
+12. `widgets/MediaUpload.tsx` - Image/file upload
+13. `widgets/ColorPicker.tsx`
+14. `widgets/RichText.tsx`
+
+### P2 - Advanced Widgets
+15. `widgets/CodeEditor.tsx` - Monaco editor
+16. `widgets/SecretInput.tsx`
+17. `widgets/MapPicker.tsx`
+18. `widgets/DatePicker.tsx`
+19. `widgets/IconSelector.tsx`
+20. `widgets/DynamicOptions.tsx`
+
+## Porting Strategy
+
+The JSON Schema form is a critical component. Strategy:
+
+1. **Start Fresh** - Use `react-hook-form` + `ajv` instead of RJSF
+2. **Port Widgets** - Convert Preact widgets to React one by one
+3. **Maintain Compatibility** - Same schema format as admin-cx
+4. **Improve UX** - Take opportunity to improve upon original
+
+See detailed plans:
+- `json-schema/PLAN.md`
+- `preview/PLAN.md`
+
diff --git a/apps/cms/src/components/editor/json-schema/PLAN.md b/apps/cms/src/components/editor/json-schema/PLAN.md
new file mode 100644
index 0000000000..de0f8dba66
--- /dev/null
+++ b/apps/cms/src/components/editor/json-schema/PLAN.md
@@ -0,0 +1,313 @@
+# JSON Schema Form System
+
+## Overview
+
+The JSON Schema form system dynamically generates forms from JSON Schema definitions. This is the core of the CMS editing experience.
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Form.tsx │
+│ ┌─────────────────────────────────────────────────────┐ │
+│ │ FormProvider │ │
+│ │ (react-hook-form + ajv validation) │ │
+│ └─────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────────────────────────────────────────┐ │
+│ │ SchemaRenderer │ │
+│ │ - Resolves $ref │ │
+│ │ - Determines widget type │ │
+│ │ - Handles oneOf/anyOf │ │
+│ └─────────────────────────────────────────────────────┘ │
+│ │ │
+│ ┌───────────────┼───────────────┐ │
+│ ▼ ▼ ▼ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
+│ │ String │ │ Object │ │ Custom │ │
+│ │ Widget │ │ Template │ │ Widget │ │
+│ └──────────┘ └──────────┘ └──────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Core Components
+
+### Form.tsx
+
+Main form component that orchestrates everything.
+
+```tsx
+interface JSONSchemaFormProps {
+ schema: JSONSchema;
+ formData: unknown;
+ onChange: (data: unknown, errors?: ValidationError[]) => void;
+ blockId?: string;
+ className?: string;
+}
+
+export function JSONSchemaForm({
+ schema,
+ formData,
+ onChange,
+ blockId,
+ className,
+}: JSONSchemaFormProps) {
+ const methods = useForm({
+ defaultValues: formData,
+ resolver: ajvResolver(schema),
+ mode: 'onChange',
+ });
+
+ // Sync form changes with parent
+ useEffect(() => {
+ const subscription = methods.watch((data) => {
+ onChange(data, methods.formState.errors);
+ });
+ return () => subscription.unsubscribe();
+ }, [methods, onChange]);
+
+ return (
+
+
+
+
+
+ );
+}
+```
+
+### SchemaRenderer.tsx
+
+Recursively renders schema nodes.
+
+```tsx
+interface SchemaRendererProps {
+ schema: JSONSchema;
+ path: string;
+}
+
+export function SchemaRenderer({ schema, path }: SchemaRendererProps) {
+ // Resolve $ref
+ const resolvedSchema = useResolvedSchema(schema);
+
+ // Handle oneOf/anyOf
+ if (resolvedSchema.oneOf || resolvedSchema.anyOf) {
+ return ;
+ }
+
+ // Get widget for schema type
+ const Widget = getWidget(resolvedSchema);
+
+ return ;
+}
+```
+
+## Widget Resolution
+
+Widgets are resolved based on schema properties:
+
+```typescript
+function getWidget(schema: JSONSchema): WidgetComponent {
+ // Check for explicit widget
+ if (schema.format === 'uri' && schema['x-widget'] === 'image') {
+ return MediaUploadWidget;
+ }
+
+ // Check format
+ if (schema.format === 'color') return ColorPickerWidget;
+ if (schema.format === 'date') return DatePickerWidget;
+ if (schema.format === 'date-time') return DateTimePickerWidget;
+ if (schema.format === 'uri') return UrlInputWidget;
+ if (schema.format === 'code') return CodeEditorWidget;
+
+ // Check type
+ switch (schema.type) {
+ case 'string':
+ if (schema.enum) return SelectWidget;
+ if (schema.maxLength > 100) return TextareaWidget;
+ return StringWidget;
+ case 'number':
+ case 'integer':
+ return NumberWidget;
+ case 'boolean':
+ return BooleanWidget;
+ case 'array':
+ return ArrayWidget;
+ case 'object':
+ return ObjectWidget;
+ default:
+ return StringWidget;
+ }
+}
+```
+
+## Widgets Directory
+
+```
+widgets/
+├── primitives/
+│ ├── StringWidget.tsx # Text input
+│ ├── NumberWidget.tsx # Number input
+│ ├── BooleanWidget.tsx # Checkbox/toggle
+│ ├── SelectWidget.tsx # Dropdown select
+│ └── TextareaWidget.tsx # Multi-line text
+├── complex/
+│ ├── ArrayWidget.tsx # Array field with add/remove
+│ ├── ObjectWidget.tsx # Nested object
+│ └── TypeSelector.tsx # oneOf/anyOf selector
+├── custom/
+│ ├── BlockSelector.tsx # Section/block picker
+│ ├── MediaUpload.tsx # Image/file upload
+│ ├── ColorPicker.tsx # Color input
+│ ├── CodeEditor.tsx # Monaco editor
+│ ├── RichText.tsx # TipTap editor
+│ ├── SecretInput.tsx # Password field
+│ ├── MapPicker.tsx # Location picker
+│ ├── DatePicker.tsx # Date input
+│ └── IconSelector.tsx # Icon picker
+└── templates/
+ ├── FieldTemplate.tsx # Wrapper for all fields
+ ├── ArrayTemplate.tsx # Array item layout
+ └── ObjectTemplate.tsx # Object layout
+```
+
+## Key Widget Implementations
+
+### BlockSelector.tsx
+
+The most complex widget - allows selecting sections from library.
+
+```tsx
+export function BlockSelector({ schema, path }: WidgetProps) {
+ const { setValue, watch } = useFormContext();
+ const value = watch(path);
+ const [isOpen, setIsOpen] = useState(false);
+
+ // Get available block types from schema
+ const blockTypes = getBlockTypes(schema);
+
+ return (
+ <>
+ setIsOpen(true)}
+ >
+ {value?.__resolveType ? (
+
+ ) : (
+ Select section...
+ )}
+
+
+ {
+ setValue(path, block);
+ setIsOpen(false);
+ }}
+ />
+ >
+ );
+}
+```
+
+### ArrayWidget.tsx
+
+Array field with drag-and-drop reordering.
+
+```tsx
+export function ArrayWidget({ schema, path }: WidgetProps) {
+ const { control } = useFormContext();
+ const { fields, append, remove, move } = useFieldArray({
+ control,
+ name: path,
+ });
+
+ return (
+
+
{
+ if (over && active.id !== over.id) {
+ const oldIndex = fields.findIndex(f => f.id === active.id);
+ const newIndex = fields.findIndex(f => f.id === over.id);
+ move(oldIndex, newIndex);
+ }
+ }}>
+ f.id)}>
+ {fields.map((field, index) => (
+ remove(index)}
+ />
+ ))}
+
+
+
+
+
+ );
+}
+```
+
+## Validation
+
+Using AJV for JSON Schema validation:
+
+```typescript
+// utils/ajv-resolver.ts
+import Ajv from 'ajv';
+import addFormats from 'ajv-formats';
+
+const ajv = new Ajv({ allErrors: true });
+addFormats(ajv);
+
+export function ajvResolver(schema: JSONSchema) {
+ const validate = ajv.compile(schema);
+
+ return async (data: unknown) => {
+ const valid = validate(data);
+
+ if (valid) {
+ return { values: data, errors: {} };
+ }
+
+ const errors = validate.errors?.reduce((acc, error) => {
+ const path = error.instancePath.replace(/\//g, '.');
+ acc[path] = { message: error.message };
+ return acc;
+ }, {} as Record);
+
+ return { values: {}, errors };
+ };
+}
+```
+
+## Porting from admin-cx
+
+Key files to reference:
+- `admin-cx/components/editor/JSONSchema/Form.tsx`
+- `admin-cx/components/editor/JSONSchema/widgets/*.tsx`
+- `admin-cx/components/editor/JSONSchema/utils.ts`
+- `admin-cx/components/editor/JSONSchema/validator.ts`
+
+Main differences:
+1. **React Hook Form** instead of custom form state
+2. **AJV** instead of RJSF validation
+3. **Tailwind/shadcn** instead of custom UI
+4. **DnD Kit** instead of custom drag-and-drop
+
diff --git a/apps/cms/src/components/editor/preview/PLAN.md b/apps/cms/src/components/editor/preview/PLAN.md
new file mode 100644
index 0000000000..0304a51f92
--- /dev/null
+++ b/apps/cms/src/components/editor/preview/PLAN.md
@@ -0,0 +1,261 @@
+# Preview System
+
+## Overview
+
+The preview system provides a live iframe preview of the block being edited. It communicates with the deco runtime to render blocks in real-time.
+
+## Components
+
+### Preview.tsx
+
+Main preview component with iframe and controls.
+
+```tsx
+interface PreviewProps {
+ block: Block;
+ blockId?: string;
+ previewPath?: string;
+ className?: string;
+}
+
+export function Preview({
+ block,
+ blockId,
+ previewPath = '/',
+ className,
+}: PreviewProps) {
+ const { site } = useSite();
+ const iframeRef = useRef(null);
+ const [viewport, setViewport] = useState('desktop');
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Build preview URL
+ const previewUrl = usePreviewUrl(site, block, {
+ path: previewPath,
+ viewport,
+ });
+
+ // Handle iframe communication
+ useLiveEditorEvents({
+ iframeRef,
+ onEditProp: (paths) => {
+ // Scroll to field in form
+ const fieldId = paths.join('_');
+ document.getElementById(fieldId)?.scrollIntoView({ behavior: 'smooth' });
+ },
+ });
+
+ return (
+
+ );
+}
+```
+
+### Addressbar.tsx
+
+URL bar with viewport controls and external link.
+
+```tsx
+interface AddressbarProps {
+ url: string;
+ viewport: Viewport;
+ onViewportChange: (viewport: Viewport) => void;
+ isLoading?: boolean;
+}
+
+export function Addressbar({
+ url,
+ viewport,
+ onViewportChange,
+ isLoading,
+}: AddressbarProps) {
+ const displayUrl = useMemo(() => {
+ const parsed = new URL(url);
+ return `${parsed.pathname}${parsed.search}`;
+ }, [url]);
+
+ return (
+
+ {/* Viewport selector */}
+
+
+ {/* URL display */}
+
+ {isLoading && }
+
+ {displayUrl}
+
+
+
+ {/* External link */}
+
+
+ );
+}
+```
+
+### ViewportSelector.tsx
+
+Toggle between mobile, tablet, and desktop viewports.
+
+```tsx
+const VIEWPORTS = {
+ mobile: { width: 412, height: 823, icon: Smartphone },
+ tablet: { width: 1024, height: 1366, icon: Tablet },
+ desktop: { width: 1280, height: 800, icon: Monitor },
+} as const;
+
+export function ViewportSelector({ value, onChange }: ViewportSelectorProps) {
+ return (
+
+ {Object.entries(VIEWPORTS).map(([key, { icon: Icon }]) => (
+
+
+
+ ))}
+
+ );
+}
+```
+
+## Preview URL Generation
+
+```typescript
+// hooks/use-preview-url.ts
+export function usePreviewUrl(
+ site: Site,
+ block: Block,
+ options: PreviewOptions
+): string {
+ return useMemo(() => {
+ const { __resolveType, ...props } = block;
+
+ if (!__resolveType) return '';
+
+ const url = new URL(`${site.url}/live/previews/${__resolveType}`);
+
+ // Add path parameter
+ url.searchParams.set('path', options.path);
+ url.searchParams.set('pathTemplate', options.path);
+
+ // Add encoded props
+ url.searchParams.set('props', encodeProps(JSON.stringify(props)));
+
+ // Add viewport hint
+ url.searchParams.set('deviceHint', options.viewport);
+
+ // Disable async rendering
+ url.searchParams.set('__decoFBT', '0');
+ url.searchParams.set('__d', '');
+
+ // Add cache buster
+ url.searchParams.set('__cb', site.etag || Date.now().toString());
+
+ return url.toString();
+ }, [site, block, options]);
+}
+
+function encodeProps(props: string): string {
+ return btoa(encodeURIComponent(props));
+}
+```
+
+## Iframe Communication
+
+The preview iframe and editor communicate via postMessage:
+
+```typescript
+// hooks/use-live-editor-events.ts
+interface LiveEditorEventsOptions {
+ iframeRef: RefObject;
+ onEditProp?: (paths: string[]) => void;
+ onSelectSection?: (data: { index: number }) => void;
+}
+
+export function useLiveEditorEvents({
+ iframeRef,
+ onEditProp,
+ onSelectSection,
+}: LiveEditorEventsOptions) {
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ // Verify origin
+ if (!event.origin.includes('.deco.site')) return;
+
+ const { type, args } = event.data;
+
+ switch (type) {
+ case 'editor::edit':
+ onEditProp?.(args.paths);
+ break;
+ case 'editor::select-section':
+ onSelectSection?.(args);
+ break;
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, [onEditProp, onSelectSection]);
+
+ // Send mode to iframe
+ const sendMode = useCallback((mode: 'view' | 'edit') => {
+ iframeRef.current?.contentWindow?.postMessage(
+ { type: 'editor::mode', args: { mode } },
+ '*'
+ );
+ }, [iframeRef]);
+
+ return { sendMode };
+}
+```
+
+## Message Types
+
+Messages sent FROM iframe (site) TO editor:
+- `editor::edit` - User clicked to edit a field
+- `editor::select-section` - User clicked to select a section
+- `editor::ready` - Iframe finished loading
+
+Messages sent FROM editor TO iframe:
+- `editor::mode` - Set edit/view mode
+- `editor::highlight` - Highlight a specific element
+- `editor::scroll-to` - Scroll to a specific section
+
+## Porting from admin-cx
+
+Key files to reference:
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/Preview.tsx`
+- `admin-cx/components/pages/View.tsx`
+- `admin-cx/components/pages/block-edit/BlockEditorPreview.tsx`
+- `admin-cx/components/pages/block-edit/inlineEditor.ts`
+- `admin-cx/components/pages/block-edit/state.tsx`
+
+The preview system is largely the same - it uses the same deco runtime endpoints (`/live/previews/*`). Main changes:
+1. React refs instead of Preact refs
+2. CSS modules/Tailwind instead of inline styles
+3. Zustand/React Query instead of Signals for state
+
diff --git a/apps/cms/src/components/shell/PLAN.md b/apps/cms/src/components/shell/PLAN.md
new file mode 100644
index 0000000000..5ed6260525
--- /dev/null
+++ b/apps/cms/src/components/shell/PLAN.md
@@ -0,0 +1,134 @@
+# Shell Components
+
+## Overview
+
+The shell provides the main application layout, including navigation, topbar, and content areas.
+
+## Components
+
+### CMSLayout.tsx
+
+Main layout component that wraps all CMS routes.
+
+```tsx
+interface CMSLayoutProps {
+ children: React.ReactNode;
+}
+
+export function CMSLayout({ children }: CMSLayoutProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+```
+
+### Sidebar.tsx
+
+Navigation sidebar with space links.
+
+**Features:**
+- Collapsible sections (Content, Advanced, Management)
+- Active state indication
+- Badge for counts/notifications
+- Pin/unpin functionality
+
+**Navigation Items:**
+```typescript
+const navItems = [
+ // Content
+ { id: 'pages', label: 'Pages', icon: 'file', href: '/pages' },
+ { id: 'sections', label: 'Sections', icon: 'component', href: '/sections' },
+ { id: 'assets', label: 'Assets', icon: 'image', href: '/assets' },
+ { id: 'releases', label: 'Releases', icon: 'rocket', href: '/releases' },
+
+ // Advanced
+ { id: 'loaders', label: 'Loaders', icon: 'database', href: '/loaders' },
+ { id: 'actions', label: 'Actions', icon: 'zap', href: '/actions' },
+ { id: 'apps', label: 'Apps', icon: 'grid', href: '/apps' },
+ { id: 'seo', label: 'SEO', icon: 'search', href: '/seo' },
+ { id: 'redirects', label: 'Redirects', icon: 'corner-up-right', href: '/redirects' },
+
+ // Management
+ { id: 'analytics', label: 'Analytics', icon: 'bar-chart', href: '/analytics' },
+ { id: 'logs', label: 'Logs', icon: 'terminal', href: '/logs' },
+ { id: 'settings', label: 'Settings', icon: 'settings', href: '/settings' },
+];
+```
+
+### Topbar.tsx
+
+Top navigation bar with context information.
+
+**Features:**
+- Breadcrumb navigation (Org > Site > Space > Item)
+- Site/env selector dropdown
+- Connection status indicator
+- User menu (profile, logout)
+- Quick actions (publish, preview)
+
+```tsx
+export function Topbar() {
+ const { site, env } = useSite();
+ const { status } = useDaemon();
+
+ return (
+
+ );
+}
+```
+
+### SpaceContainer.tsx
+
+Container for space content with consistent padding and scroll behavior.
+
+```tsx
+interface SpaceContainerProps {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export function SpaceContainer({ title, actions, children }: SpaceContainerProps) {
+ return (
+
+ {(title || actions) && (
+
+ {title &&
{title}
}
+ {actions &&
{actions}
}
+
+ )}
+ {children}
+
+ );
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/shell/Index.tsx`
+- `admin-cx/components/spaces/shell/SideNav.tsx`
+- `admin-cx/components/spaces/siteEditor/Index.tsx`
+
+Key differences:
+1. React instead of Preact (hooks are similar)
+2. `@deco/ui` components instead of custom UI
+3. React Router instead of Fresh routes
+4. React Query instead of Preact Signals for state
+
diff --git a/apps/cms/src/components/spaces/PLAN.md b/apps/cms/src/components/spaces/PLAN.md
new file mode 100644
index 0000000000..9f9aa84241
--- /dev/null
+++ b/apps/cms/src/components/spaces/PLAN.md
@@ -0,0 +1,94 @@
+# Space Components
+
+## Overview
+
+Spaces are the main content areas of the CMS, each focused on a specific type of content or functionality.
+
+## Space Pattern
+
+Each space follows a consistent pattern:
+
+```
+spaces/{spaceName}/
+├── index.tsx # Main export + routing logic
+├── {SpaceName}List.tsx # List/grid view
+├── {SpaceName}Edit.tsx # Edit view with form + preview
+├── use-{spaceName}.ts # Space-specific hooks
+└── types.ts # Type definitions
+```
+
+## Implementation Priority
+
+### P0 - MVP
+1. **Pages** - Core page management
+2. **Sections** - Reusable UI blocks
+
+### P1 - Core
+3. **Loaders** - Data fetching blocks
+4. **Actions** - Server actions
+5. **Apps** - App installation
+6. **Assets** - Media library
+
+### P2 - Advanced
+7. **Releases** - Git versioning
+8. **Analytics** - Stats dashboard
+9. **Logs** - Server logs
+10. **Settings** - Configuration
+11. **SEO** - Meta/sitemap
+12. **Redirects** - URL redirects
+13. **Segments** - Audience targeting
+14. **Experiments** - A/B testing
+
+## Detailed Plans
+
+See `PLAN.md` in each space subdirectory:
+- `spaces/pages/PLAN.md`
+- `spaces/sections/PLAN.md`
+- `spaces/apps/PLAN.md`
+- etc.
+
+## Shared Space Components
+
+### BlockList.tsx
+Generic list component for block-based spaces:
+
+```tsx
+interface BlockListProps {
+ blocks: T[];
+ columns: Column[];
+ onSelect: (block: T) => void;
+ onCreate?: () => void;
+ onDelete?: (block: T) => void;
+ emptyState?: React.ReactNode;
+}
+```
+
+### BlockEdit.tsx
+Generic edit layout with form + preview:
+
+```tsx
+interface BlockEditProps {
+ blockId: string;
+ blockType: 'pages' | 'sections' | 'loaders' | 'actions';
+}
+```
+
+## Data Flow
+
+```
+┌─────────────────┐ ┌─────────────────┐
+│ SpaceList │────▶│ SpaceEdit │
+│ (React Query) │ │ (React Query) │
+└────────┬────────┘ └────────┬────────┘
+ │ │
+ │ ┌──────────────────┤
+ │ │ │
+ ▼ ▼ ▼
+┌─────────────────┐ ┌─────────────────┐
+│ cms-sdk │ │ JSONSchemaForm │
+│ blocks.list() │ │ + Preview │
+│ blocks.get() │ │ │
+│ blocks.save() │ │ │
+└─────────────────┘ └─────────────────┘
+```
+
diff --git a/apps/cms/src/components/spaces/actions/PLAN.md b/apps/cms/src/components/spaces/actions/PLAN.md
new file mode 100644
index 0000000000..57b045fc3e
--- /dev/null
+++ b/apps/cms/src/components/spaces/actions/PLAN.md
@@ -0,0 +1,227 @@
+# Actions Space
+
+## Overview
+
+The Actions space manages server actions - functions that handle user interactions like form submissions, cart operations, and other mutations.
+
+## What are Actions?
+
+Actions are TypeScript functions that:
+- Run on the server when triggered by user interaction
+- Handle mutations (create, update, delete operations)
+- Can return data or redirect
+- Are called via POST requests
+
+## Components
+
+### ActionsList.tsx
+
+List view showing all saved action instances.
+
+```tsx
+export function ActionsList() {
+ const { data: actions, isLoading } = useActions();
+ const { data: templates } = useActionTemplates();
+
+ return (
+ }
+ >
+ formatActionType(a.__resolveType) },
+ { key: 'updatedAt', label: 'Updated', render: (a) => formatTimeAgo(a.updatedAt) },
+ ]}
+ onRowClick={(action) => navigate(action.id)}
+ searchPlaceholder="Search actions..."
+ />
+
+ );
+}
+```
+
+### ActionsEdit.tsx
+
+Action configuration editor with test panel.
+
+```tsx
+export function ActionsEdit({ actionId }: { actionId: string }) {
+ const { data: action } = useAction(actionId);
+ const { data: schema } = useBlockSchema(action?.__resolveType);
+ const saveAction = useSaveBlock();
+
+ return (
+
+ {/* Form */}
+
+
+
Configuration
+
+
saveAction.mutate({ id: actionId, data })}
+ className="p-4"
+ />
+
+
+ {/* Test Panel */}
+
+
+ );
+}
+```
+
+### ActionTestPanel.tsx
+
+Panel for testing/invoking an action.
+
+```tsx
+interface ActionTestPanelProps {
+ actionId: string;
+ action: Block;
+}
+
+export function ActionTestPanel({ actionId, action }: ActionTestPanelProps) {
+ const { site } = useSite();
+ const [payload, setPayload] = useState('{}');
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const testAction = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const requestPayload = {
+ ...action,
+ ...JSON.parse(payload),
+ };
+
+ const response = await fetch(`${site.url}/live/invoke/${action.__resolveType}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestPayload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ setResult(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+ {result && (
+
+
Result:
+
+ {JSON.stringify(result, null, 2)}
+
+
+ )}
+
+ );
+}
+```
+
+## Hooks
+
+### use-actions.ts
+
+```tsx
+// List all saved actions
+export function useActions() {
+ const { blocks } = useDaemon();
+ return useQuery({
+ queryKey: ['actions'],
+ queryFn: () => blocks.list({ type: 'actions' }),
+ });
+}
+
+// Get action templates from manifest
+export function useActionTemplates() {
+ const { meta } = useSite();
+ return useMemo(() => {
+ const actions = meta?.manifest.blocks.actions || {};
+ return Object.keys(actions)
+ .filter(key => !key.startsWith('deco-sites'))
+ .map(resolveType => ({
+ resolveType,
+ name: resolveType.split('/').pop()?.replace('.ts', ''),
+ schema: actions[resolveType],
+ }));
+ }, [meta]);
+}
+
+// Invoke action for testing
+export function useInvokeAction() {
+ const { site } = useSite();
+
+ return useMutation({
+ mutationFn: async ({ resolveType, props }: { resolveType: string; props: unknown }) => {
+ const response = await fetch(`${site.url}/live/invoke/${resolveType}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(props),
+ });
+ return response.json();
+ },
+ });
+}
+```
+
+## Common Action Types
+
+From apps in decohub:
+- `vtex/actions/cart/addItems.ts` - Add to cart
+- `vtex/actions/cart/updateItem.ts` - Update cart item
+- `shopify/actions/cart/addItem.ts` - Shopify add to cart
+- `website/actions/newsletter/subscribe.ts` - Newsletter signup
+- `website/actions/sendEmail.ts` - Send email
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/List.tsx`
+- `admin-cx/components/library/BlockSelector.tsx`
+
diff --git a/apps/cms/src/components/spaces/analytics/PLAN.md b/apps/cms/src/components/spaces/analytics/PLAN.md
new file mode 100644
index 0000000000..700eb8a962
--- /dev/null
+++ b/apps/cms/src/components/spaces/analytics/PLAN.md
@@ -0,0 +1,201 @@
+# Analytics Space
+
+## Overview
+
+The Analytics space provides site analytics data, primarily through embedded Plausible or other analytics providers.
+
+## Analytics Providers
+
+1. **Plausible** (Primary) - Privacy-focused analytics
+2. **Cloudflare Analytics** - CDN-level metrics
+3. **OneDollarStats** - Budget analytics option
+4. **Custom** - HyperDX for logs and traces
+
+## Components
+
+### AnalyticsDashboard.tsx
+
+Main analytics view with provider tabs.
+
+```tsx
+export function AnalyticsDashboard() {
+ const { site } = useSite();
+ const { data: analyticsConfig } = useAnalyticsConfig();
+ const [provider, setProvider] = useState('plausible');
+
+ return (
+
+
+
+ Plausible
+ CDN
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+### PlausibleEmbed.tsx
+
+Embedded Plausible dashboard.
+
+```tsx
+interface PlausibleEmbedProps {
+ domain: string;
+}
+
+export function PlausibleEmbed({ domain }: PlausibleEmbedProps) {
+ const [timeRange, setTimeRange] = useState('30d');
+
+ // Build embed URL
+ const embedUrl = useMemo(() => {
+ const url = new URL(`https://plausible.io/share/${domain}`);
+ url.searchParams.set('auth', 'embed');
+ url.searchParams.set('embed', 'true');
+ url.searchParams.set('theme', 'system');
+ url.searchParams.set('period', timeRange);
+ return url.toString();
+ }, [domain, timeRange]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+### CloudflareAnalytics.tsx
+
+Cloudflare CDN analytics with charts.
+
+```tsx
+export function CloudflareAnalytics({ site }: { site: string }) {
+ const { data: analytics, isLoading } = useCloudflareAnalytics(site);
+
+ if (isLoading) return ;
+
+ return (
+
+ {/* Requests over time */}
+
+
+ Requests
+
+
+
+
+
+
+ {/* Bandwidth */}
+
+
+ Bandwidth
+
+
+
+
+
+
+ {/* Cache ratio */}
+
+
+ Cache Hit Ratio
+
+
+
+
+
+
+ {/* Response times */}
+
+
+ Response Time (p95)
+
+
+
+
+
+
+ );
+}
+```
+
+## Hooks
+
+### use-analytics.ts
+
+```tsx
+// Get analytics configuration
+export function useAnalyticsConfig() {
+ const { site } = useSite();
+ return useQuery({
+ queryKey: ['analytics-config', site.name],
+ queryFn: () => api.sites.analytics.getConfig({ site: site.name }),
+ });
+}
+
+// Get Cloudflare analytics
+export function useCloudflareAnalytics(site: string, period = '24h') {
+ return useQuery({
+ queryKey: ['cloudflare-analytics', site, period],
+ queryFn: () => api.cloudflare.analytics({ site, period }),
+ refetchInterval: 60000, // Refresh every minute
+ });
+}
+
+// Get Plausible data (if using API instead of embed)
+export function usePlausibleData(domain: string, period = '30d') {
+ return useQuery({
+ queryKey: ['plausible', domain, period],
+ queryFn: () => api.plausible.aggregate({ domain, period }),
+ });
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/extensions/Deco/views/Analytics.tsx`
+- `admin-cx/components/analytics/AnalyticsFrame.tsx`
+- `admin-cx/islands/PlausibleAdminIsland.tsx`
+- `admin-cx/loaders/cloudflare/*.ts`
+- `admin-cx/loaders/plausible/*.ts`
+
diff --git a/apps/cms/src/components/spaces/apps/PLAN.md b/apps/cms/src/components/spaces/apps/PLAN.md
new file mode 100644
index 0000000000..1e1eaf55a8
--- /dev/null
+++ b/apps/cms/src/components/spaces/apps/PLAN.md
@@ -0,0 +1,180 @@
+# Apps Space
+
+## Overview
+
+The Apps space allows users to install, configure, and manage deco apps. Apps extend site functionality by providing sections, loaders, actions, and integrations.
+
+## App Categories
+
+1. **Commerce** - VTEX, Shopify, Wake, VNDA, etc.
+2. **CMS** - Blog, Records, etc.
+3. **Analytics** - Plausible, PostHog, etc.
+4. **AI** - OpenAI, Anthropic, etc.
+5. **Integrations** - Slack, Discord, etc.
+6. **Utils** - Website, Assets, etc.
+
+## Components
+
+### AppsList.tsx
+
+Grid view of available and installed apps.
+
+**Features:**
+- Tabs: Installed | Available
+- Category filter
+- Search by name
+- App cards with:
+ - Icon
+ - Name
+ - Description
+ - Install/Configure button
+ - Status badge (installed, update available)
+
+```tsx
+export function AppsList() {
+ const { data: installed } = useInstalledApps();
+ const { data: available } = useAvailableApps();
+ const [tab, setTab] = useState<'installed' | 'available'>('installed');
+
+ return (
+
+
+
+
+ Installed ({installed?.length || 0})
+
+ Available
+
+
+
+ navigate(`/apps/${app.id}`)}
+ />
+
+
+
+ installApp(app)}
+ />
+
+
+
+ );
+}
+```
+
+### AppConfig.tsx
+
+Configuration form for an installed app.
+
+**Features:**
+- App header with icon and description
+- JSON Schema form for app props
+- Documentation link
+- Uninstall button
+
+```tsx
+export function AppConfig({ appId }: { appId: string }) {
+ const { data: app } = useApp(appId);
+ const { data: schema } = useAppSchema(app?.__resolveType);
+ const saveApp = useSaveApp();
+
+ return (
+
+
+
+
saveApp.mutate({ id: appId, data })}
+ />
+
+
+
+
+
+ );
+}
+```
+
+### InstallAppDialog.tsx
+
+Modal for installing a new app.
+
+**Steps:**
+1. App selection (if not pre-selected)
+2. Required configuration
+3. Confirmation
+4. Installation (creates block in /.deco/blocks/)
+
+## App Installation Flow
+
+```typescript
+async function installApp(locator: AppLocator) {
+ const { vendor, app } = locator;
+
+ if (vendor === 'decohub') {
+ // Legacy decohub apps
+ await blocks.save(app, {
+ __resolveType: `decohub/apps/${app}.ts`,
+ });
+ } else {
+ // New apps via API
+ await api.sites.apps.install({
+ locator,
+ env: currentEnv,
+ site: siteName,
+ });
+ }
+}
+```
+
+## Hooks
+
+### use-apps.ts
+
+```tsx
+// List installed apps
+export function useInstalledApps() {
+ const { blocks } = useDaemon();
+ return useQuery({
+ queryKey: ['apps', 'installed'],
+ queryFn: () => blocks.list({ type: 'apps' }),
+ });
+}
+
+// List available apps from decohub
+export function useAvailableApps() {
+ return useQuery({
+ queryKey: ['apps', 'available'],
+ queryFn: async () => {
+ // Fetch from decohub or apps registry
+ const response = await fetch('https://apps.deco.cx/api/apps');
+ return response.json();
+ },
+ });
+}
+
+// Get app schema
+export function useAppSchema(resolveType: string) {
+ const { meta } = useSite();
+ return useMemo(() => {
+ return meta?.manifest.blocks.apps?.[resolveType];
+ }, [meta, resolveType]);
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/Apps.tsx`
+- `admin-cx/loaders/apps/list.ts`
+- `admin-cx/actions/sites/apps/install.ts`
+
diff --git a/apps/cms/src/components/spaces/assets/PLAN.md b/apps/cms/src/components/spaces/assets/PLAN.md
new file mode 100644
index 0000000000..3599c21c92
--- /dev/null
+++ b/apps/cms/src/components/spaces/assets/PLAN.md
@@ -0,0 +1,224 @@
+# Assets Space
+
+## Overview
+
+The Assets space provides a media library for managing images, videos, documents, and other files used in the site.
+
+## Features
+
+- **Upload** - Drag & drop, paste, or click to upload
+- **Browse** - Grid/list view with thumbnails
+- **Search** - By filename, type, or metadata
+- **Organize** - Folders (optional), tags
+- **Use** - Copy URL, insert into content
+- **Edit** - Rename, replace, crop images
+
+## Components
+
+### AssetsList.tsx
+
+Main asset browser with grid view.
+
+```tsx
+export function AssetsList() {
+ const { data: assets, isLoading } = useAssets();
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
+ const [selectedAssets, setSelectedAssets] = useState([]);
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ {viewMode === 'grid' ? (
+ setPreviewAsset(asset)}
+ />
+ ) : (
+
+ )}
+
+
+ {selectedAssets.length > 0 && (
+ deleteAssets(selectedAssets)}
+ onDownload={() => downloadAssets(selectedAssets)}
+ />
+ )}
+
+ );
+}
+```
+
+### AssetUploader.tsx
+
+Upload component with drag & drop.
+
+**Features:**
+- Drag & drop zone
+- Multiple file selection
+- Upload progress
+- Image compression (optional)
+- Duplicate detection
+
+```tsx
+export function AssetUploader({ onUpload }: AssetUploaderProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [uploads, setUploads] = useState([]);
+
+ const handleDrop = async (files: File[]) => {
+ for (const file of files) {
+ const upload = { id: uuid(), file, progress: 0 };
+ setUploads(prev => [...prev, upload]);
+
+ try {
+ const asset = await uploadAsset(file, (progress) => {
+ setUploads(prev => prev.map(u =>
+ u.id === upload.id ? { ...u, progress } : u
+ ));
+ });
+ onUpload?.(asset);
+ } catch (error) {
+ // Handle error
+ }
+ }
+ };
+
+ return (
+ setIsDragging(false)}
+ onDrop={handleDrop}
+ >
+ {uploads.map(upload => (
+
+ ))}
+
+ );
+}
+```
+
+### AssetPreview.tsx
+
+Modal/panel for viewing and editing assets.
+
+**Features:**
+- Full-size preview
+- Metadata display (size, dimensions, format)
+- Copy URL button
+- Rename
+- Delete
+- Image cropping (via ImageCrop component)
+
+### AssetPicker.tsx
+
+Modal for selecting assets from library (used in forms).
+
+```tsx
+interface AssetPickerProps {
+ value?: string;
+ onChange: (url: string) => void;
+ accept?: string; // MIME types
+}
+
+export function AssetPicker({ value, onChange, accept }: AssetPickerProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+ setIsOpen(true)}>
+ {value ? (
+

+ ) : (
+
Select asset...
+ )}
+
+
+
+ >
+ );
+}
+```
+
+## Asset Storage
+
+Assets are typically stored in:
+1. **Deco Assets** - Managed asset storage (recommended)
+2. **External URLs** - Direct links to external resources
+3. **GitHub** - Static files in repo (legacy)
+
+```typescript
+async function uploadAsset(file: File): Promise {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch(`/api/sites/${site}/assets`, {
+ method: 'POST',
+ body: formData,
+ });
+
+ return response.json();
+}
+```
+
+## Hooks
+
+### use-assets.ts
+
+```tsx
+export function useAssets(options?: { type?: string }) {
+ return useQuery({
+ queryKey: ['assets', options],
+ queryFn: () => api.sites.assets.list({
+ site: siteName,
+ ...options
+ }),
+ });
+}
+
+export function useUploadAsset() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (file: File) => uploadAsset(file),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['assets']);
+ },
+ });
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/Assets.tsx`
+- `admin-cx/components/ui/UploadAsset.tsx`
+- `admin-cx/components/ui/ImageCrop.tsx`
+- `admin-cx/loaders/sites/assets.ts`
+
diff --git a/apps/cms/src/components/spaces/loaders/PLAN.md b/apps/cms/src/components/spaces/loaders/PLAN.md
new file mode 100644
index 0000000000..8ca23eea92
--- /dev/null
+++ b/apps/cms/src/components/spaces/loaders/PLAN.md
@@ -0,0 +1,213 @@
+# Loaders Space
+
+## Overview
+
+The Loaders space manages data loaders - server-side functions that fetch and transform data for use in sections and pages.
+
+## What are Loaders?
+
+Loaders are TypeScript functions that:
+- Run on the server at request time
+- Fetch data from APIs, databases, or other sources
+- Transform and return data for use in UI components
+- Can be cached and have dependencies
+
+## Components
+
+### LoadersList.tsx
+
+List view showing all saved loader instances.
+
+```tsx
+export function LoadersList() {
+ const { data: loaders, isLoading } = useLoaders();
+ const { data: templates } = useLoaderTemplates();
+
+ return (
+ }
+ >
+ formatLoaderType(l.__resolveType) },
+ { key: 'usedIn', label: 'Used In', render: (l) => },
+ { key: 'updatedAt', label: 'Updated', render: (l) => formatTimeAgo(l.updatedAt) },
+ ]}
+ onRowClick={(loader) => navigate(loader.id)}
+ searchPlaceholder="Search loaders..."
+ />
+
+ );
+}
+```
+
+### LoadersEdit.tsx
+
+Loader configuration editor.
+
+```tsx
+export function LoadersEdit({ loaderId }: { loaderId: string }) {
+ const { data: loader } = useLoader(loaderId);
+ const { data: schema } = useBlockSchema(loader?.__resolveType);
+ const saveLoader = useSaveBlock();
+
+ // Loaders typically don't have visual preview
+ // Instead, show test/invoke panel
+ return (
+
+ {/* Form */}
+
+
+
Configuration
+
+
saveLoader.mutate({ id: loaderId, data })}
+ className="p-4"
+ />
+
+
+ {/* Test Panel */}
+
+
+ );
+}
+```
+
+### LoaderTestPanel.tsx
+
+Panel for testing/invoking a loader.
+
+```tsx
+interface LoaderTestPanelProps {
+ loaderId: string;
+ loader: Block;
+}
+
+export function LoaderTestPanel({ loaderId, loader }: LoaderTestPanelProps) {
+ const { site } = useSite();
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const testLoader = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`${site.url}/live/invoke/${loader.__resolveType}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(loader),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ setResult(data);
+ } catch (err) {
+ setError(err as Error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+ {result && (
+
+
Result:
+
+ {JSON.stringify(result, null, 2)}
+
+
+ )}
+
+ );
+}
+```
+
+## Hooks
+
+### use-loaders.ts
+
+```tsx
+// List all saved loaders
+export function useLoaders() {
+ const { blocks } = useDaemon();
+ return useQuery({
+ queryKey: ['loaders'],
+ queryFn: () => blocks.list({ type: 'loaders' }),
+ });
+}
+
+// Get loader templates from manifest
+export function useLoaderTemplates() {
+ const { meta } = useSite();
+ return useMemo(() => {
+ const loaders = meta?.manifest.blocks.loaders || {};
+ return Object.keys(loaders)
+ .filter(key => !key.startsWith('deco-sites')) // Filter site-specific
+ .map(resolveType => ({
+ resolveType,
+ name: resolveType.split('/').pop()?.replace('.ts', ''),
+ schema: loaders[resolveType],
+ }));
+ }, [meta]);
+}
+
+// Invoke loader for testing
+export function useInvokeLoader() {
+ const { site } = useSite();
+
+ return useMutation({
+ mutationFn: async ({ resolveType, props }: { resolveType: string; props: unknown }) => {
+ const response = await fetch(`${site.url}/live/invoke/${resolveType}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(props),
+ });
+ return response.json();
+ },
+ });
+}
+```
+
+## Common Loader Types
+
+From apps in decohub:
+- `website/loaders/image.ts` - Image optimization
+- `vtex/loaders/product/productList.ts` - VTEX product lists
+- `vtex/loaders/cart.ts` - Shopping cart
+- `shopify/loaders/ProductList.ts` - Shopify products
+- `blog/loaders/posts.ts` - Blog posts
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/List.tsx`
+- `admin-cx/components/library/BlockSelector.tsx`
+
diff --git a/apps/cms/src/components/spaces/pages/PLAN.md b/apps/cms/src/components/spaces/pages/PLAN.md
new file mode 100644
index 0000000000..1c8d414f4e
--- /dev/null
+++ b/apps/cms/src/components/spaces/pages/PLAN.md
@@ -0,0 +1,168 @@
+# Pages Space
+
+## Overview
+
+The Pages space allows users to create, edit, and manage website pages. Pages are composed of sections and define the URL structure of the site.
+
+## Components
+
+### PagesList.tsx
+
+List view showing all pages with their paths and metadata.
+
+**Features:**
+- Table view with columns: Name, Path, Sections count, Last updated
+- Search/filter by name or path
+- Sort by name, path, or date
+- Quick actions: Edit, Duplicate, Delete
+- Create new page button
+
+**Implementation:**
+```tsx
+export function PagesList() {
+ const { data: pages, isLoading } = usePages();
+ const navigate = useNavigate();
+
+ const columns = [
+ { key: 'name', label: 'Name', sortable: true },
+ { key: 'path', label: 'Path', sortable: true },
+ { key: 'sectionsCount', label: 'Sections', render: (p) => p.sections?.length || 0 },
+ { key: 'updatedAt', label: 'Updated', render: (p) => formatTimeAgo(p.updatedAt) },
+ ];
+
+ return (
+ navigate('new')}>Create Page}
+ >
+ navigate(page.id)}
+ searchPlaceholder="Search pages..."
+ />
+
+ );
+}
+```
+
+### PagesEdit.tsx
+
+Page editor with form on left, preview on right.
+
+**Features:**
+- Split view: Form (left) + Preview (right)
+- Resizable panels
+- Form fields:
+ - Name (text input)
+ - Path (path input with validation)
+ - Sections (array of section selectors)
+ - SEO settings (collapsible)
+- Preview:
+ - Live iframe preview
+ - Viewport selector (mobile/tablet/desktop)
+ - Addressbar with URL
+- Auto-save on change
+- Publish button
+
+**Implementation:**
+```tsx
+export function PagesEdit({ pageId }: { pageId: string }) {
+ const { data: page, isLoading } = usePage(pageId);
+ const { data: schema } = useBlockSchema('website/pages/Page.tsx');
+ const savePage = useSavePage();
+
+ if (isLoading) return ;
+
+ return (
+ savePage.mutate({ id: pageId, data })}
+ showPreview
+ previewPath={page?.path}
+ />
+ );
+}
+```
+
+## Hooks
+
+### use-pages.ts
+
+```tsx
+// List all pages
+export function usePages() {
+ const { blocks } = useDaemon();
+ return useQuery({
+ queryKey: ['pages'],
+ queryFn: () => blocks.list({ type: 'pages' }),
+ });
+}
+
+// Get single page
+export function usePage(pageId: string) {
+ const { blocks } = useDaemon();
+ return useQuery({
+ queryKey: ['pages', pageId],
+ queryFn: () => blocks.get(pageId),
+ enabled: !!pageId,
+ });
+}
+
+// Save page
+export function useSavePage() {
+ const { blocks } = useDaemon();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, data }) => blocks.save(id, data),
+ onSuccess: (_, { id }) => {
+ queryClient.invalidateQueries(['pages']);
+ queryClient.invalidateQueries(['pages', id]);
+ },
+ });
+}
+```
+
+## Page Block Schema
+
+Pages use the `website/pages/Page.tsx` schema:
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Page Name"
+ },
+ "path": {
+ "type": "string",
+ "title": "URL Path",
+ "pattern": "^/.*"
+ },
+ "sections": {
+ "type": "array",
+ "title": "Sections",
+ "items": {
+ "$ref": "#/definitions/Section"
+ }
+ },
+ "seo": {
+ "$ref": "#/definitions/SEO"
+ }
+ },
+ "required": ["name", "path", "sections"]
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/library/Pages.tsx`
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/List.tsx`
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/Edit.tsx`
+
diff --git a/apps/cms/src/components/spaces/releases/PLAN.md b/apps/cms/src/components/spaces/releases/PLAN.md
new file mode 100644
index 0000000000..76873b0287
--- /dev/null
+++ b/apps/cms/src/components/spaces/releases/PLAN.md
@@ -0,0 +1,295 @@
+# Releases Space
+
+## Overview
+
+The Releases space manages git-based versioning and deployments. It shows the git status, allows creating releases (commits), and managing branches/environments.
+
+## Features
+
+- **Git Status** - Show changed files (staged, unstaged)
+- **Commit Changes** - Create releases with commit messages
+- **Release History** - List of past releases with diffs
+- **Branches** - View and switch environments
+- **Rebase** - Sync with upstream changes
+- **Discard** - Revert changes to files
+
+## Components
+
+### ReleasesList.tsx
+
+Main releases view showing git status and history.
+
+```tsx
+export function ReleasesList() {
+ const { changeset, env } = useDaemon();
+ const { data: releases } = useReleases();
+ const [selectedFiles, setSelectedFiles] = useState([]);
+
+ const status = changeset.get();
+ const hasChanges = status?.staged.length > 0 || status?.unstaged.length > 0;
+
+ return (
+
+ {/* Current changes section */}
+
+
+ Current Changes
+
+ {hasChanges
+ ? `${status.staged.length + status.unstaged.length} file(s) changed`
+ : 'No uncommitted changes'
+ }
+
+
+
+ {hasChanges && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ {/* Release history */}
+
+
+ Release History
+
+
+
+
+
+
+ );
+}
+```
+
+### CommitDialog.tsx
+
+Modal for creating a new release/commit.
+
+```tsx
+interface CommitDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ files: FileChange[];
+}
+
+export function CommitDialog({ open, onOpenChange, files }: CommitDialogProps) {
+ const [message, setMessage] = useState('');
+ const createRelease = useCreateRelease();
+
+ const handleCommit = async () => {
+ await createRelease.mutateAsync({
+ message,
+ files: files.map(f => f.path),
+ });
+ onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
+```
+
+### ReleaseDetail.tsx
+
+View details of a specific release with diff.
+
+```tsx
+export function ReleaseDetail({ releaseId }: { releaseId: string }) {
+ const { data: release } = useRelease(releaseId);
+ const { data: diff } = useReleaseDiff(releaseId);
+
+ return (
+
+
+
{release?.message}
+
+ {release?.author} • {formatDate(release?.timestamp)}
+
+
+
+
+
+ Changed Files
+ Diff
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+### RebaseButton.tsx
+
+Button to sync environment with upstream.
+
+```tsx
+export function RebaseButton() {
+ const { env, changeset } = useDaemon();
+ const [isRebasing, setIsRebasing] = useState(false);
+
+ const handleRebase = async () => {
+ setIsRebasing(true);
+ try {
+ await changeset.rebase(env.name);
+ toast.success('Successfully synced with upstream');
+ } catch (error) {
+ toast.error('Rebase failed. Please resolve conflicts.');
+ } finally {
+ setIsRebasing(false);
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+## Hooks
+
+### use-releases.ts
+
+```tsx
+// Get git status
+export function useGitStatus() {
+ const { changeset } = useDaemon();
+ return useQuery({
+ queryKey: ['git-status'],
+ queryFn: () => changeset.sync(),
+ refetchInterval: 10000,
+ });
+}
+
+// List releases/commits
+export function useReleases() {
+ const { site, env } = useSite();
+ return useQuery({
+ queryKey: ['releases', site.name, env.name],
+ queryFn: () => api.releases.list({ site: site.name, env: env.name }),
+ });
+}
+
+// Get release diff
+export function useReleaseDiff(releaseId: string) {
+ const { site, env } = useSite();
+ return useQuery({
+ queryKey: ['release-diff', releaseId],
+ queryFn: () => api.releases.diff({ site: site.name, env: env.name, releaseId }),
+ enabled: !!releaseId,
+ });
+}
+
+// Create release mutation
+export function useCreateRelease() {
+ const { site, env } = useSite();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data: { message: string; files: string[] }) =>
+ api.releases.create({ site: site.name, env: env.name, ...data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['releases']);
+ queryClient.invalidateQueries(['git-status']);
+ },
+ });
+}
+
+// Discard changes mutation
+export function useDiscardChanges() {
+ const { changeset } = useDaemon();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (files: string[]) => changeset.discardChanges(files),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['git-status']);
+ queryClient.invalidateQueries(['blocks']);
+ },
+ });
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/extensions/Git/views/Releases/Releases.tsx`
+- `admin-cx/components/spaces/siteEditor/extensions/Git/views/Summary.tsx`
+- `admin-cx/components/spaces/siteEditor/extensions/Git/components/RebaseButton.tsx`
+- `admin-cx/loaders/releases/git/*.ts`
+- `admin-cx/actions/releases/git/*.ts`
+
diff --git a/apps/cms/src/components/spaces/sections/PLAN.md b/apps/cms/src/components/spaces/sections/PLAN.md
new file mode 100644
index 0000000000..2f84d18327
--- /dev/null
+++ b/apps/cms/src/components/spaces/sections/PLAN.md
@@ -0,0 +1,159 @@
+# Sections Space
+
+## Overview
+
+The Sections space allows users to create and manage reusable UI sections. Sections are the building blocks of pages and can be shared across multiple pages.
+
+## Types of Sections
+
+1. **Saved Sections** - Custom instances stored in `/.deco/blocks/`
+2. **Template Sections** - Available section types from the manifest (e.g., `website/sections/Hero.tsx`)
+
+## Components
+
+### SectionsList.tsx
+
+Grid/list view showing all saved sections.
+
+**Features:**
+- Grid view with preview thumbnails
+- Filter by section type
+- Search by name
+- Quick actions: Edit, Duplicate, Delete
+- Create new section (from template)
+
+**Implementation:**
+```tsx
+export function SectionsList() {
+ const { data: sections, isLoading } = useSections();
+ const { data: templates } = useSectionTemplates();
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
+
+ return (
+
+
+
+ >
+ }
+ >
+ {viewMode === 'grid' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+```
+
+### SectionsEdit.tsx
+
+Section editor with form + preview.
+
+**Features:**
+- Same split view as pages
+- Preview shows section in isolation
+- Section-specific fields based on schema
+- "Used in" indicator showing which pages use this section
+
+```tsx
+export function SectionsEdit({ sectionId }: { sectionId: string }) {
+ const { data: section } = useSection(sectionId);
+ const { data: schema } = useBlockSchema(section?.__resolveType);
+ const { data: usedIn } = useSectionUsage(sectionId);
+
+ return (
+
+
+ {usedIn?.length > 0 && (
+
+ )}
+
+ );
+}
+```
+
+### CreateSectionDialog.tsx
+
+Modal for creating new sections from templates.
+
+**Features:**
+- Template selector with categories
+- Preview of selected template
+- Name input
+- Initial configuration (optional)
+
+## Section Preview
+
+Sections are previewed by wrapping them in a minimal page:
+
+```typescript
+function getSectionPreviewUrl(site: string, section: Block) {
+ const pageWrapper = {
+ path: '/',
+ sections: [section],
+ __resolveType: 'website/pages/Page.tsx',
+ };
+
+ const url = new URL(`${site}/live/previews/website/pages/Page.tsx`);
+ url.searchParams.set('props', encodeProps(pageWrapper));
+ return url.toString();
+}
+```
+
+## Hooks
+
+### use-sections.ts
+
+```tsx
+// List all saved sections
+export function useSections() {
+ const { blocks } = useDaemon();
+ return useQuery({
+ queryKey: ['sections'],
+ queryFn: () => blocks.list({ type: 'sections' }),
+ });
+}
+
+// Get available section templates from manifest
+export function useSectionTemplates() {
+ const { meta } = useSite();
+ return useQuery({
+ queryKey: ['section-templates'],
+ queryFn: () => {
+ const sections = meta?.manifest.blocks.sections || {};
+ return Object.keys(sections).map(resolveType => ({
+ resolveType,
+ schema: sections[resolveType],
+ }));
+ },
+ enabled: !!meta,
+ });
+}
+
+// Find pages that use a section
+export function useSectionUsage(sectionId: string) {
+ const { data: pages } = usePages();
+ return useMemo(() => {
+ return pages?.filter(page =>
+ page.sections?.some(s => s.__resolveType === sectionId)
+ );
+ }, [pages, sectionId]);
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/library/BlockSelector.tsx`
+- `admin-cx/components/spaces/siteEditor/extensions/CMS/views/List.tsx`
+- `admin-cx/components/editor/JSONSchema/widgets/SelectBlock/SelectSectionBlock.tsx`
+
diff --git a/apps/cms/src/components/spaces/settings/PLAN.md b/apps/cms/src/components/spaces/settings/PLAN.md
new file mode 100644
index 0000000000..bd7303c321
--- /dev/null
+++ b/apps/cms/src/components/spaces/settings/PLAN.md
@@ -0,0 +1,306 @@
+# Settings Space
+
+## Overview
+
+The Settings space provides configuration options for the site, including domains, team management, hosting options, and danger zone operations.
+
+## Settings Sections
+
+1. **General** - Site name, description, visibility
+2. **Domains** - Custom domain management
+3. **Hosting** - Environment configuration
+4. **Team** - Members and permissions
+5. **Integrations** - External service connections
+6. **Danger Zone** - Delete site, transfer ownership
+
+## Components
+
+### SettingsOverview.tsx
+
+Main settings page with section navigation.
+
+```tsx
+export function SettingsOverview() {
+ const { site } = useSite();
+
+ return (
+
+
+ {/* General Settings */}
+
+
+
+
+ {/* Domains */}
+
+
+
+
+ {/* Team */}
+
+
+
+
+ {/* Danger Zone */}
+
+
+
+
+
+ );
+}
+```
+
+### DomainsSettings.tsx
+
+Custom domain management.
+
+```tsx
+export function DomainsSettings() {
+ const { site } = useSite();
+ const { data: domains, isLoading } = useDomains();
+ const addDomain = useAddDomain();
+ const removeDomain = useRemoveDomain();
+
+ return (
+
+
+
+ Custom Domains
+
+ Add custom domains to your site
+
+
+
+ {/* Default domain */}
+
+
+ {site.name}.deco.site
+ Default
+
+
+
+
+ {/* Custom domains list */}
+
+ {domains?.map(domain => (
+ removeDomain.mutate(domain.hostname)}
+ />
+ ))}
+
+
+ {/* Add domain form */}
+ addDomain.mutate(hostname)} />
+
+
+
+ {/* DNS Instructions */}
+
+
+ DNS Configuration
+
+
+
+
+
+
+ );
+}
+```
+
+### TeamSettings.tsx
+
+Team member management.
+
+```tsx
+export function TeamSettings() {
+ const { data: members, isLoading } = useTeamMembers();
+ const inviteMember = useInviteMember();
+ const removeMember = useRemoveMember();
+ const updateRole = useUpdateMemberRole();
+
+ return (
+
+
+
+
+ Team Members
+
+ Manage who has access to this site
+
+
+
+
+
+
+
+
+ Member
+ Role
+ Added
+
+
+
+
+ {members?.map(member => (
+
+
+
+
+
+
{member.name}
+
+ {member.email}
+
+
+
+
+
+ updateRole.mutate({ id: member.id, role })}
+ />
+
+ {formatDate(member.addedAt)}
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+```
+
+### DangerZone.tsx
+
+Dangerous operations with confirmations.
+
+```tsx
+export function DangerZone() {
+ const { site } = useSite();
+ const deleteSite = useDeleteSite();
+ const navigate = useNavigate();
+
+ const handleDelete = async () => {
+ const confirmed = await confirmDialog({
+ title: 'Delete Site',
+ description: `Are you sure you want to delete "${site.name}"? This action cannot be undone.`,
+ confirmLabel: 'Delete',
+ variant: 'destructive',
+ });
+
+ if (confirmed) {
+ await deleteSite.mutateAsync(site.name);
+ navigate('/');
+ }
+ };
+
+ return (
+
+
+
+ Delete Site
+
+ Permanently delete this site and all its content. This action cannot be undone.
+
+
+
+
+
+
+
+ );
+}
+```
+
+## Hooks
+
+### use-settings.ts
+
+```tsx
+// Get domains
+export function useDomains() {
+ const { site } = useSite();
+ return useQuery({
+ queryKey: ['domains', site.name],
+ queryFn: () => api.domains.list({ site: site.name }),
+ });
+}
+
+// Add domain
+export function useAddDomain() {
+ const { site } = useSite();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (hostname: string) =>
+ api.domains.add({ site: site.name, hostname }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['domains']);
+ },
+ });
+}
+
+// Get team members
+export function useTeamMembers() {
+ const { site } = useSite();
+ return useQuery({
+ queryKey: ['team-members', site.name],
+ queryFn: () => api.teams.members({ site: site.name }),
+ });
+}
+
+// Delete site
+export function useDeleteSite() {
+ return useMutation({
+ mutationFn: (siteName: string) => api.sites.delete({ site: siteName }),
+ });
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/settings/Settings.tsx`
+- `admin-cx/components/settings/DomainSettings.tsx`
+- `admin-cx/components/settings/DeleteSettings.tsx`
+- `admin-cx/components/TeamMemberTable.tsx`
+- `admin-cx/loaders/domains/*.ts`
+- `admin-cx/actions/domains/*.ts`
+
diff --git a/apps/cms/src/hooks/PLAN.md b/apps/cms/src/hooks/PLAN.md
new file mode 100644
index 0000000000..f17f08d320
--- /dev/null
+++ b/apps/cms/src/hooks/PLAN.md
@@ -0,0 +1,247 @@
+# Hooks
+
+## Overview
+
+Custom React hooks for the CMS application. These provide reusable logic for common operations.
+
+## Directory Structure
+
+```
+hooks/
+├── use-site.ts # Site context and metadata
+├── use-daemon.ts # Daemon connection and file operations
+├── use-blocks.ts # Block CRUD operations
+├── use-schema.ts # JSON Schema utilities
+├── use-preview.ts # Preview URL generation
+├── use-live-editor.ts # Iframe communication
+└── use-presence.ts # Real-time collaboration
+```
+
+## Core Hooks
+
+### use-site.ts
+
+Access site context and metadata.
+
+```tsx
+interface SiteContext {
+ site: Site;
+ env: Environment;
+ meta: MetaInfo | null;
+ isLoading: boolean;
+}
+
+export function useSite(): SiteContext {
+ const context = useContext(SiteContext);
+ if (!context) {
+ throw new Error('useSite must be used within SiteProvider');
+ }
+ return context;
+}
+
+// Get site info from URL params
+export function useSiteParams() {
+ const { org, site } = useParams<{ org: string; site: string }>();
+ const searchParams = useSearchParams();
+ const env = searchParams.get('env') || 'staging';
+
+ return { org, site, env };
+}
+```
+
+### use-daemon.ts
+
+Daemon connection for real-time file operations.
+
+```tsx
+interface DaemonContext {
+ status: 'connecting' | 'connected' | 'disconnected' | 'error';
+ fs: {
+ read: (path: string) => Promise;
+ write: (path: string, content: string) => Promise;
+ delete: (path: string) => Promise;
+ list: (prefix?: string) => Promise;
+ watch: (path: string) => AsyncIterable;
+ };
+}
+
+export function useDaemon(): DaemonContext {
+ const context = useContext(DaemonContext);
+ if (!context) {
+ throw new Error('useDaemon must be used within DaemonProvider');
+ }
+ return context;
+}
+
+// Hook for real-time file watching
+export function useFileWatch(path: string) {
+ const { fs, status } = useDaemon();
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (status !== 'connected') return;
+
+ const abortController = new AbortController();
+
+ (async () => {
+ for await (const event of fs.watch(path)) {
+ // Invalidate queries when file changes
+ queryClient.invalidateQueries(['file', path]);
+ }
+ })();
+
+ return () => abortController.abort();
+ }, [path, status]);
+}
+```
+
+### use-blocks.ts
+
+Block CRUD operations with React Query.
+
+```tsx
+// List blocks by type
+export function useBlocks(type?: BlockType) {
+ const { fs } = useDaemon();
+
+ return useQuery({
+ queryKey: ['blocks', type],
+ queryFn: async () => {
+ const files = await fs.list('/.deco/blocks/');
+ const blocks = await Promise.all(
+ files.map(async (path) => {
+ const content = await fs.read(path);
+ return { id: pathToBlockId(path), ...JSON.parse(content) };
+ })
+ );
+
+ if (type) {
+ return blocks.filter(b => getBlockType(b) === type);
+ }
+ return blocks;
+ },
+ });
+}
+
+// Get single block
+export function useBlock(blockId: string) {
+ const { fs } = useDaemon();
+
+ return useQuery({
+ queryKey: ['block', blockId],
+ queryFn: async () => {
+ const path = blockIdToPath(blockId);
+ const content = await fs.read(path);
+ return JSON.parse(content);
+ },
+ enabled: !!blockId,
+ });
+}
+
+// Save block mutation
+export function useSaveBlock() {
+ const { fs } = useDaemon();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, data }: { id: string; data: Block }) => {
+ const path = blockIdToPath(id);
+ await fs.write(path, JSON.stringify(data, null, 2));
+ },
+ onSuccess: (_, { id }) => {
+ queryClient.invalidateQueries(['blocks']);
+ queryClient.invalidateQueries(['block', id]);
+ },
+ });
+}
+
+// Delete block mutation
+export function useDeleteBlock() {
+ const { fs } = useDaemon();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const path = blockIdToPath(id);
+ await fs.delete(path);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries(['blocks']);
+ },
+ });
+}
+```
+
+### use-schema.ts
+
+JSON Schema utilities.
+
+```tsx
+// Get schema for a block type
+export function useBlockSchema(resolveType: string) {
+ const { meta } = useSite();
+
+ return useMemo(() => {
+ if (!meta?.manifest.blocks || !resolveType) return null;
+
+ // Find schema in manifest blocks
+ for (const [blockType, blocks] of Object.entries(meta.manifest.blocks)) {
+ if (blocks[resolveType]) {
+ return {
+ ...meta.schema,
+ ...blocks[resolveType],
+ };
+ }
+ }
+
+ return null;
+ }, [meta, resolveType]);
+}
+
+// Resolve $ref in schema
+export function useResolvedSchema(schema: JSONSchema) {
+ const { meta } = useSite();
+
+ return useMemo(() => {
+ return resolveRefs(schema, meta?.schema || {});
+ }, [schema, meta]);
+}
+```
+
+### use-preview.ts
+
+Preview URL generation.
+
+```tsx
+export function usePreviewUrl(block: Block, options?: PreviewOptions) {
+ const { site, meta } = useSite();
+
+ return useMemo(() => {
+ if (!block?.__resolveType || !site) return '';
+
+ const { __resolveType, ...props } = block;
+ const url = new URL(`${site.url}/live/previews/${__resolveType}`);
+
+ url.searchParams.set('path', options?.path || '/');
+ url.searchParams.set('props', encodeProps(props));
+ url.searchParams.set('deviceHint', options?.viewport || 'desktop');
+ url.searchParams.set('__cb', meta?.etag || Date.now().toString());
+
+ return url.toString();
+ }, [block, site, meta, options]);
+}
+```
+
+## Porting from admin-cx
+
+The hooks in admin-cx use Preact Signals. Key differences:
+
+| admin-cx (Signals) | admin/cms (React Query) |
+|--------------------|-------------------------|
+| `useSignal` | `useState` or React Query |
+| `useComputed` | `useMemo` |
+| `useSignalEffect` | `useEffect` |
+| Signal `.value` access | Direct value from hook |
+
+Most business logic can be directly ported, just changing the reactive primitives.
+
diff --git a/apps/cms/src/main.tsx b/apps/cms/src/main.tsx
new file mode 100644
index 0000000000..6820b8e888
--- /dev/null
+++ b/apps/cms/src/main.tsx
@@ -0,0 +1,209 @@
+/**
+ * Deco CMS - Content Management System
+ *
+ * Entry point for the CMS application.
+ * Provides editing capabilities for deco sites (pages, sections, loaders, actions, etc.)
+ */
+
+import { StrictMode, Suspense } from "react";
+import { createRoot } from "react-dom/client";
+import { createBrowserRouter, RouterProvider, Outlet } from "react-router";
+import { DecoQueryClientProvider } from "@deco/sdk";
+import { Spinner } from "@deco/ui/components/spinner.tsx";
+
+// Styles
+import "@deco/ui/styles/global.css";
+// TODO: Add local styles
+// import "./styles.css";
+
+// Lazy load route components
+const SiteLayout = React.lazy(() => import("./routes/site-layout.tsx"));
+const SiteHome = React.lazy(() => import("./routes/home.tsx"));
+const PagesList = React.lazy(() => import("./routes/pages/index.tsx"));
+const PagesEdit = React.lazy(() => import("./routes/pages/[pageId].tsx"));
+const SectionsList = React.lazy(() => import("./routes/sections/index.tsx"));
+const SectionsEdit = React.lazy(
+ () => import("./routes/sections/[sectionId].tsx")
+);
+const LoadersList = React.lazy(() => import("./routes/loaders/index.tsx"));
+const ActionsList = React.lazy(() => import("./routes/actions/index.tsx"));
+const AppsList = React.lazy(() => import("./routes/apps/index.tsx"));
+const AssetsList = React.lazy(() => import("./routes/assets/index.tsx"));
+const ReleasesList = React.lazy(() => import("./routes/releases/index.tsx"));
+const AnalyticsDashboard = React.lazy(
+ () => import("./routes/analytics/index.tsx")
+);
+const LogsViewer = React.lazy(() => import("./routes/logs/index.tsx"));
+const SettingsOverview = React.lazy(
+ () => import("./routes/settings/index.tsx")
+);
+
+import React from "react";
+
+// Loading fallback
+function LoadingFallback() {
+ return (
+
+
+
+ );
+}
+
+// Error boundary fallback
+function ErrorFallback({ error }: { error: Error }) {
+ return (
+
+
+
+ Something went wrong
+
+
{error.message}
+
+
+
+ );
+}
+
+// Router configuration
+const router = createBrowserRouter([
+ {
+ path: "/:org/:site",
+ element: (
+ }>
+
+
+ ),
+ errorElement: ,
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ // Pages
+ {
+ path: "pages",
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: "pages/:pageId",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Sections
+ {
+ path: "sections",
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: "sections/:sectionId",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Loaders
+ {
+ path: "loaders",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Actions
+ {
+ path: "actions",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Apps
+ {
+ path: "apps",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Assets
+ {
+ path: "assets",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Releases
+ {
+ path: "releases",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Analytics
+ {
+ path: "analytics",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Logs
+ {
+ path: "logs",
+ element: (
+ }>
+
+
+ ),
+ },
+ // Settings
+ {
+ path: "settings",
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+]);
+
+// Render app
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+);
+
diff --git a/apps/cms/src/providers/PLAN.md b/apps/cms/src/providers/PLAN.md
new file mode 100644
index 0000000000..b1dd50ecb4
--- /dev/null
+++ b/apps/cms/src/providers/PLAN.md
@@ -0,0 +1,221 @@
+# Providers
+
+## Overview
+
+React context providers that make site and daemon functionality available throughout the app.
+
+## Directory Structure
+
+```
+providers/
+├── site-provider.tsx # Site metadata and connection
+├── daemon-provider.tsx # Real-time file system
+└── index.tsx # Combined providers
+```
+
+## SiteProvider
+
+Provides site context including metadata, environment, and connection info.
+
+```tsx
+// site-provider.tsx
+interface SiteContextValue {
+ site: {
+ name: string;
+ url: string;
+ domains: string[];
+ };
+ env: {
+ name: string;
+ platform: 'deco' | 'tunnel' | 'keda';
+ head?: string;
+ };
+ meta: MetaInfo | null;
+ isLoading: boolean;
+ error: Error | null;
+ refetchMeta: () => void;
+}
+
+const SiteContext = createContext(null);
+
+export function SiteProvider({ children }: { children: React.ReactNode }) {
+ const { org, site, env } = useSiteParams();
+
+ // Fetch site info
+ const { data: siteInfo, isLoading: siteLoading } = useQuery({
+ queryKey: ['site', org, site],
+ queryFn: () => api.sites.get({ org, site }),
+ });
+
+ // Fetch environment
+ const { data: envInfo, isLoading: envLoading } = useQuery({
+ queryKey: ['env', org, site, env],
+ queryFn: () => api.environments.get({ org, site, env }),
+ enabled: !!siteInfo,
+ });
+
+ // Fetch metadata from site
+ const {
+ data: meta,
+ isLoading: metaLoading,
+ refetch: refetchMeta,
+ } = useQuery({
+ queryKey: ['meta', envInfo?.url],
+ queryFn: async () => {
+ const response = await fetch(`${envInfo!.url}/live/_meta`);
+ return response.json();
+ },
+ enabled: !!envInfo?.url,
+ refetchInterval: 30000, // Refetch every 30s
+ });
+
+ const value = useMemo(() => ({
+ site: siteInfo,
+ env: envInfo,
+ meta,
+ isLoading: siteLoading || envLoading || metaLoading,
+ error: null,
+ refetchMeta,
+ }), [siteInfo, envInfo, meta, siteLoading, envLoading, metaLoading]);
+
+ if (!siteInfo || !envInfo) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+```
+
+## DaemonProvider
+
+Manages the daemon WebSocket connection for real-time file operations.
+
+```tsx
+// daemon-provider.tsx
+interface DaemonContextValue {
+ status: 'connecting' | 'connected' | 'disconnected' | 'error';
+ fs: FileSystem;
+ reconnect: () => void;
+}
+
+const DaemonContext = createContext(null);
+
+export function DaemonProvider({ children }: { children: React.ReactNode }) {
+ const { site, env } = useSite();
+ const queryClient = useQueryClient();
+ const [status, setStatus] = useState('connecting');
+ const clientRef = useRef(null);
+
+ // Initialize daemon connection
+ useEffect(() => {
+ if (!site?.url || !env?.name) return;
+
+ const client = new DaemonClient(site.url, env.name);
+ clientRef.current = client;
+
+ const connect = async () => {
+ setStatus('connecting');
+
+ try {
+ // Start watching
+ for await (const event of client.watch()) {
+ setStatus('connected');
+
+ if (event.type === 'fs-sync') {
+ // Invalidate query for changed file
+ const blockId = pathToBlockId(event.path);
+ if (blockId) {
+ queryClient.invalidateQueries(['block', blockId]);
+ queryClient.invalidateQueries(['blocks']);
+ }
+ }
+
+ if (event.type === 'meta-info') {
+ // Update meta info
+ queryClient.setQueryData(['meta', site.url], event.data);
+ }
+ }
+ } catch (error) {
+ console.error('Daemon connection error:', error);
+ setStatus('error');
+
+ // Retry after delay
+ setTimeout(connect, 5000);
+ }
+ };
+
+ connect();
+
+ return () => {
+ client.disconnect();
+ };
+ }, [site?.url, env?.name]);
+
+ const fs = useMemo(() => ({
+ read: (path: string) => clientRef.current!.readFile(path),
+ write: (path: string, content: string) => clientRef.current!.writeFile(path, content),
+ delete: (path: string) => clientRef.current!.deleteFile(path),
+ list: (prefix?: string) => clientRef.current!.listFiles(prefix),
+ }), []);
+
+ const reconnect = useCallback(() => {
+ clientRef.current?.reconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+```
+
+## Combined Provider
+
+Wraps all CMS-specific providers.
+
+```tsx
+// index.tsx
+export function CMSProviders({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+```
+
+## Usage in Routes
+
+```tsx
+// routes/site-layout.tsx
+export function SiteLayout() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+## Porting from admin-cx
+
+Reference files:
+- `admin-cx/components/spaces/siteEditor/sdk.ts` - Main SDK with all providers
+- `admin-cx/components/spaces/siteEditor/fs.tsx` - File system utilities
+- `admin-cx/components/AdminProvider.tsx` - Auth and site context
+
+The admin-cx SDK creates a complex interconnected system with Signals. For React:
+1. Split into separate providers (Site, Daemon)
+2. Use React Query for data fetching
+3. Use regular React state for connection status
+4. Keep the same daemon protocol and API calls
+
diff --git a/apps/cms/src/routes/PLAN.md b/apps/cms/src/routes/PLAN.md
new file mode 100644
index 0000000000..75b85f9d52
--- /dev/null
+++ b/apps/cms/src/routes/PLAN.md
@@ -0,0 +1,180 @@
+# Routes
+
+## Overview
+
+Route components using React Router. Each route maps to a space or specific view in the CMS.
+
+## Route Structure
+
+```
+routes/
+├── index.tsx # Router configuration
+├── site-layout.tsx # Layout with providers
+├── home.tsx # Site dashboard
+├── pages/
+│ ├── index.tsx # Pages list
+│ └── [pageId].tsx # Page editor
+├── sections/
+│ ├── index.tsx # Sections list
+│ └── [sectionId].tsx # Section editor
+├── loaders/
+│ └── index.tsx # Loaders list
+├── actions/
+│ └── index.tsx # Actions list
+├── apps/
+│ ├── index.tsx # Apps list
+│ └── [appId].tsx # App config
+├── assets/
+│ └── index.tsx # Asset library
+├── releases/
+│ └── index.tsx # Release management
+├── analytics/
+│ └── index.tsx # Analytics dashboard
+├── logs/
+│ └── index.tsx # Log viewer
+└── settings/
+ ├── index.tsx # Settings overview
+ ├── domains.tsx # Domain management
+ └── team.tsx # Team settings
+```
+
+## Router Configuration
+
+```tsx
+// routes/index.tsx
+import { createBrowserRouter, RouteObject } from 'react-router';
+
+const routes: RouteObject[] = [
+ {
+ path: '/:org/:site',
+ element: ,
+ children: [
+ { index: true, element: },
+
+ // Pages
+ { path: 'pages', element: },
+ { path: 'pages/new', element: },
+ { path: 'pages/:pageId', element: },
+
+ // Sections
+ { path: 'sections', element: },
+ { path: 'sections/new', element: },
+ { path: 'sections/:sectionId', element: },
+
+ // Loaders & Actions
+ { path: 'loaders', element: },
+ { path: 'loaders/:loaderId', element: },
+ { path: 'actions', element: },
+ { path: 'actions/:actionId', element: },
+
+ // Apps
+ { path: 'apps', element: },
+ { path: 'apps/:appId', element: },
+
+ // Assets
+ { path: 'assets', element: },
+
+ // Releases
+ { path: 'releases', element: },
+ { path: 'releases/:releaseId', element: },
+
+ // Analytics & Logs
+ { path: 'analytics', element: },
+ { path: 'logs', element: },
+
+ // Settings
+ { path: 'settings', element: },
+ { path: 'settings/domains', element: },
+ { path: 'settings/team', element: },
+ ],
+ },
+];
+
+export const router = createBrowserRouter(routes);
+```
+
+## Site Layout
+
+```tsx
+// routes/site-layout.tsx
+export function SiteLayout() {
+ return (
+
+
+ }>
+
+
+
+
+ );
+}
+```
+
+## Page Route Example
+
+```tsx
+// routes/pages/[pageId].tsx
+export function PagesEdit() {
+ const { pageId } = useParams<{ pageId: string }>();
+ const { data: page, isLoading } = usePage(pageId!);
+ const { data: schema } = useBlockSchema('website/pages/Page.tsx');
+ const savePage = useSaveBlock();
+ const navigate = useNavigate();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!page) {
+ return ;
+ }
+
+ return (
+ savePage.mutate({ id: pageId!, data })}
+ onBack={() => navigate('/pages')}
+ />
+ );
+}
+```
+
+## URL Patterns
+
+| Pattern | Description | Example |
+|---------|-------------|---------|
+| `/:org/:site` | Site home | `/deco/storefront` |
+| `/:org/:site/pages` | Pages list | `/deco/storefront/pages` |
+| `/:org/:site/pages/:pageId` | Edit page | `/deco/storefront/pages/pages-home-abc123` |
+| `/:org/:site/sections` | Sections list | `/deco/storefront/sections` |
+| `/:org/:site/apps` | Apps list | `/deco/storefront/apps` |
+
+## Query Parameters
+
+- `?env=staging` - Environment selection (default: staging)
+- `?viewport=mobile` - Preview viewport (pages/sections)
+
+## Navigation Hooks
+
+```tsx
+// Custom hook for CMS navigation
+export function useCMSNavigate() {
+ const navigate = useNavigate();
+ const { org, site } = useParams();
+
+ return {
+ toHome: () => navigate(`/${org}/${site}`),
+ toPages: () => navigate(`/${org}/${site}/pages`),
+ toPage: (pageId: string) => navigate(`/${org}/${site}/pages/${pageId}`),
+ toSections: () => navigate(`/${org}/${site}/sections`),
+ toSection: (sectionId: string) => navigate(`/${org}/${site}/sections/${sectionId}`),
+ toApps: () => navigate(`/${org}/${site}/apps`),
+ toSettings: () => navigate(`/${org}/${site}/settings`),
+ };
+}
+```
+
diff --git a/apps/cms/vite.config.ts b/apps/cms/vite.config.ts
new file mode 100644
index 0000000000..162a58b80b
--- /dev/null
+++ b/apps/cms/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { resolve } from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 3001,
+ proxy: {
+ "/api": {
+ target: "http://localhost:3000",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ sourcemap: true,
+ },
+});
+
diff --git a/packages/cms-sdk/PLAN.md b/packages/cms-sdk/PLAN.md
new file mode 100644
index 0000000000..8c4f7b2225
--- /dev/null
+++ b/packages/cms-sdk/PLAN.md
@@ -0,0 +1,334 @@
+# @deco/cms-sdk
+
+## Overview
+
+SDK package for Content Management System operations. This package provides:
+- Daemon client for real-time file operations
+- Block utilities (CRUD, path conversion, metadata)
+- Schema fetching and resolution
+- Preview URL generation
+
+## Why a Separate Package?
+
+1. **Reusability** - Can be used by both `apps/cms` and `apps/web`
+2. **Testing** - Easier to test SDK logic in isolation
+3. **Versioning** - Can evolve independently of UI
+4. **Clarity** - Clear separation between SDK and UI layers
+
+## Directory Structure
+
+```
+packages/cms-sdk/
+├── package.json
+├── tsconfig.json
+├── src/
+│ ├── index.ts # Main exports
+│ ├── daemon/
+│ │ ├── client.ts # WebSocket daemon client
+│ │ ├── events.ts # Event types
+│ │ └── index.ts
+│ ├── blocks/
+│ │ ├── decofile.ts # Path <-> ID conversion
+│ │ ├── metadata.ts # Block metadata inference
+│ │ ├── crud.ts # CRUD operations
+│ │ └── index.ts
+│ ├── schema/
+│ │ ├── fetcher.ts # /live/_meta fetching
+│ │ ├── resolver.ts # $ref resolution
+│ │ └── index.ts
+│ ├── preview/
+│ │ ├── url-builder.ts # Preview URL generation
+│ │ └── index.ts
+│ └── types/
+│ ├── block.ts # Block type definitions
+│ ├── meta.ts # MetaInfo types
+│ └── index.ts
+└── tests/
+ ├── daemon.test.ts
+ ├── blocks.test.ts
+ └── schema.test.ts
+```
+
+## Core Modules
+
+### daemon/client.ts
+
+WebSocket client for real-time file operations.
+
+```typescript
+export interface DaemonConfig {
+ siteUrl: string;
+ env: string;
+ onConnect?: () => void;
+ onDisconnect?: () => void;
+ onError?: (error: Error) => void;
+}
+
+export interface DaemonEvent {
+ type: 'fs-sync' | 'fs-snapshot' | 'meta-info' | 'worker-status';
+ detail: unknown;
+}
+
+export class DaemonClient {
+ private ws: WebSocket | null = null;
+ private reconnectAttempts = 0;
+
+ constructor(private config: DaemonConfig) {}
+
+ async connect(): Promise {
+ // Connect to daemon watch endpoint
+ }
+
+ async *watch(): AsyncIterableIterator {
+ // Yield events from WebSocket
+ }
+
+ async readFile(path: string): Promise {
+ // Read file via daemon API
+ }
+
+ async writeFile(path: string, content: string): Promise {
+ // Write file via daemon API
+ }
+
+ async deleteFile(path: string): Promise {
+ // Delete file via daemon API
+ }
+
+ async listFiles(prefix?: string): Promise {
+ // List files via daemon API
+ }
+
+ disconnect(): void {
+ this.ws?.close();
+ this.ws = null;
+ }
+}
+```
+
+### blocks/decofile.ts
+
+Utilities for block ID <-> file path conversion.
+
+```typescript
+const DECO_FOLDER = '.deco';
+const BLOCKS_FOLDER = `/${DECO_FOLDER}/blocks`;
+
+export const DECOFILE = {
+ paths: {
+ blocks: {
+ // Convert block ID to file path
+ fromId: (blockId: string): string => {
+ return `${BLOCKS_FOLDER}/${encodeURIComponent(blockId)}.json`;
+ },
+
+ // Convert file path to block ID
+ toId: (path: string): string | null => {
+ if (!path.startsWith(BLOCKS_FOLDER)) return null;
+ const filename = path.slice(BLOCKS_FOLDER.length + 1);
+ return decodeURIComponent(filename.replace('.json', ''));
+ },
+ },
+
+ blocksFolder: BLOCKS_FOLDER,
+ metadataPath: `/${DECO_FOLDER}/metadata.json`,
+ },
+};
+```
+
+### blocks/metadata.ts
+
+Infer block type and metadata from block content.
+
+```typescript
+export interface BlockMetadata {
+ blockType: BlockType;
+ __resolveType: string;
+ name?: string;
+ path?: string;
+}
+
+export type BlockType =
+ | 'pages'
+ | 'sections'
+ | 'loaders'
+ | 'actions'
+ | 'apps'
+ | 'flags'
+ | 'handlers'
+ | 'matchers'
+ | 'workflows';
+
+export function inferMetadata(block: Block): BlockMetadata | null {
+ const resolveType = block.__resolveType;
+ if (!resolveType) return null;
+
+ // Determine block type from __resolveType
+ const blockType = getBlockType(resolveType);
+
+ return {
+ blockType,
+ __resolveType: resolveType,
+ name: block.name as string | undefined,
+ path: block.path as string | undefined,
+ };
+}
+
+function getBlockType(resolveType: string): BlockType {
+ if (resolveType.includes('/pages/')) return 'pages';
+ if (resolveType.includes('/sections/')) return 'sections';
+ if (resolveType.includes('/loaders/')) return 'loaders';
+ if (resolveType.includes('/actions/')) return 'actions';
+ if (resolveType.includes('/apps/')) return 'apps';
+ if (resolveType.includes('/flags/')) return 'flags';
+ if (resolveType.includes('/handlers/')) return 'handlers';
+ if (resolveType.includes('/matchers/')) return 'matchers';
+ if (resolveType.includes('/workflows/')) return 'workflows';
+ return 'sections'; // default
+}
+```
+
+### schema/fetcher.ts
+
+Fetch and cache schema from deco runtime.
+
+```typescript
+export interface MetaInfo {
+ version: string;
+ namespace: string;
+ site: string;
+ etag: string;
+ timestamp: number;
+ schema: JSONSchema;
+ manifest: {
+ blocks: Record>;
+ };
+}
+
+export async function fetchMeta(siteUrl: string): Promise {
+ const response = await fetch(`${siteUrl}/live/_meta`, {
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch meta: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+export function getBlockSchema(
+ meta: MetaInfo,
+ resolveType: string
+): JSONSchema | null {
+ for (const [_, blocks] of Object.entries(meta.manifest.blocks)) {
+ if (blocks[resolveType]) {
+ return {
+ ...meta.schema,
+ ...blocks[resolveType],
+ };
+ }
+ }
+ return null;
+}
+```
+
+### preview/url-builder.ts
+
+Build preview URLs for the iframe.
+
+```typescript
+export interface PreviewOptions {
+ path?: string;
+ pathTemplate?: string;
+ viewport?: 'mobile' | 'tablet' | 'desktop';
+ matchers?: Record;
+ cacheBuster?: string;
+}
+
+export function buildPreviewUrl(
+ siteUrl: string,
+ block: Block,
+ options: PreviewOptions = {}
+): string {
+ const { __resolveType, ...props } = block;
+
+ if (!__resolveType) {
+ throw new Error('Block must have __resolveType');
+ }
+
+ const url = new URL(`${siteUrl}/live/previews/${__resolveType}`);
+
+ // Path parameters
+ url.searchParams.set('path', options.path || '/');
+ url.searchParams.set('pathTemplate', options.pathTemplate || options.path || '/');
+
+ // Props (encoded)
+ url.searchParams.set('props', encodeProps(JSON.stringify(props)));
+
+ // Viewport hint
+ if (options.viewport) {
+ url.searchParams.set('deviceHint', options.viewport);
+ }
+
+ // Matcher overrides
+ if (options.matchers) {
+ for (const [matcherId, active] of Object.entries(options.matchers)) {
+ url.searchParams.append('x-deco-matchers-override', `${matcherId}=${active ? 1 : 0}`);
+ }
+ }
+
+ // Disable async rendering
+ url.searchParams.set('__decoFBT', '0');
+ url.searchParams.set('__d', '');
+
+ // Cache buster
+ url.searchParams.set('__cb', options.cacheBuster || Date.now().toString());
+
+ return url.toString();
+}
+
+function encodeProps(props: string): string {
+ return btoa(encodeURIComponent(props));
+}
+
+export function decodeProps(encoded: string): string {
+ return decodeURIComponent(atob(encoded));
+}
+```
+
+## Exports
+
+```typescript
+// src/index.ts
+export { DaemonClient, type DaemonConfig, type DaemonEvent } from './daemon';
+export { DECOFILE, inferMetadata, type BlockMetadata, type BlockType } from './blocks';
+export { fetchMeta, getBlockSchema, type MetaInfo } from './schema';
+export { buildPreviewUrl, type PreviewOptions } from './preview';
+export * from './types';
+```
+
+## Dependencies
+
+```json
+{
+ "dependencies": {},
+ "devDependencies": {
+ "typescript": "^5.0.0",
+ "vitest": "^1.0.0"
+ },
+ "peerDependencies": {}
+}
+```
+
+## Porting from admin-cx
+
+Key files to reference:
+- `admin-cx/sdk/decofile.json.ts` → `blocks/decofile.ts`
+- `admin-cx/sdk/metadata.ts` → `blocks/metadata.ts`
+- `admin-cx/sdk/environment.tsx` → `schema/fetcher.ts`
+- `admin-cx/components/spaces/siteEditor/sdk.ts` → `daemon/client.ts`
+- `admin-cx/components/pages/block-edit/state.tsx` → `preview/url-builder.ts`
+
diff --git a/packages/cms-sdk/package.json b/packages/cms-sdk/package.json
new file mode 100644
index 0000000000..d4c370708c
--- /dev/null
+++ b/packages/cms-sdk/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@deco/cms-sdk",
+ "version": "0.0.1",
+ "description": "SDK for deco Content Management System operations",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./daemon": {
+ "import": "./dist/daemon/index.js",
+ "types": "./dist/daemon/index.d.ts"
+ },
+ "./blocks": {
+ "import": "./dist/blocks/index.js",
+ "types": "./dist/blocks/index.d.ts"
+ },
+ "./schema": {
+ "import": "./dist/schema/index.js",
+ "types": "./dist/schema/index.d.ts"
+ },
+ "./preview": {
+ "import": "./dist/preview/index.js",
+ "types": "./dist/preview/index.d.ts"
+ }
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "biome lint src/",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "typescript": "^5.4.0",
+ "vitest": "^1.6.0"
+ },
+ "peerDependencies": {},
+ "publishConfig": {
+ "access": "public"
+ }
+}
+
diff --git a/packages/cms-sdk/src/blocks/index.ts b/packages/cms-sdk/src/blocks/index.ts
new file mode 100644
index 0000000000..2511610d8a
--- /dev/null
+++ b/packages/cms-sdk/src/blocks/index.ts
@@ -0,0 +1,177 @@
+/**
+ * Block Utilities
+ *
+ * Utilities for working with deco blocks:
+ * - Path <-> ID conversion
+ * - Metadata inference
+ * - Block type detection
+ */
+
+const DECO_FOLDER = ".deco";
+const BLOCKS_FOLDER = `/${DECO_FOLDER}/blocks`;
+
+/**
+ * Block type definition
+ */
+export type BlockType =
+ | "pages"
+ | "sections"
+ | "loaders"
+ | "actions"
+ | "apps"
+ | "flags"
+ | "handlers"
+ | "matchers"
+ | "workflows"
+ | "redirects";
+
+/**
+ * Base block interface
+ */
+export interface Block {
+ __resolveType: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Block metadata inferred from content
+ */
+export interface BlockMetadata {
+ blockType: BlockType;
+ __resolveType: string;
+ name?: string;
+ path?: string;
+}
+
+/**
+ * DECOFILE utilities for path and block management
+ */
+export const DECOFILE = {
+ paths: {
+ blocks: {
+ /**
+ * Convert a block ID to its file path.
+ *
+ * @example
+ * DECOFILE.paths.blocks.fromId('pages-home-abc123')
+ * // Returns: '/.deco/blocks/pages-home-abc123.json'
+ */
+ fromId: (blockId: string): string => {
+ return `${BLOCKS_FOLDER}/${encodeURIComponent(blockId)}.json`;
+ },
+
+ /**
+ * Convert a file path to its block ID.
+ *
+ * @example
+ * DECOFILE.paths.blocks.toId('/.deco/blocks/pages-home-abc123.json')
+ * // Returns: 'pages-home-abc123'
+ */
+ toId: (path: string): string | null => {
+ if (!path.startsWith(BLOCKS_FOLDER)) return null;
+ const filename = path.slice(BLOCKS_FOLDER.length + 1);
+ if (!filename.endsWith(".json")) return null;
+ return decodeURIComponent(filename.replace(".json", ""));
+ },
+ },
+
+ /** The blocks folder path */
+ blocksFolder: BLOCKS_FOLDER,
+
+ /** The .deco folder name */
+ dirname: DECO_FOLDER,
+
+ /** The decofile.json build file path */
+ buildFile: `${DECO_FOLDER}/decofile.json`,
+
+ /** The metadata.json file path */
+ metadataPath: `/${DECO_FOLDER}/metadata.json`,
+ },
+};
+
+/**
+ * Determine block type from __resolveType string.
+ */
+export function getBlockType(resolveType: string): BlockType {
+ if (resolveType.includes("/pages/")) return "pages";
+ if (resolveType.includes("/sections/")) return "sections";
+ if (resolveType.includes("/loaders/")) return "loaders";
+ if (resolveType.includes("/actions/")) return "actions";
+ if (resolveType.includes("/apps/")) return "apps";
+ if (resolveType.includes("/flags/")) return "flags";
+ if (resolveType.includes("/handlers/")) return "handlers";
+ if (resolveType.includes("/matchers/")) return "matchers";
+ if (resolveType.includes("/workflows/")) return "workflows";
+
+ // Check for specific patterns
+ if (resolveType.includes("redirects")) return "redirects";
+
+ // Default to sections for unknown types
+ return "sections";
+}
+
+/**
+ * Infer metadata from a block's content.
+ *
+ * @example
+ * const metadata = inferMetadata({
+ * __resolveType: 'website/pages/Page.tsx',
+ * name: 'Home',
+ * path: '/'
+ * });
+ * // Returns: { blockType: 'pages', __resolveType: '...', name: 'Home', path: '/' }
+ */
+export function inferMetadata(block: Block): BlockMetadata | null {
+ const resolveType = block.__resolveType;
+ if (!resolveType) return null;
+
+ const blockType = getBlockType(resolveType);
+
+ return {
+ blockType,
+ __resolveType: resolveType,
+ name: block.name as string | undefined,
+ path: block.path as string | undefined,
+ };
+}
+
+/**
+ * Check if a block is a page.
+ */
+export function isPage(block: Block | null | undefined): boolean {
+ if (!block?.__resolveType) return false;
+ return getBlockType(block.__resolveType) === "pages";
+}
+
+/**
+ * Check if a block is a section.
+ */
+export function isSection(block: Block | null | undefined): boolean {
+ if (!block?.__resolveType) return false;
+ return getBlockType(block.__resolveType) === "sections";
+}
+
+/**
+ * Check if a block is a loader.
+ */
+export function isLoader(block: Block | null | undefined): boolean {
+ if (!block?.__resolveType) return false;
+ return getBlockType(block.__resolveType) === "loaders";
+}
+
+/**
+ * Check if a block is an action.
+ */
+export function isAction(block: Block | null | undefined): boolean {
+ if (!block?.__resolveType) return false;
+ return getBlockType(block.__resolveType) === "actions";
+}
+
+/**
+ * Check if a block is an app.
+ */
+export function isApp(block: Block | null | undefined): boolean {
+ if (!block?.__resolveType) return false;
+ return getBlockType(block.__resolveType) === "apps";
+}
+
diff --git a/packages/cms-sdk/src/daemon/index.ts b/packages/cms-sdk/src/daemon/index.ts
new file mode 100644
index 0000000000..d02cd230b9
--- /dev/null
+++ b/packages/cms-sdk/src/daemon/index.ts
@@ -0,0 +1,233 @@
+/**
+ * Daemon Client
+ *
+ * WebSocket client for real-time file system operations with the deco daemon.
+ * Provides file read/write/delete operations and real-time change notifications.
+ */
+
+export interface DaemonConfig {
+ siteUrl: string;
+ env: string;
+ onConnect?: () => void;
+ onDisconnect?: () => void;
+ onError?: (error: Error) => void;
+}
+
+export type DaemonEventType =
+ | "fs-sync"
+ | "fs-snapshot"
+ | "meta-info"
+ | "worker-status";
+
+export interface DaemonEvent {
+ type: DaemonEventType;
+ detail: unknown;
+ timestamp?: number;
+}
+
+export interface FsSyncEvent extends DaemonEvent {
+ type: "fs-sync";
+ detail: {
+ filepath: string;
+ content?: string;
+ deleted?: boolean;
+ };
+}
+
+export interface MetaInfoEvent extends DaemonEvent {
+ type: "meta-info";
+ detail: {
+ version: string;
+ namespace: string;
+ site: string;
+ etag: string;
+ timestamp: number;
+ schema: Record;
+ manifest: {
+ blocks: Record>;
+ };
+ };
+}
+
+/**
+ * DaemonClient provides real-time file system access to a deco site.
+ *
+ * @example
+ * ```ts
+ * const daemon = new DaemonClient({
+ * siteUrl: 'https://mysite.deco.site',
+ * env: 'staging',
+ * });
+ *
+ * // Watch for changes
+ * for await (const event of daemon.watch()) {
+ * console.log('File changed:', event);
+ * }
+ * ```
+ */
+export class DaemonClient {
+ private abortController: AbortController | null = null;
+ private reconnectAttempts = 0;
+ private maxReconnectAttempts = 10;
+ private reconnectDelay = 1000;
+
+ constructor(private config: DaemonConfig) {}
+
+ /**
+ * Start watching for file system changes.
+ * Returns an async iterator that yields events.
+ */
+ async *watch(): AsyncIterableIterator {
+ const { siteUrl, env } = this.config;
+ this.abortController = new AbortController();
+
+ try {
+ const watchUrl = `${siteUrl}/live/invoke/website/loaders/daemon/watch.ts`;
+ const url = new URL(watchUrl);
+ url.searchParams.set("env", env);
+ url.searchParams.set("since", "0");
+
+ const response = await fetch(url.toString(), {
+ signal: this.abortController.signal,
+ headers: {
+ Accept: "text/event-stream",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Daemon connection failed: ${response.status}`);
+ }
+
+ this.config.onConnect?.();
+ this.reconnectAttempts = 0;
+
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("No response body");
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ try {
+ const event = JSON.parse(line.slice(6)) as DaemonEvent;
+ yield event;
+ } catch {
+ // Skip invalid JSON
+ }
+ }
+ }
+ }
+ } catch (error) {
+ if ((error as Error).name !== "AbortError") {
+ this.config.onError?.(error as Error);
+ throw error;
+ }
+ } finally {
+ this.config.onDisconnect?.();
+ }
+ }
+
+ /**
+ * Read a file from the site's file system.
+ */
+ async readFile(path: string): Promise {
+ const { siteUrl, env } = this.config;
+ const url = `${siteUrl}/live/invoke/website/loaders/daemon/fs/read.ts`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, env }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to read file: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.content;
+ }
+
+ /**
+ * Write content to a file in the site's file system.
+ */
+ async writeFile(path: string, content: string): Promise {
+ const { siteUrl, env } = this.config;
+ const url = `${siteUrl}/live/invoke/website/actions/daemon/fs/write.ts`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, content, env }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to write file: ${response.status}`);
+ }
+ }
+
+ /**
+ * Delete a file from the site's file system.
+ */
+ async deleteFile(path: string): Promise {
+ const { siteUrl, env } = this.config;
+ const url = `${siteUrl}/live/invoke/website/actions/daemon/fs/delete.ts`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, env }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to delete file: ${response.status}`);
+ }
+ }
+
+ /**
+ * List files in a directory.
+ */
+ async listFiles(prefix?: string): Promise {
+ const { siteUrl, env } = this.config;
+ const url = `${siteUrl}/live/invoke/website/loaders/daemon/fs/ls.ts`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prefix, env }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to list files: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.files;
+ }
+
+ /**
+ * Disconnect from the daemon.
+ */
+ disconnect(): void {
+ this.abortController?.abort();
+ this.abortController = null;
+ }
+
+ /**
+ * Reconnect to the daemon.
+ */
+ reconnect(): void {
+ this.disconnect();
+ // The watch() generator should be restarted by the caller
+ }
+}
+
diff --git a/packages/cms-sdk/src/index.ts b/packages/cms-sdk/src/index.ts
new file mode 100644
index 0000000000..d08a50a19c
--- /dev/null
+++ b/packages/cms-sdk/src/index.ts
@@ -0,0 +1,41 @@
+/**
+ * @deco/cms-sdk
+ *
+ * SDK for deco Content Management System operations.
+ * Provides daemon client, block utilities, schema fetching, and preview URL generation.
+ */
+
+// Daemon - WebSocket client for real-time file operations
+export {
+ DaemonClient,
+ type DaemonConfig,
+ type DaemonEvent,
+ type DaemonEventType,
+} from "./daemon/index.ts";
+
+// Blocks - Block CRUD and path utilities
+export {
+ DECOFILE,
+ inferMetadata,
+ type Block,
+ type BlockMetadata,
+ type BlockType,
+} from "./blocks/index.ts";
+
+// Schema - JSON Schema fetching and resolution
+export {
+ fetchMeta,
+ getBlockSchema,
+ resolveRefs,
+ type MetaInfo,
+ type JSONSchema,
+} from "./schema/index.ts";
+
+// Preview - Preview URL generation
+export {
+ buildPreviewUrl,
+ encodeProps,
+ decodeProps,
+ type PreviewOptions,
+} from "./preview/index.ts";
+
diff --git a/packages/cms-sdk/src/preview/index.ts b/packages/cms-sdk/src/preview/index.ts
new file mode 100644
index 0000000000..f430354c87
--- /dev/null
+++ b/packages/cms-sdk/src/preview/index.ts
@@ -0,0 +1,178 @@
+/**
+ * Preview URL Utilities
+ *
+ * Utilities for building preview URLs for the iframe preview system.
+ */
+
+import type { Block } from "../blocks/index.ts";
+
+/**
+ * Viewport types for preview
+ */
+export type Viewport = "mobile" | "tablet" | "desktop";
+
+/**
+ * Viewport dimensions
+ */
+export const VIEWPORT_SIZES: Record<
+ Viewport,
+ { width: number; height: number }
+> = {
+ mobile: { width: 412, height: 823 }, // Moto G Power
+ tablet: { width: 1024, height: 1366 }, // iPad Pro
+ desktop: { width: 1280, height: 800 }, // MacBook Pro 14
+};
+
+/**
+ * Options for building preview URLs
+ */
+export interface PreviewOptions {
+ /** The URL path to preview (e.g., "/", "/products/123") */
+ path?: string;
+ /** The path template for validation (e.g., "/products/*") */
+ pathTemplate?: string;
+ /** The viewport size hint */
+ viewport?: Viewport;
+ /** Matcher overrides for A/B testing */
+ matchers?: Record;
+ /** Cache buster string (defaults to timestamp) */
+ cacheBuster?: string;
+ /** Disable async rendering */
+ disableAsync?: boolean;
+}
+
+/**
+ * Encode props for URL transmission.
+ * Uses base64 encoding of URL-encoded JSON.
+ */
+export function encodeProps(props: unknown): string {
+ const json = JSON.stringify(props);
+ return btoa(encodeURIComponent(json));
+}
+
+/**
+ * Decode props from URL.
+ */
+export function decodeProps(encoded: string): T {
+ const json = decodeURIComponent(atob(encoded));
+ return JSON.parse(json);
+}
+
+/**
+ * Build a preview URL for a block.
+ *
+ * @example
+ * const url = buildPreviewUrl('https://mysite.deco.site', {
+ * __resolveType: 'website/pages/Page.tsx',
+ * name: 'Home',
+ * path: '/',
+ * sections: []
+ * }, { viewport: 'mobile' });
+ */
+export function buildPreviewUrl(
+ siteUrl: string,
+ block: Block,
+ options: PreviewOptions = {}
+): string {
+ const { __resolveType, ...props } = block;
+
+ if (!__resolveType) {
+ throw new Error("Block must have __resolveType");
+ }
+
+ const url = new URL(`${siteUrl}/live/previews/${__resolveType}`);
+
+ // Path parameters
+ const path = options.path || "/";
+ url.searchParams.set("path", path);
+ url.searchParams.set("pathTemplate", options.pathTemplate || path);
+
+ // Props (encoded)
+ url.searchParams.set("props", encodeProps(props));
+
+ // Viewport hint
+ if (options.viewport) {
+ url.searchParams.set("deviceHint", options.viewport);
+ }
+
+ // Matcher overrides
+ if (options.matchers) {
+ for (const [matcherId, active] of Object.entries(options.matchers)) {
+ url.searchParams.append(
+ "x-deco-matchers-override",
+ `${matcherId}=${active ? 1 : 0}`
+ );
+ }
+ }
+
+ // Disable async rendering for consistent preview
+ if (options.disableAsync !== false) {
+ url.searchParams.set("__decoFBT", "0");
+ url.searchParams.set("__d", "");
+ }
+
+ // Cache buster
+ url.searchParams.set(
+ "__cb",
+ options.cacheBuster || Date.now().toString()
+ );
+
+ return url.toString();
+}
+
+/**
+ * Build a standalone URL for opening the page in a new tab.
+ */
+export function buildStandaloneUrl(
+ siteUrl: string,
+ path: string,
+ matchers?: Record
+): string {
+ const url = new URL(path, siteUrl);
+
+ // Add matcher overrides if any
+ if (matchers) {
+ for (const [matcherId, active] of Object.entries(matchers)) {
+ url.searchParams.append(
+ "x-deco-matchers-override",
+ `${matcherId}=${active ? 1 : 0}`
+ );
+ }
+ }
+
+ return url.toString();
+}
+
+/**
+ * Build a section preview URL by wrapping it in a minimal page.
+ */
+export function buildSectionPreviewUrl(
+ siteUrl: string,
+ section: Block,
+ options: PreviewOptions = {}
+): string {
+ // Wrap section in a minimal page
+ const pageWrapper: Block = {
+ __resolveType: "website/pages/Page.tsx",
+ path: options.path || "/",
+ sections: [section],
+ };
+
+ return buildPreviewUrl(siteUrl, pageWrapper, {
+ ...options,
+ path: options.path || "/",
+ });
+}
+
+/**
+ * Check if a block can be previewed.
+ * Only pages, sections, and apps can be previewed.
+ */
+export function canPreview(resolveType: string): boolean {
+ return (
+ resolveType.includes("/pages/") ||
+ resolveType.includes("/sections/") ||
+ resolveType.includes("/apps/")
+ );
+}
+
diff --git a/packages/cms-sdk/src/schema/index.ts b/packages/cms-sdk/src/schema/index.ts
new file mode 100644
index 0000000000..428bcac3bd
--- /dev/null
+++ b/packages/cms-sdk/src/schema/index.ts
@@ -0,0 +1,218 @@
+/**
+ * Schema Utilities
+ *
+ * Utilities for fetching and working with JSON Schema from deco sites.
+ */
+
+/**
+ * JSON Schema type definition
+ */
+export interface JSONSchema {
+ $id?: string;
+ $ref?: string;
+ $defs?: Record;
+ definitions?: Record;
+ type?: string | string[];
+ title?: string;
+ description?: string;
+ default?: unknown;
+ enum?: unknown[];
+ const?: unknown;
+ format?: string;
+ properties?: Record;
+ additionalProperties?: boolean | JSONSchema;
+ required?: string[];
+ items?: JSONSchema | JSONSchema[];
+ minItems?: number;
+ maxItems?: number;
+ minimum?: number;
+ maximum?: number;
+ minLength?: number;
+ maxLength?: number;
+ pattern?: string;
+ oneOf?: JSONSchema[];
+ anyOf?: JSONSchema[];
+ allOf?: JSONSchema[];
+ if?: JSONSchema;
+ then?: JSONSchema;
+ else?: JSONSchema;
+ // Custom extensions
+ "x-widget"?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * MetaInfo returned from /live/_meta endpoint
+ */
+export interface MetaInfo {
+ version: string;
+ namespace: string;
+ site: string;
+ etag: string;
+ timestamp: number;
+ schema: JSONSchema;
+ manifest: {
+ blocks: Record>;
+ };
+}
+
+/**
+ * Fetch metadata from a deco site.
+ *
+ * @example
+ * const meta = await fetchMeta('https://mysite.deco.site');
+ * console.log(meta.version); // '1.0.0'
+ */
+export async function fetchMeta(siteUrl: string): Promise {
+ const response = await fetch(`${siteUrl}/live/_meta`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch meta: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Get the JSON Schema for a specific block type.
+ *
+ * @example
+ * const schema = getBlockSchema(meta, 'website/pages/Page.tsx');
+ */
+export function getBlockSchema(
+ meta: MetaInfo,
+ resolveType: string
+): JSONSchema | null {
+ if (!meta?.manifest?.blocks) return null;
+
+ for (const [_, blocks] of Object.entries(meta.manifest.blocks)) {
+ if (blocks[resolveType]) {
+ return {
+ ...meta.schema,
+ ...blocks[resolveType],
+ };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Resolve $ref references in a JSON Schema.
+ *
+ * @example
+ * const resolved = resolveRefs(schema, rootSchema);
+ */
+export function resolveRefs(
+ schema: JSONSchema,
+ rootSchema: JSONSchema
+): JSONSchema {
+ if (!schema) return schema;
+
+ // Handle $ref
+ if (schema.$ref) {
+ const refPath = schema.$ref;
+
+ // Handle local refs like "#/definitions/Foo"
+ if (refPath.startsWith("#/")) {
+ const path = refPath.slice(2).split("/");
+ let resolved: unknown = rootSchema;
+
+ for (const segment of path) {
+ if (resolved && typeof resolved === "object") {
+ resolved = (resolved as Record)[segment];
+ } else {
+ return schema; // Can't resolve
+ }
+ }
+
+ if (resolved && typeof resolved === "object") {
+ return resolveRefs(resolved as JSONSchema, rootSchema);
+ }
+ }
+
+ return schema;
+ }
+
+ // Recursively resolve refs in nested schemas
+ const result: JSONSchema = { ...schema };
+
+ if (result.properties) {
+ result.properties = Object.fromEntries(
+ Object.entries(result.properties).map(([key, value]) => [
+ key,
+ resolveRefs(value, rootSchema),
+ ])
+ );
+ }
+
+ if (result.items) {
+ if (Array.isArray(result.items)) {
+ result.items = result.items.map((item) => resolveRefs(item, rootSchema));
+ } else {
+ result.items = resolveRefs(result.items, rootSchema);
+ }
+ }
+
+ if (result.oneOf) {
+ result.oneOf = result.oneOf.map((s) => resolveRefs(s, rootSchema));
+ }
+
+ if (result.anyOf) {
+ result.anyOf = result.anyOf.map((s) => resolveRefs(s, rootSchema));
+ }
+
+ if (result.allOf) {
+ result.allOf = result.allOf.map((s) => resolveRefs(s, rootSchema));
+ }
+
+ if (
+ result.additionalProperties &&
+ typeof result.additionalProperties === "object"
+ ) {
+ result.additionalProperties = resolveRefs(
+ result.additionalProperties,
+ rootSchema
+ );
+ }
+
+ return result;
+}
+
+/**
+ * Get all block types from manifest organized by category.
+ */
+export function getBlocksByCategory(meta: MetaInfo): Record {
+ if (!meta?.manifest?.blocks) return {};
+
+ const result: Record = {};
+
+ for (const [category, blocks] of Object.entries(meta.manifest.blocks)) {
+ result[category] = Object.keys(blocks);
+ }
+
+ return result;
+}
+
+/**
+ * Check if a schema represents a section/block selector.
+ */
+export function isSectionSelector(schema: JSONSchema): boolean {
+ // Check for common patterns that indicate a section selector
+ if (schema.oneOf || schema.anyOf) {
+ const variants = schema.oneOf || schema.anyOf;
+ return variants.some(
+ (v) =>
+ v.properties?.__resolveType ||
+ v.$ref?.includes("Section") ||
+ v.$ref?.includes("Block")
+ );
+ }
+
+ return false;
+}
+
diff --git a/packages/cms-sdk/tsconfig.json b/packages/cms-sdk/tsconfig.json
new file mode 100644
index 0000000000..81bfd98543
--- /dev/null
+++ b/packages/cms-sdk/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}
+
diff --git a/plans/CMS_MIGRATION.md b/plans/CMS_MIGRATION.md
new file mode 100644
index 0000000000..7c75163656
--- /dev/null
+++ b/plans/CMS_MIGRATION.md
@@ -0,0 +1,218 @@
+# DecoCMS Migration Plan
+
+> Comprehensive plan for porting admin.deco.cx to the new React/Vite stack
+
+## Summary
+
+This document outlines the migration of the Content Management System from the legacy Deno/Fresh stack (admin-cx) to the new React/Vite stack, creating a sibling to the existing Context Management System (mesh).
+
+## What We're Building
+
+**DUAL CMS Architecture:**
+1. **Context CMS** (mesh) - Edit MCP connections, tools, AI agents
+2. **Content CMS** (NEW) - Edit sites, pages, sections, loaders, actions
+
+Both systems share:
+- Authentication (`@deco/sdk` better-auth)
+- UI Components (`@deco/ui`)
+- Infrastructure (Cloudflare Workers, Supabase)
+
+## Implementation Structure
+
+### New Packages
+
+```
+admin/
+├── apps/
+│ └── cms/ # NEW: Content CMS web app
+│ ├── PLAN.md # Top-level app plan
+│ └── src/
+│ ├── components/
+│ │ ├── shell/ # Layout components
+│ │ ├── spaces/ # Space views (pages, sections, etc.)
+│ │ └── editor/ # Form + Preview
+│ ├── hooks/
+│ ├── providers/
+│ └── routes/
+│
+└── packages/
+ └── cms-sdk/ # NEW: CMS-specific SDK
+ ├── PLAN.md
+ └── src/
+ ├── daemon/ # WebSocket daemon client
+ ├── blocks/ # Block CRUD utilities
+ ├── schema/ # JSON Schema fetching
+ └── preview/ # Preview URL generation
+```
+
+### Detailed Plans
+
+Each directory contains a `PLAN.md` with:
+- Purpose and overview
+- Component specifications
+- Implementation details
+- Code examples
+- Reference to admin-cx files for porting
+
+## Feature Mapping
+
+| admin-cx Feature | New Location | Priority |
+|-----------------|--------------|----------|
+| Pages | `apps/cms/src/components/spaces/pages/` | P0 |
+| Sections | `apps/cms/src/components/spaces/sections/` | P0 |
+| JSON Schema Form | `apps/cms/src/components/editor/json-schema/` | P0 |
+| Preview | `apps/cms/src/components/editor/preview/` | P0 |
+| Loaders | `apps/cms/src/components/spaces/loaders/` | P1 |
+| Actions | `apps/cms/src/components/spaces/actions/` | P1 |
+| Apps | `apps/cms/src/components/spaces/apps/` | P1 |
+| Assets | `apps/cms/src/components/spaces/assets/` | P1 |
+| Releases | `apps/cms/src/components/spaces/releases/` | P2 |
+| Analytics | `apps/cms/src/components/spaces/analytics/` | P2 |
+| Settings | `apps/cms/src/components/spaces/settings/` | P2 |
+
+## Technology Stack Comparison
+
+| Aspect | admin-cx (Old) | apps/cms (New) |
+|--------|----------------|----------------|
+| Runtime | Deno | Node/Bun |
+| Framework | Fresh + Preact | Vite + React |
+| Routing | Fresh file routes | React Router |
+| State | Preact Signals | React Query + useState |
+| Forms | RJSF custom | react-hook-form + AJV |
+| Styling | Tailwind + DaisyUI | Tailwind + shadcn/ui |
+| UI Components | Custom | @deco/ui |
+
+## Implementation Phases
+
+### Phase 1: Foundation (Week 1-2)
+- [ ] Create `packages/cms-sdk` with daemon client
+- [ ] Setup `apps/cms` with Vite + React Router
+- [ ] Implement site connection flow
+- [ ] Basic shell layout (sidebar, topbar)
+
+### Phase 2: Core Editor (Week 2-3)
+- [ ] Port JSON Schema form system
+- [ ] Implement core widgets (string, number, boolean, array, object)
+- [ ] Implement preview iframe with viewport controls
+- [ ] Bidirectional iframe communication
+
+### Phase 3: Essential Spaces (Week 4-5)
+- [ ] Pages space (list + edit)
+- [ ] Sections space (list + edit)
+- [ ] Block selector widget
+
+### Phase 4: Content Spaces (Week 5-6)
+- [ ] Loaders space
+- [ ] Actions space
+- [ ] Apps space
+- [ ] Assets space
+
+### Phase 5: Operations (Week 6-7)
+- [ ] Releases (git status, commit, history)
+- [ ] Settings (domains, team)
+- [ ] Integration with mesh navigation
+
+### Phase 6: Analytics & Polish (Week 8)
+- [ ] Analytics (Plausible embed)
+- [ ] Logs viewer
+- [ ] Error handling and edge cases
+- [ ] Performance optimization
+
+## Key Design Decisions
+
+### 1. Separate App vs Integrated
+**Decision:** Separate `apps/cms` app
+**Rationale:**
+- Clean separation of concerns
+- Independent deployment
+- Easier testing
+- Can be integrated later via module federation
+
+### 2. Daemon vs Deconfig
+**Decision:** Keep daemon for now
+**Rationale:**
+- Full backward compatibility with existing sites
+- Deconfig support can be added later
+- Sites don't need migration
+
+### 3. Form Library
+**Decision:** react-hook-form + AJV
+**Rationale:**
+- Better React integration than RJSF
+- More control over rendering
+- Same validation via AJV
+- Smaller bundle size
+
+### 4. Shared SDK
+**Decision:** Create `@deco/cms-sdk`
+**Rationale:**
+- Reusable across apps
+- Easier testing
+- Clear API boundaries
+- Version management
+
+## File Index
+
+All `PLAN.md` files in the implementation:
+
+### App Level
+- `apps/cms/PLAN.md` - Main app overview
+
+### Source Structure
+- `apps/cms/src/PLAN.md` - Source organization
+
+### Components
+- `apps/cms/src/components/PLAN.md` - Component overview
+- `apps/cms/src/components/shell/PLAN.md` - Shell/layout
+- `apps/cms/src/components/editor/PLAN.md` - Editor overview
+- `apps/cms/src/components/editor/json-schema/PLAN.md` - Form system
+- `apps/cms/src/components/editor/preview/PLAN.md` - Preview system
+
+### Spaces
+- `apps/cms/src/components/spaces/PLAN.md` - Spaces overview
+- `apps/cms/src/components/spaces/pages/PLAN.md` - Pages
+- `apps/cms/src/components/spaces/sections/PLAN.md` - Sections
+- `apps/cms/src/components/spaces/loaders/PLAN.md` - Loaders
+- `apps/cms/src/components/spaces/actions/PLAN.md` - Actions
+- `apps/cms/src/components/spaces/apps/PLAN.md` - Apps
+- `apps/cms/src/components/spaces/assets/PLAN.md` - Assets
+- `apps/cms/src/components/spaces/releases/PLAN.md` - Releases
+- `apps/cms/src/components/spaces/analytics/PLAN.md` - Analytics
+- `apps/cms/src/components/spaces/settings/PLAN.md` - Settings
+
+### Hooks & Providers
+- `apps/cms/src/hooks/PLAN.md` - Custom hooks
+- `apps/cms/src/providers/PLAN.md` - Context providers
+- `apps/cms/src/routes/PLAN.md` - Routing
+
+### SDK Package
+- `packages/cms-sdk/PLAN.md` - SDK overview
+
+## Success Criteria
+
+- [ ] Feature parity with admin-cx for P0/P1 features
+- [ ] Existing sites work without migration
+- [ ] < 2s initial load time
+- [ ] < 100ms form render time
+- [ ] < 500ms preview refresh after edit
+- [ ] All JSON Schema widgets ported
+- [ ] Real-time daemon sync working
+- [ ] Release/git operations functional
+
+## Getting Started
+
+1. Review this document and the linked PLAN.md files
+2. Start with `packages/cms-sdk` implementation
+3. Setup `apps/cms` with basic routing
+4. Implement core editor (form + preview)
+5. Build out spaces one by one
+
+## Questions?
+
+Key files in admin-cx to reference:
+- `admin-cx/components/spaces/siteEditor/sdk.ts` - Main SDK
+- `admin-cx/components/editor/JSONSchema/` - Form system
+- `admin-cx/components/spaces/siteEditor/extensions/` - All views
+- `admin-cx/loaders/` - Data fetching
+- `admin-cx/actions/` - Mutations
+