Document: docs/developer/plugin-architecture-implementation.md

# Plugin Architecture Implementation Guide

> **Related Documents:**
>
> - [ADR-0008: Frontend Plugin Architecture](../adr/0008-frontend-plugin-architecture.md) - High-level design decision
> - [Framework M Sidebar Architecture](../rfcs/rfc-0007-sidebar-architecture.md) - Sidebar implementation that consumes plugin menus

## Table of Contents

1. [Why Do We Need a Plugin SDK?](#why-do-we-need-a-plugin-sdk)
2. [Package Roles & Responsibilities](#package-roles--responsibilities)
3. [Plugin Menu Implementation](#plugin-menu-implementation)
4. [Step-by-Step Implementation Plan](#step-by-step-implementation-plan)
5. [Developer Workflow Examples](#developer-workflow-examples)

---

## Why Do We Need a Plugin SDK?

### The Problem Without an SDK

Currently, if you want to build a WMS (Warehouse Management) app with custom frontend:

```bash
# Without SDK - Manual wiring required
m new:app wms
cd frontend/
# Now what? How do I add WMS routes/menus without forking the entire frontend?
# Answer: You can't. You have to modify frontend/src/ directly.
```

**Problems:**

1. ❌ **No separation** - WMS code mixed with core framework code
2. ❌ **No reusability** - Can't share WMS plugin across projects
3. ❌ **Merge conflicts** - Multiple apps editing same files
4. ❌ **No discovery** - Manual imports everywhere
5. ❌ **Tight coupling** - Changes to one app affect others

### The Solution With an SDK

```bash
# With SDK - Plugin auto-discovery
m new:app wms --with-frontend
cd frontend/
pnpm dev  # WMS plugin automatically loaded, routes/menus registered

# Later, add another app
m new:app personnel --with-frontend
pnpm dev  # Both WMS + Personnel auto-discovered and composed
```

**Benefits:**

1. ✅ **Clean separation** - Each app is self-contained plugin
2. ✅ **Reusable** - Share plugins across projects
3. ✅ **No conflicts** - Each app in its own directory
4. ✅ **Auto-discovery** - Vite plugin finds all workspace plugins
5. ✅ **Loose coupling** - Apps don't know about each other

### What the SDK Provides

The SDK is the **contract and tooling** that makes plugins possible:

```typescript
// Without SDK - You write this manually everywhere:
import WMSRoutes from "../apps/wms/routes";
import PersonnelRoutes from "../apps/personnel/routes";
import FinanceRoutes from "../apps/finance/routes";

const routes = [
  ...WMSRoutes,
  ...PersonnelRoutes,
  ...FinanceRoutes,
  // Every new app = manual import + spread
];

// With SDK - Framework does this automatically:
const registry = new PluginRegistry();
await registry.discoverAndRegister(); // Finds all plugins
const routes = registry.getRoutes(); // Merged automatically
const menus = registry.getMenu(); // Merged automatically
```

**The SDK is the "glue" that:**

- Defines plugin structure (types, interfaces)
- Discovers plugins at build time
- Registers plugins into a central registry
- Provides React hooks for accessing plugin data
- Manages plugin lifecycle

---

## Package Roles & Responsibilities

### Current Package Landscape

```
┌─────────────────────────────────────────────────────────────┐
│ External Packages (npm)                                     │
│ - @refinedev/core, @refinedev/react-router, etc.           │
│ - react, react-dom, react-router-dom                        │
│ - @tanstack/react-query, lucide-react, etc.                │
└─────────────────────────────────────────────────────────────┘
         │ used by
         ▼
┌─────────────────────────────────────────────────────────────┐
│ @framework-m/desk (EXISTING)                                │
│ Location: libs/framework-m-desk/                            │
│                                                              │
│ Purpose: Core UI library for Framework M applications      │
│                                                              │
│ Responsibilities:                                           │
│ • Refine.dev data provider (REST API integration)          │
│ • Auth provider (JWT, session management)                  │
│ • Live provider (WebSocket notifications)                  │
│ • Base DocType components (List, Form, Show)               │
│ • Hooks (useDocType, useMetadata, useWorkflow)             │
│ • Utilities (date formatting, validation, etc.)            │
│                                                              │
│ Consumers: Both single-app and multi-app projects          │
│ Published to: GitLab npm registry (@framework-m/desk)      │
└─────────────────────────────────────────────────────────────┘
```

### New Packages for Plugin System

```
┌─────────────────────────────────────────────────────────────┐
│ @framework-m/plugin-sdk (NEW - Phase 1)                    │
│ Location: libs/framework-m-plugin-sdk/                      │
│                                                              │
│ Purpose: Plugin infrastructure and runtime                  │
│                                                              │
│ Responsibilities:                                           │
│ • PluginRegistry - Central registry for all plugins        │
│ • ServiceContainer - Dependency injection for services     │
│ • React hooks - usePlugin(), useService(), usePluginMenu() │
│ • Types - FrameworkMPlugin interface, MenuItem, etc.       │
│ • Discovery - discoverPlugins() utility                     │
│                                                              │
│ Dependencies: React (peer), @framework-m/desk (peer)       │
│ Consumers: Shell app, all plugin packages                  │
│ Published to: GitLab npm registry                          │
└─────────────────────────────────────────────────────────────┘
         │ used by
         ▼
┌─────────────────────────────────────────────────────────────┐
│ @framework-m/vite-plugin (NEW - Phase 2)                   │
│ Location: libs/framework-m-vite-plugin/                     │
│                                                              │
│ Purpose: Build-time plugin discovery and bundling          │
│                                                              │
│ Responsibilities:                                           │
│ • Scan workspace for packages with "framework-m" metadata  │
│ • Generate virtual entry point with all plugins            │
│ • Configure code-splitting (one chunk per plugin)          │
│ • Setup HMR for plugin hot reload                          │
│ • Type generation for discovered plugins                   │
│                                                              │
│ Dependencies: vite, @framework-m/plugin-sdk                │
│ Consumers: Shell app vite.config.ts                        │
│ Published to: GitLab npm registry                          │
└─────────────────────────────────────────────────────────────┘
         │ used by
         ▼
┌─────────────────────────────────────────────────────────────┐
│ frontend/ (Shell Application)                              │
│ Location: frontend/ (root of project)                      │
│                                                              │
│ Purpose: Composition root - assembles all plugins          │
│                                                              │
│ Responsibilities:                                           │
│ • Bootstrap PluginRegistry                                  │
│ • Render composed UI (routes, menus, providers)            │
│ • Provide base layout (Sidebar, Header, etc.)              │
│ • Handle authentication flow                               │
│ • Error boundaries and loading states                      │
│                                                              │
│ Dependencies:                                               │
│ • @framework-m/desk - Core UI components                   │
│ • @framework-m/plugin-sdk - Plugin runtime                 │
│ • @framework-m/vite-plugin - Build tooling                 │
│                                                              │
│ NOT published - Static build output (dist/)                │
└─────────────────────────────────────────────────────────────┘
         │ uses
         ▼
┌─────────────────────────────────────────────────────────────┐
│ App Plugins (Workspace Packages)                           │
│ Locations:                                                  │
│ • apps/wms/frontend/                                        │
│ • apps/personnel/frontend/                                  │
│ • apps/finance/frontend/                                    │
│                                                              │
│ Purpose: Domain-specific UI modules                        │
│                                                              │
│ Example: @my-company/wms                                    │
│                                                              │
│ Responsibilities:                                           │
│ • Define routes for WMS screens                            │
│ • Define menu items for WMS navigation                     │
│ • Provide WMS-specific components                          │
│ • Register WMS services (WarehouseService, etc.)           │
│ • Extend base DocTypes (add WMS-specific fields)           │
│                                                              │
│ Dependencies:                                               │
│ • @framework-m/desk - Use base components                  │
│ • @framework-m/plugin-sdk - Plugin contract                │
│                                                              │
│ NOT published to npm - Workspace-local only                │
│ Bundled into frontend/dist/ at build time                  │
└─────────────────────────────────────────────────────────────┘
```

### Package Dependency Matrix

| Package                    | Depends On                   | Used By                     | Published?           |
| -------------------------- | ---------------------------- | --------------------------- | -------------------- |
| `@framework-m/desk`        | `@refinedev/*`, `react`      | Shell, All Plugins          | ✅ GitLab npm        |
| `@framework-m/plugin-sdk`  | `react`, `@framework-m/desk` | Shell, Plugins, Vite Plugin | ✅ GitLab npm        |
| `@framework-m/vite-plugin` | `vite`, `plugin-sdk`         | Shell (vite.config.ts)      | ✅ GitLab npm        |
| Shell App (`frontend/`)    | All framework packages       | -                           | ❌ Static build only |
| App Plugins                | `desk`, `plugin-sdk`         | Shell (via auto-discovery)  | ❌ Workspace-local   |

### Key Design Principles

1. **@framework-m/desk remains unchanged** - No breaking changes for existing users
2. **Plugin SDK is opt-in** - Single-app users don't need it
3. **Workspace-local plugins** - No need to publish app-specific code
4. **Build-time composition** - Plugins bundled into single artifact
5. **Runtime flexibility** - Can switch to Module Federation later if needed

---

## Plugin Menu Implementation

### Step 1: Define Plugin Contract (in SDK)

```typescript
// libs/framework-m-plugin-sdk/src/types/plugin.ts

export interface MenuItem {
  name: string; // Unique ID: "sales.invoice"
  label: string; // Display: "Sales Invoice"
  route: string; // Path: "/doctypes/sales.invoice"
  icon?: string; // Icon name: "file-text"
  module?: string; // Group: "Sales"
  category?: string; // Subgroup: "Transactions"
  order?: number; // Sort order
  children?: MenuItem[]; // Nested items
}

export interface FrameworkMPlugin {
  name: string;
  version: string;

  // Menu contribution
  menu?: MenuItem[];

  // Other plugin features
  routes?: RouteObject[];
  services?: Record<string, ServiceFactory>;
  providers?: Provider[];
  doctypes?: DocTypeExtension[];
  widgets?: Widget[];
}
```

### Step 2: Create Plugin Registry (in SDK)

```typescript
// libs/framework-m-plugin-sdk/src/core/PluginRegistry.ts

import { FrameworkMPlugin, MenuItem } from "../types/plugin";

export class PluginRegistry {
  private plugins = new Map<string, FrameworkMPlugin>();
  private menuCache: MenuItem[] | null = null;

  /**
   * Register a plugin with the registry
   */
  async register(plugin: FrameworkMPlugin): Promise<void> {
    // Validate plugin structure
    if (!plugin.name || !plugin.version) {
      throw new Error("Plugin must have name and version");
    }

    // Check for duplicates
    if (this.plugins.has(plugin.name)) {
      console.warn(`Plugin ${plugin.name} already registered, overwriting`);
    }

    this.plugins.set(plugin.name, plugin);
    this.menuCache = null; // Invalidate cache
  }

  /**
   * Get merged menu tree from all plugins
   */
  getMenu(): MenuItem[] {
    if (this.menuCache) return this.menuCache;

    const allMenus: MenuItem[] = [];

    // Collect menu items from all plugins
    for (const plugin of this.plugins.values()) {
      if (plugin.menu) {
        allMenus.push(...plugin.menu);
      }
    }

    // Merge and sort menu items
    this.menuCache = this.mergeMenus(allMenus);
    return this.menuCache;
  }

  /**
   * Merge menu items by module and category
   */
  private mergeMenus(items: MenuItem[]): MenuItem[] {
    const moduleMap = new Map<string, MenuItem>();

    for (const item of items) {
      const moduleName = item.module || "Other";

      if (!moduleMap.has(moduleName)) {
        // Create module group
        moduleMap.set(moduleName, {
          name: moduleName.toLowerCase(),
          label: moduleName,
          route: `/${moduleName.toLowerCase()}`,
          icon: this.getModuleIcon(moduleName),
          children: [],
        });
      }

      const moduleGroup = moduleMap.get(moduleName)!;

      if (item.category) {
        // Find or create category subgroup
        let categoryGroup = moduleGroup.children?.find(
          c => c.name === item.category,
        );

        if (!categoryGroup) {
          categoryGroup = {
            name: item.category!.toLowerCase(),
            label: item.category!,
            route: `/${moduleName.toLowerCase()}/${item.category!.toLowerCase()}`,
            children: [],
          };
          moduleGroup.children!.push(categoryGroup);
        }

        categoryGroup.children!.push(item);
      } else {
        // No category, add directly to module
        moduleGroup.children!.push(item);
      }
    }

    // Convert map to array and sort
    return Array.from(moduleMap.values()).sort(
      (a, b) => (a.order || 999) - (b.order || 999),
    );
  }

  private getModuleIcon(moduleName: string): string {
    const icons: Record<string, string> = {
      Sales: "shopping-cart",
      Inventory: "package",
      HR: "users",
      Finance: "dollar-sign",
      Core: "settings",
    };
    return icons[moduleName] || "folder";
  }

  /**
   * Get all registered routes
   */
  getRoutes(): RouteObject[] {
    const routes: RouteObject[] = [];
    for (const plugin of this.plugins.values()) {
      if (plugin.routes) {
        routes.push(...plugin.routes);
      }
    }
    return routes;
  }

  /**
   * Get all registered plugins
   */
  getAllPlugins(): FrameworkMPlugin[] {
    return Array.from(this.plugins.values());
  }
}
```

### Step 3: Create React Hook (in SDK)

```typescript
// libs/framework-m-plugin-sdk/src/hooks/usePluginMenu.ts

import { useContext, useMemo } from "react";
import { PluginRegistryContext } from "../context/PluginRegistryContext";
import { MenuItem } from "../types/plugin";

/**
 * Hook to access the merged menu tree from all plugins
 */
export function usePluginMenu(): MenuItem[] {
  const registry = useContext(PluginRegistryContext);

  if (!registry) {
    throw new Error("usePluginMenu must be used within PluginRegistryProvider");
  }

  return useMemo(() => registry.getMenu(), [registry]);
}

/**
 * Hook to access a specific plugin's data
 */
export function usePlugin(name: string) {
  const registry = useContext(PluginRegistryContext);

  if (!registry) {
    throw new Error("usePlugin must be used within PluginRegistryProvider");
  }

  return useMemo(
    () => registry.getAllPlugins().find(p => p.name === name),
    [registry, name],
  );
}
```

### Step 4: Create Context Provider (in SDK)

```typescript
// libs/framework-m-plugin-sdk/src/context/PluginRegistryContext.tsx

import { createContext, ReactNode, useEffect, useState } from 'react';
import { PluginRegistry } from '../core/PluginRegistry';

export const PluginRegistryContext = createContext<PluginRegistry | null>(null);

interface PluginRegistryProviderProps {
  children: ReactNode;
  registry?: PluginRegistry;
}

export function PluginRegistryProvider({
  children,
  registry: externalRegistry,
}: PluginRegistryProviderProps) {
  const [registry] = useState(() => externalRegistry || new PluginRegistry());
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // Mark as ready after initial mount
    setIsReady(true);
  }, []);

  if (!isReady) {
    return <div>Loading plugins...</div>;
  }

  return (
    <PluginRegistryContext.Provider value={registry}>
      {children}
    </PluginRegistryContext.Provider>
  );
}
```

### Step 5: Update Shell App to Use Plugin Menu

```typescript
// frontend/src/layout/Sidebar.tsx

import { usePluginMenu } from '@framework-m/plugin-sdk';
import { useMemo } from 'react';

export function Sidebar() {
  // Get merged menu from all plugins
  const pluginMenu = usePluginMenu();

  // Load user preferences (favorites, recent)
  const favorites = useFavorites();
  const recent = useRecent();
  const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});

  // Group plugin menu by module
  const groupedMenu = useMemo(() => {
    const groups: Record<string, MenuItem[]> = {};

    for (const moduleGroup of pluginMenu) {
      if (moduleGroup.children) {
        groups[moduleGroup.label] = moduleGroup.children;
      }
    }

    return groups;
  }, [pluginMenu]);

  return (
    <nav>
      {/* Favorites Section */}
      <FavoritesSection items={favorites} />

      {/* Recent Section */}
      <RecentSection items={recent} />

      {/* Plugin Menus */}
      {Object.entries(groupedMenu).map(([moduleName, items]) => (
        <ModuleSection
          key={moduleName}
          name={moduleName}
          items={items}
          isCollapsed={collapsedGroups[moduleName]}
          onToggle={() => toggleGroup(moduleName)}
        />
      ))}
    </nav>
  );
}
```

### Step 6: Create WMS Plugin

```typescript
// apps/wms/frontend/plugin.config.ts

import { FrameworkMPlugin } from "@framework-m/plugin-sdk";

export default {
  name: "wms",
  version: "1.0.0",

  // Define WMS menu items
  menu: [
    {
      name: "wms.warehouse",
      label: "Warehouse",
      route: "/doctypes/wms.warehouse",
      icon: "warehouse",
      module: "Inventory",
      category: "Masters",
      order: 1,
    },
    {
      name: "wms.stock_entry",
      label: "Stock Entry",
      route: "/doctypes/wms.stock_entry",
      icon: "package",
      module: "Inventory",
      category: "Transactions",
      order: 2,
    },
    {
      name: "wms.bin_location",
      label: "Bin Location",
      route: "/doctypes/wms.bin_location",
      icon: "map-pin",
      module: "Inventory",
      category: "Masters",
      order: 3,
    },
  ],

  // Define WMS routes
  routes: [
    {
      path: "/wms/dashboard",
      element: () => import("./pages/Dashboard"),
    },
    {
      path: "/wms/receiving",
      element: () => import("./pages/Receiving"),
    },
  ],

  // Register WMS services
  services: {
    warehouseService: () => import("./services/WarehouseService"),
  },
} satisfies FrameworkMPlugin;
```

```json
// apps/wms/frontend/package.json
{
  "name": "@my-company/wms",
  "version": "1.0.0",
  "private": true,
  "framework-m": {
    "plugin": "./dist/plugin.config.js",
    "type": "frontend-module"
  },
  "dependencies": {
    "@framework-m/desk": "^0.1.0",
    "@framework-m/plugin-sdk": "^0.1.0"
  }
}
```

### Step 7: Vite Plugin Auto-Discovery

```typescript
// libs/framework-m-vite-plugin/src/index.ts

import { Plugin } from "vite";
import { globSync } from "glob";
import path from "path";
import fs from "fs";

export function frameworkMPlugin(): Plugin {
  let pluginPaths: string[] = [];

  return {
    name: "framework-m-plugin",

    // Discover plugins before build starts
    async buildStart() {
      // Find all workspace packages with framework-m metadata
      const packageJsonFiles = globSync("**/package.json", {
        ignore: ["**/node_modules/**", "**/dist/**"],
      });

      pluginPaths = [];

      for (const pkgPath of packageJsonFiles) {
        const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));

        // Check for framework-m plugin metadata
        if (pkg["framework-m"]?.plugin) {
          const pluginConfigPath = path.resolve(
            path.dirname(pkgPath),
            pkg["framework-m"].plugin,
          );
          pluginPaths.push(pluginConfigPath);
        }
      }

      console.log(`✓ Discovered ${pluginPaths.length} Framework M plugins`);
    },

    // Generate virtual module with all plugins
    resolveId(id) {
      if (id === "virtual:framework-m-plugins") {
        return "\0virtual:framework-m-plugins";
      }
    },

    load(id) {
      if (id === "\0virtual:framework-m-plugins") {
        // Generate import statements for all plugins
        const imports = pluginPaths
          .map((p, i) => `import plugin${i} from '${p}';`)
          .join("\n");

        const exports = `export default [${pluginPaths.map((_, i) => `plugin${i}`).join(", ")}];`;

        return `${imports}\n${exports}`;
      }
    },
  };
}
```

### Step 8: Shell App Bootstrap

```typescript
// frontend/src/App.tsx

import { PluginRegistry, PluginRegistryProvider } from '@framework-m/plugin-sdk';
import { useEffect, useState } from 'react';
// @ts-ignore - Virtual module generated by Vite plugin
import discoveredPlugins from 'virtual:framework-m-plugins';

async function bootstrap() {
  const registry = new PluginRegistry();

  // Register all discovered plugins
  for (const plugin of discoveredPlugins) {
    await registry.register(plugin);
  }

  return registry;
}

export function App() {
  const [registry, setRegistry] = useState<PluginRegistry | null>(null);

  useEffect(() => {
    bootstrap().then(setRegistry);
  }, []);

  if (!registry) {
    return <LoadingScreen />;
  }

  return (
    <PluginRegistryProvider registry={registry}>
      <Refine
        dataProvider={dataProvider}
        authProvider={authProvider}
        routerProvider={routerProvider}
      >
        <Routes>
          <Route path="/" element={<Layout />}>
            {/* Routes from plugins */}
            {registry.getRoutes().map((route, i) => (
              <Route key={i} {...route} />
            ))}
          </Route>
        </Routes>
      </Refine>
    </PluginRegistryProvider>
  );
}
```

---

## Step-by-Step Implementation Plan

### Phase 1: Create @framework-m/plugin-sdk (Week 1-2)

**Tasks:**

1. ✅ Create package structure

   ```bash
   mkdir -p libs/framework-m-plugin-sdk/src/{types,core,hooks,context}
   cd libs/framework-m-plugin-sdk
   pnpm init
   ```

2. ✅ Define types (`src/types/plugin.ts`)
   - `MenuItem` interface
   - `FrameworkMPlugin` interface
   - `ServiceFactory` type
   - `RouteObject` extensions

3. ✅ Implement `PluginRegistry` class (`src/core/PluginRegistry.ts`)
   - `register()` method
   - `getMenu()` with merging logic
   - `getRoutes()` aggregation
   - `getAllPlugins()` accessor

4. ✅ Create React hooks (`src/hooks/`)
   - `usePluginMenu()`
   - `usePlugin(name)`
   - `useService(name)`

5. ✅ Create context provider (`src/context/PluginRegistryContext.tsx`)

6. ✅ Write tests (100% coverage target)

7. ✅ Setup build (TypeScript + Vite)

8. ✅ Publish to GitLab npm registry

**Deliverables:**

- `@framework-m/plugin-sdk` package published
- Full API documentation
- Unit tests passing

### Phase 2: Create @framework-m/vite-plugin (Week 2-3)

**Tasks:**

1. ✅ Create package structure
2. ✅ Implement plugin discovery logic
3. ✅ Generate virtual module
4. ✅ Configure code-splitting
5. ✅ Setup HMR support
6. ✅ Write integration tests
7. ✅ Publish to GitLab npm registry

**Deliverables:**

- `@framework-m/vite-plugin` package published
- Working auto-discovery
- Documentation with examples

### Phase 3: Update Shell App (Week 3-4)

**Tasks:**

1. ✅ Install plugin-sdk and vite-plugin

   ```bash
   cd frontend
   pnpm add @framework-m/plugin-sdk @framework-m/vite-plugin
   ```

2. ✅ Update `vite.config.ts`

   ```typescript
   import { frameworkMPlugin } from "@framework-m/vite-plugin";

   export default defineConfig({
     plugins: [react(), frameworkMPlugin()],
   });
   ```

3. ✅ Bootstrap in `App.tsx`
4. ✅ Update `Sidebar.tsx` to use `usePluginMenu()`
5. ✅ Test with existing menu data
6. ✅ Add loading states and error boundaries

**Deliverables:**

- Shell app using plugin system
- Sidebar consuming plugin menus
- No breaking changes to existing functionality

### Phase 4: Create Example Plugin (Week 4-5)

**Tasks:**

1. ✅ Scaffold WMS plugin structure

   ```bash
   m new:app wms --with-frontend
   ```

2. ✅ Define `plugin.config.ts`
3. ✅ Add menu items
4. ✅ Create sample routes
5. ✅ Test auto-discovery
6. ✅ Document plugin development workflow

**Deliverables:**

- Working WMS plugin example
- Plugin development guide
- Tutorial documentation

### Phase 5: CLI Enhancements (Week 5-6)

**Tasks:**

1. ✅ Add `--with-frontend` flag to `m new:app`
2. ✅ Create plugin template
3. ✅ Auto-configure workspace
4. ✅ Update CLI documentation

**Deliverables:**

- Enhanced CLI command
- Plugin scaffolding template
- Developer workflow documentation

---

## Developer Workflow Examples

### Single App (No Plugins) - Current Workflow

```bash
# Create project
m new:project erp
cd erp

# Work in monolithic frontend
cd frontend/
pnpm dev

# Add custom route manually
# Edit: frontend/src/App.tsx
# Edit: frontend/src/pages/CustomPage.tsx
```

**No changes needed** - Existing workflow still works!

### Multi-App (With Plugins) - New Workflow

```bash
# Create project
m new:project erp
cd erp

# Create first app with frontend
m new:app wms --with-frontend

# Plugin structure auto-generated:
# apps/wms/frontend/
#   ├── plugin.config.ts
#   ├── package.json (with framework-m metadata)
#   └── src/
#       ├── pages/
#       ├── components/
#       └── services/

# Develop WMS plugin
cd apps/wms/frontend
# Edit: plugin.config.ts (add menu items, routes)
# Edit: src/pages/Dashboard.tsx (create pages)

# Run from shell
cd ../../../frontend
pnpm dev
# ✓ WMS plugin auto-discovered
# ✓ WMS routes registered
# ✓ WMS menu items in sidebar

# Add second app
m new:app personnel --with-frontend
cd frontend
pnpm dev
# ✓ Both WMS and Personnel plugins loaded
# ✓ Menus merged automatically
```

### Production Build

```bash
cd frontend
pnpm build

# Output: frontend/dist/
#   index.html
#   assets/
#     main-abc123.js          (Shell + framework)
#     wms-def456.js           (WMS plugin - lazy loaded)
#     personnel-ghi789.js     (Personnel plugin - lazy loaded)

# Deploy
m build  # Bundles dist/ into Python package
pip install dist/framework_m-1.0.0-py3-none-any.whl
m prod   # Serves bundled frontend
```

---

## Summary

### Why SDK?

| Without SDK               | With SDK         |
| ------------------------- | ---------------- |
| Manual imports everywhere | Auto-discovery   |
| Code duplication          | Reusable plugins |
| Merge conflicts           | Clean separation |
| Tight coupling            | Loose coupling   |
| Hard to scale             | Scales easily    |

### Package Roles Quick Reference

| Package                    | Role             | Published?         | Used By         |
| -------------------------- | ---------------- | ------------------ | --------------- |
| `@framework-m/desk`        | Core UI library  | ✅ npm             | Everyone        |
| `@framework-m/plugin-sdk`  | Plugin runtime   | ✅ npm             | Shell + Plugins |
| `@framework-m/vite-plugin` | Build tooling    | ✅ npm             | Shell only      |
| Shell (`frontend/`)        | Composition root | ❌ Build output    | -               |
| App Plugins                | Domain modules   | ❌ Workspace-local | Shell (bundled) |

### Implementation Path

1. **Phase 1** - Build `@framework-m/plugin-sdk` ← **START HERE**
2. **Phase 2** - Build `@framework-m/vite-plugin`
3. **Phase 3** - Update shell app to use plugins
4. **Phase 4** - Create example WMS plugin
5. **Phase 5** - Enhance CLI for plugin scaffolding

**Next Step:** Start implementing Phase 1 (plugin-sdk package)


Document: docs/developer/i18n.md

---
title: Internationalization
sidebar_position: 7
---

# Internationalization (i18n)

Framework M provides built-in internationalization support through the `I18nProtocol`.

## I18nProtocol

The core interface for translations:

```python
from framework_m.core.interfaces.i18n import I18nProtocol

class I18nProtocol(Protocol):
    async def translate(
        self,
        text: str,
        locale: str | None = None,
        *,
        context: dict[str, str] | None = None,
        default: str | None = None,
    ) -> str: ...

    async def get_locale(self) -> str: ...
    async def set_locale(self, locale: str) -> None: ...
    async def get_available_locales(self) -> list[str]: ...
```

## Using Translations

```python
# In a controller or service
async def greet_user(i18n: I18nProtocol, name: str, locale: str = "en"):
    greeting = await i18n.translate(
        "Hello, {name}!",
        locale=locale,
        context={"name": name}
    )
    return greeting
```

## InMemoryI18nAdapter

A simple adapter for testing and development:

```python
from framework_m.adapters.i18n import InMemoryI18nAdapter

i18n = InMemoryI18nAdapter(default_locale="en")

# Add translations
i18n.add_translation("es", "Hello", "Hola")
i18n.add_translation("fr", "Hello", "Bonjour")

# Use translations
text = await i18n.translate("Hello", locale="es")
# Returns: "Hola"

# With interpolation
i18n.add_translation("es", "Hello, {name}!", "¡Hola, {name}!")
text = await i18n.translate(
    "Hello, {name}!",
    locale="es",
    context={"name": "Juan"}
)
# Returns: "¡Hola, Juan!"
```

## Locale Resolution

The framework supports locale resolution in this order:

1. **Request locale** - `Accept-Language` header
2. **User preference** - `user.locale` setting
3. **Tenant default** - `tenant.default_locale`
4. **System default** - `settings.DEFAULT_LOCALE`

## Creating Custom Adapters

Implement `I18nProtocol` for custom translation backends:

```python
class DatabaseI18nAdapter:
    """Load translations from database."""

    def __init__(self, repo: TranslationRepository):
        self.repo = repo
        self._cache: dict[str, dict[str, str]] = {}

    async def translate(
        self,
        text: str,
        locale: str | None = None,
        *,
        context: dict[str, str] | None = None,
        default: str | None = None,
    ) -> str:
        target_locale = locale or await self.get_locale()

        # Check cache first
        if target_locale in self._cache:
            if text in self._cache[target_locale]:
                translation = self._cache[target_locale][text]
                if context:
                    for key, value in context.items():
                        translation = translation.replace(f"{{{key}}}", value)
                return translation

        # Fallback to original text
        return default or text

    async def load_translations(self, locale: str) -> None:
        """Load all translations for a locale into cache."""
        translations = await self.repo.get_by_locale(locale)
        self._cache[locale] = {t.source: t.translated for t in translations}
```

## DocType Labels

Field labels in DocTypes support i18n:

```python
class Invoice(BaseDocType):
    customer: str = Field(
        description="Customer name",  # Can be translated via i18n
    )
```

The Meta API returns translated labels based on request locale.


Document: docs/developer/plugin-composition-patterns.md

# Plugin Composition Patterns

## Overview

Framework M's multi-package UI architecture enables powerful composition patterns where multiple packages can contribute, extend, and override UI components at build time.

## Registration Order

Plugins are registered in a specific order that determines override precedence:

```typescript
// 1. Installed packages (alphabetically by package name)
registerPlugin({ name: "business_m" });
registerPlugin({ name: "crm_app" });
registerPlugin({ name: "wms_app" });

// 2. Local workspace apps (alphabetically by app name)
registerPlugin({ name: "custom_app" });

// Later registrations win for the same route/component
```

## Pattern 1: Page Override

Replace a page from another package entirely.

### Example: Custom CRM Lead Form

**Standard CRM (crm_app):**

```typescript
// crm_app/frontend/index.ts
registerPlugin({
  name: "crm_app",
  pages: [
    {
      path: "/app/lead/:id",
      component: LeadForm,
    },
  ],
});
```

**Custom Extension (acme_crm_ext):**

```typescript
// acme_crm_ext/frontend/index.ts
import { LeadForm as StandardLeadForm } from "@crm-app/frontend/pages/LeadForm";

function AcmeLeadForm(props) {
  return (
    <div>
      <StandardLeadForm {...props} />
      {/* Add Acme-specific fields */}
      <AcmeCustomFields leadId={props.id} />
    </div>
  );
}

registerPlugin({
  name: "acme_crm_ext",
  pages: [
    {
      path: "/app/lead/:id",
      component: AcmeLeadForm,
      override: true, // ← Explicitly override
    },
  ],
});
```

**Result:** When users navigate to `/app/lead/123`, they see `AcmeLeadForm` instead of the standard `LeadForm`.

## Pattern 2: Slot Injection

Add components to predefined slots without overriding the entire page.

### Example: Dashboard Widgets

**Base Package (framework-m-desk):**

```typescript
// Defines slot in Dashboard
registerPlugin({
  name: "framework_m_desk",
  pages: [
    {
      path: "/app/dashboard",
      component: Dashboard,
      slots: ["dashboard.widgets"], // ← Define available slots
    },
  ],
});

// Dashboard.tsx renders slot
import { SlotRenderer } from "@framework-m/core";

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <SlotRenderer slot="dashboard.widgets" />
    </div>
  );
}
```

**Extension Packages (multiple):**

```typescript
// business_m/frontend/index.ts
registerPlugin({
  name: "business_m",
  slots: [
    {
      slot: "dashboard.widgets",
      component: SalesWidget,
      priority: 10,
    },
  ],
});

// wms_app/frontend/index.ts
registerPlugin({
  name: "wms_app",
  slots: [
    {
      slot: "dashboard.widgets",
      component: StockLevelWidget,
      priority: 20, // ← Higher priority = rendered first
    },
  ],
});

// crm_app/frontend/index.ts
registerPlugin({
  name: "crm_app",
  slots: [
    {
      slot: "dashboard.widgets",
      component: LeadPipelineWidget,
      priority: 15,
    },
  ],
});
```

**Result:** Dashboard automatically renders all three widgets in priority order: `StockLevelWidget` (20) → `LeadPipelineWidget` (15) → `SalesWidget` (10).

## Pattern 3: Component Extension

Wrap or enhance components from base packages.

### Example: Enhanced Customer Form

**Base Package (business_m):**

```typescript
// business_m/frontend/components/CustomerForm.tsx
export function CustomerForm({ customer, onSave }) {
  return (
    <form>
      <Input name="customer_name" value={customer.name} />
      <Input name="email" value={customer.email} />
      <Button onClick={onSave}>Save</Button>
    </form>
  );
}
```

**Extension (custom_app):**

```typescript
// custom_app/frontend/components/EnhancedCustomerForm.tsx
import { CustomerForm } from "@business-m/frontend/components/CustomerForm";
import { CreditScoreWidget } from "./CreditScoreWidget";

export function EnhancedCustomerForm(props) {
  return (
    <div>
      {/* Standard form */}
      <CustomerForm {...props} />

      {/* Additional features */}
      <CreditScoreWidget customerId={props.customer.id} />
      <PaymentHistorySection customerId={props.customer.id} />
    </div>
  );
}

registerPlugin({
  name: "custom_app",
  components: {
    CustomerForm: EnhancedCustomerForm, // ← Override component
  },
});
```

## Pattern 4: Hook Composition

Extend or wrap hooks from other packages.

### Example: Enhanced Data Fetching

**Base Package:**

```typescript
// business_m/frontend/hooks/useCustomer.ts
export function useCustomer(id: string) {
  const { data, isLoading } = useQuery(["customer", id], () =>
    fetch(`/api/customer/${id}`),
  );

  return { customer: data, isLoading };
}
```

**Extension:**

```typescript
// custom_app/frontend/hooks/useCustomerWithScore.ts
import { useCustomer } from "@business-m/frontend/hooks/useCustomer";

export function useCustomerWithScore(id: string) {
  const { customer, isLoading } = useCustomer(id);

  // Add credit score data
  const { data: score } = useQuery(["credit-score", id], () =>
    fetch(`/api/credit-score/${id}`),
  );

  return {
    customer: { ...customer, creditScore: score },
    isLoading,
  };
}
```

## Pattern 5: Multi-Package Composition

Combine features from multiple packages into a unified experience.

### Example: Unified Customer View

```typescript
// custom_app/frontend/pages/UnifiedCustomer.tsx
import { CustomerCard } from "@business-m/frontend/components/CustomerCard";
import { CustomerOrders } from "@wms-app/frontend/components/CustomerOrders";
import { CustomerLeads } from "@crm-app/frontend/components/CustomerLeads";
import { CustomerSupport } from "@hr-app/frontend/components/CustomerSupport";

export function UnifiedCustomerView({ customerId }) {
  return (
    <Page>
      <Grid>
        {/* From business-m */}
        <CustomerCard id={customerId} />

        {/* From wms-app */}
        <CustomerOrders customerId={customerId} />

        {/* From crm-app */}
        <CustomerLeads customerId={customerId} />

        {/* From hr-app */}
        <CustomerSupport customerId={customerId} />
      </Grid>
    </Page>
  );
}

registerPlugin({
  name: "custom_app",
  pages: [
    {
      path: "/app/customer/:id/unified",
      component: UnifiedCustomerView,
    },
  ],
});
```

## Pattern 6: Conditional Registration

Register different UI based on configuration or feature flags.

### Example: Environment-Specific Features

```typescript
// custom_app/frontend/index.ts
import { registerPlugin } from "@framework-m/core";
import { DevelopmentTools } from "./components/DevelopmentTools";
import { AdminPanel } from "./components/AdminPanel";

const isDevelopment = import.meta.env.MODE === "development";
const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === "true";

registerPlugin({
  name: "custom_app",

  slots: [
    // Development-only tools
    ...(isDevelopment
      ? [
          {
            slot: "desk.toolbar",
            component: DevelopmentTools,
          },
        ]
      : []),

    // Admin-only panel
    ...(enableAdmin
      ? [
          {
            slot: "desk.sidebar.top",
            component: AdminPanel,
          },
        ]
      : []),
  ],
});
```

## Pattern 7: Type-Safe Component Registry

Share component types across packages for type-safe composition.

### Setup Type Definitions

**Base Package (business_m):**

```typescript
// business_m/frontend/types/components.ts
export interface CustomerCardProps {
  customerId: string;
  variant?: "compact" | "detailed";
  onUpdate?: (customer: Customer) => void;
}

// business_m/frontend/components/CustomerCard.tsx
import type { CustomerCardProps } from "../types/components";

export function CustomerCard(props: CustomerCardProps) {
  // Implementation
}
```

**Extension Package:**

```typescript
// custom_app/frontend/components/DashboardCustomerCard.tsx
import type { CustomerCardProps } from "@business-m/frontend/types/components";
import { CustomerCard } from "@business-m/frontend/components/CustomerCard";

// Type-safe wrapper
export function DashboardCustomerCard(props: CustomerCardProps) {
  return (
    <div className="dashboard-widget">
      <CustomerCard {...props} variant="compact" />
    </div>
  );
}
```

## Pattern 8: Theme Customization

Override theme tokens from base packages.

### Example: Custom Branding

**Base Theme (framework-m-desk):**

```typescript
// framework-m-desk/frontend/theme/index.ts
export const theme = {
  colors: {
    primary: "#3b82f6",
    secondary: "#8b5cf6",
  },
  fonts: {
    body: "Inter, sans-serif",
    heading: "Poppins, sans-serif",
  },
};
```

**Custom Theme (custom_app):**

```typescript
// custom_app/frontend/theme/index.ts
import { theme as baseTheme } from "@framework-m-desk/frontend/theme";

export const theme = {
  ...baseTheme,
  colors: {
    ...baseTheme.colors,
    primary: "#0ea5e9", // ← Acme Corp blue
    secondary: "#06b6d4",
  },
  fonts: {
    ...baseTheme.fonts,
    body: "Roboto, sans-serif", // ← Custom font
  },
};

registerPlugin({
  name: "custom_app",
  theme, // ← Override theme
});
```

## Override Resolution Rules

When multiple plugins register the same path/component/slot:

### Pages

```typescript
// Rule: Last registration wins
// crm_app registers first
registerPlugin({
  name: "crm_app",
  pages: [{ path: "/app/lead/:id", component: LeadForm }],
});

// custom_app registers later → WINS
registerPlugin({
  name: "custom_app",
  pages: [{ path: "/app/lead/:id", component: CustomLeadForm }],
});

// Result: /app/lead/123 renders CustomLeadForm
```

### Slots

```typescript
// Rule: All registrations render (sorted by priority)
registerPlugin({
  name: "pkg1",
  slots: [{ slot: "sidebar", component: Widget1, priority: 10 }],
});

registerPlugin({
  name: "pkg2",
  slots: [{ slot: "sidebar", component: Widget2, priority: 20 }],
});

// Result: Renders [Widget2, Widget1] (priority 20 first)
```

### Components

```typescript
// Rule: Last registration in component registry wins
registerPlugin({
  name: "business_m",
  components: { CustomerCard: BaseCustomerCard },
});

registerPlugin({
  name: "custom_app",
  components: { CustomerCard: CustomCustomerCard }, // ← WINS
});

// Other plugins importing CustomerCard get CustomCustomerCard
```

## Best Practices

### 1. Explicit Override Flag

Always use `override: true` when intentionally replacing a page:

```typescript
registerPlugin({
  name: "custom_app",
  pages: [
    {
      path: "/app/customer/:id",
      component: CustomCustomerPage,
      override: true, // ← Makes intent clear
    },
  ],
});
```

### 2. Semantic Slot Names

Use hierarchical, descriptive slot names:

```typescript
// ✅ Good
"desk.sidebar.top";
"desk.sidebar.bottom";
"page.customer.header";
"page.customer.footer";

// ❌ Bad
"slot1";
"sidebar";
"custom";
```

### 3. Document Public APIs

If your package exports components for others to use, document them:

````typescript
/**
 * Customer card component with customization options.
 *
 * @example
 * ```tsx
 * import { CustomerCard } from "@business-m/frontend";
 *
 * <CustomerCard
 *   customerId="CUST-001"
 *   variant="detailed"
 *   onUpdate={handleUpdate}
 * />
 * ```
 */
export function CustomerCard(props: CustomerCardProps) {
  // Implementation
}
````

### 4. Version Compatibility

Check for compatible versions when composing:

```typescript
import { version as businessMVersion } from "@business-m/frontend";

if (!businessMVersion.startsWith("1.")) {
  console.warn("custom_app requires business-m ^1.0.0");
}
```

## Debugging Composition

### View Registered Plugins

```typescript
import { getRegisteredPlugins } from "@framework-m/core";

console.log(getRegisteredPlugins());
// [
//   { name: "business_m", version: "1.0.0", pages: [...], slots: [...] },
//   { name: "crm_app", version: "2.1.0", pages: [...], slots: [...] },
//   { name: "custom_app", version: "1.0.0", pages: [...], slots: [...] },
// ]
```

### Debug Slot Rendering

```typescript
import { getSlotComponents } from "@framework-m/core";

console.log(getSlotComponents("dashboard.widgets"));
// [
//   { plugin: "wms_app", component: StockLevelWidget, priority: 20 },
//   { plugin: "crm_app", component: LeadPipelineWidget, priority: 15 },
//   { plugin: "business_m", component: SalesWidget, priority: 10 },
// ]
```

### Trace Component Origin

```typescript
import { getComponentSource } from "@framework-m/core";

const source = getComponentSource("CustomerCard");
console.log(source);
// { plugin: "custom_app", originalPlugin: "business_m", overridden: true }
```

## Next Steps

- [Package Frontend Guide](./package-frontend-guide.md) - Complete package structure reference
- [Migration Path](../adr/0010-multi-package-ui-composition.md#migration-path) - Migrate existing apps/ to packages
- [Protocol Reference](../developer/generated/index.md) - Full plugin-related protocol documentation


Document: docs/developer/desk-setup.md

---
title: Desk Setup
sidebar_position: 9
---

# Desk Setup Guide

## Running Desk

The Desk provides the main user interface for managing your Framework M application data and workflows.

### Single Command (Production)

```bash
m prod
```

This starts the backend server with the bundled Desk UI on port 8888.

### Development Mode

For development with hot reload:

```bash
m dev
```

This starts both backend and frontend with automatic hot reload using honcho process manager.

### URL Structure

The Desk uses a **path prefix architecture** for clean separation:

- **UI**: `http://127.0.0.1:8888/desk/`
  - Main interface for managing data
  - All frontend routes are under `/desk/*`

- **API**: `http://127.0.0.1:8888/api/`
  - Backend endpoints for data operations
  - All API routes are under `/api/*`

- **Root**: `http://127.0.0.1:8888/`
  - Automatically redirects to `/desk/`

## Commands

### `m prod` - Production Mode

Runs the backend with bundled static Desk UI:

```bash
# Default (port 8888)
m prod

# Custom port
m prod --port 3000

# Custom app module
m prod --app myapp:create_app

# With auto-reload
m prod --reload
```

**Use Cases:**

- Production deployments
- Testing bundled UI
- Single-process server

### `m dev` - Development Mode

Runs backend and frontend concurrently with hot reload:

```bash
# Backend + Frontend
m dev

# Backend + Frontend + Studio
m dev --studio

# Custom backend module
m dev --app myapp:create_app

# Custom frontend directory
m dev --frontend-dir ./custom-ui

# Backend only (no frontend)
m dev --no-frontend

# Custom ports
m dev --port 9000 --frontend-port 3000 --studio-port 9999

# Enable worker and scheduler
m dev --enable-worker --enable-scheduler

# Use custom Procfile
m dev --procfile Procfile.dev
```

**Features:**

- ✅ **Hot Reload**: Backend (uvicorn) and frontend (Vite HMR)
- ✅ **Process Management**: Honcho manages multiple processes
- ✅ **Concurrent Output**: Color-coded logs with process prefixes
- ✅ **Graceful Shutdown**: Ctrl+C stops all processes cleanly
- ✅ **Configurable**: Supports Procfile and m.toml configuration
- ✅ **Extensible**: Add workers, schedulers, custom processes

**Default Ports:**
| Process | Port |
|---------|------|
| Backend | 8888 |
| Frontend | 5173 |
| Studio | 9999 |

**New CLI Flags:**
| Flag | Description |
|------|-------------|
| `--enable-worker` | Start worker process for background jobs |
| `--enable-scheduler` | Start scheduler process for periodic tasks |
| `--procfile <path>` | Load custom Procfile for process definitions |

## Configuration

The `m dev` command supports multiple configuration sources with the following precedence:

**Configuration Precedence (highest to lowest):**

1. **CLI flags** (e.g., `--enable-worker`, `--port 9000`)
2. **Procfile** (if specified with `--procfile`)
3. **m.toml [dev] section**
4. **Default processes**

### Using m.toml

Create a `m.toml` file in your project root:

```toml
[dev]
# Override default ports
backend_port = 8888
frontend_port = 5173

# Enable optional processes
enable_worker = true
enable_scheduler = false

# Override default commands (optional)
backend_command = "uvicorn app:app --reload --port 8888 --log-level debug"

# Use a custom Procfile (optional)
procfile = "Procfile.dev"

# Define custom processes
[dev.processes.mailhog]
command = "mailhog -smtp-bind-addr 127.0.0.1:1025 -ui-bind-addr 127.0.0.1:8025"
enabled = false  # Set to true to enable

[dev.processes.redis]
command = "redis-server --port 6379"
enabled = false
```

**See full example:** [`docs/examples/m.toml`](../examples/m.toml)

### Using Procfile

Create a `Procfile.dev` file in your project root:

```procfile
# Procfile.dev
backend: python -m uvicorn framework_m_standard.adapters.web.app:create_app --host 127.0.0.1 --port 8888 --reload
frontend: cd frontend && pnpm dev --port 5173
worker: m worker --concurrency 4
scheduler: m worker --scheduler-only
```

Then run:

```bash
m dev --procfile Procfile.dev
```

**See full example:** [`docs/examples/Procfile.dev`](../examples/Procfile.dev)

### Configuration Examples

**Example 1: Enable worker in m.toml**

```toml
[dev]
enable_worker = true
```

```bash
m dev  # Worker automatically starts
```

**Example 2: Mix m.toml with CLI flags**

```toml
[dev]
backend_port = 9000
```

```bash
m dev --enable-scheduler  # Uses port 9000 from m.toml, adds scheduler from CLI
```

**Example 3: Full custom Procfile**

```procfile
backend: uvicorn app:app --reload
frontend: cd ui && pnpm dev
worker: m worker --concurrency 8
scheduler: m worker --scheduler-only
mailhog: mailhog -smtp-bind-addr 127.0.0.1:1025
redis: redis-server
```

```bash
m dev --procfile Procfile.dev
```

## Architecture

### Path Prefix Separation

The Desk implements complete separation between UI and API routes:

```
/desk/*     → React SPA (HTML pages)
/api/*      → JSON API endpoints
/           → Redirects to /desk/
```

This architecture:

- ✅ Prevents route collisions
- ✅ Enables clean URLs (no hash fragments)
- ✅ Works with single-command deployment
- ✅ Supports proper Content-Type headers

### Static Assets

Static assets (JS, CSS, images) are served at:

```
/desk/assets/*
```

The Vite build process outputs to `libs/framework-m/src/framework_m/static/` which is included in the Python package.

### Process Management (m dev)

When running `m dev`, honcho orchestrates multiple processes:

```
┌─────────────────────────────────────────┐
│     honcho.manager.Manager              │
│                                         │
│  ┌──────────┐  ┌──────────┐  ┌────────┐│
│  │ backend  │  │ frontend │  │ studio ││
│  │ :8888    │  │ :5173    │  │ :9999  ││
│  └──────────┘  └──────────┘  └────────┘│
│                                         │
│  • Color-coded output                   │
│  • Concurrent execution                 │
│  • Graceful shutdown                    │
└─────────────────────────────────────────┘
```

## API Endpoints

### Health Check

```bash
curl http://127.0.0.1:8888/health
```

### Authentication

```bash
curl http://127.0.0.1:8888/api/v1/auth/me
```

### List DocTypes

```bash
curl http://127.0.0.1:8888/api/meta/doctypes
```

### Translations

```bash
curl http://127.0.0.1:8888/api/v1/Translation?locale=en
```

## Building for Production

### Build Frontend

```bash
cd frontend
pnpm build
```

This outputs to `dist/` which should be copied to `libs/framework-m/src/framework_m/static/`.

### Update Bundled Static

```bash
# After building frontend
cp -r frontend/dist/* libs/framework-m/src/framework_m/static/
```

### Package Python

```bash
cd libs/framework-m
uv build
```

The built wheel includes the static frontend assets.

## Development Workflow

### Initial Setup

1. **Install dependencies:**

   ```bash
   uv sync
   ```

2. **Install your app (if custom):**

   ```bash
   cd apps/myapp
   uv pip install -e .
   ```

3. **Start development server:**
   ```bash
   m dev
   ```

### Hot Reload in Action

**Backend Changes:**

```bash
# Edit any Python file
# WatchFiles detects changes
# Server automatically restarts
```

**Frontend Changes:**

```bash
# Edit any React/TS file
# Vite HMR updates instantly
# No page reload needed
```

### Git Noise and Local Builds

**Avoid running `pnpm build` locally during development.**

Running `pnpm build` updates the static assets in `libs/framework-m/src/framework_m/static/`. Since Vite includes hashes in filenames (e.g., `index-a1b2c3.js`), every build modifies tracked files, creating unnecessary git noise.

**Best Practice:**

- Use `m dev` for frontend development
- **Rely on CI/CD** for release builds
- Local `pnpm build` is only necessary to verify production artifacts

## Troubleshooting

### UI Downloads Instead of Rendering

If the browser downloads HTML files instead of rendering them, check:

1. **Content-Type Headers**: Ensure responses have `Content-Type: text/html; charset=utf-8`
2. **Route Handlers**: Verify return type is `Response` not union types
3. **Browser Cache**: Clear browser cache and hard reload (Ctrl+Shift+R)

### 404 Errors on Deep Links

If refreshing a page like `/desk/doctypes/User` gives 404:

1. **Check Route Handler**: Ensure `/desk/{path:path}` wildcard route exists
2. **SPA Fallback**: Verify the handler returns `index.html` for non-file paths
3. **Base Path**: Confirm React Router uses `basename="/desk"`

### API Calls Failing

If API calls return errors:

1. **CORS**: Check CORS configuration allows your origin
2. **Path Prefix**: Ensure API calls use `/api/*` prefix
3. **Server Running**: Verify `m prod` or `m dev` is running on the correct port

### Hot Reload Not Working

If changes aren't being detected:

**Backend:**

1. Check WatchFiles is enabled (look for "Started reloader process" in logs)
2. Verify file is in watched directory
3. Try `--reload` flag explicitly

**Frontend:**

1. Ensure `m dev` is running (not `m prod`)
2. Check Vite dev server is on port 5173
3. Verify WebSocket connection in browser DevTools

### DocTypes Not Appearing

If your custom DocTypes don't show in the sidebar:

1. **Install your app**: `cd apps/myapp && uv pip install -e .`
2. **Check entry points**: Verify `pyproject.toml` has `[project.entry-points."framework_m.apps"]`
3. **Restart server**: DocTypes are discovered at startup
4. **Check logs**: Look for "Discovered X DocType(s) from app 'myapp'"

## Quick Reference

| Command               | Use Case                  | Hot Reload          |
| --------------------- | ------------------------- | ------------------- |
| `m prod`              | Production, bundled UI    | Optional (--reload) |
| `m dev`               | Development, both servers | ✅ Always           |
| `m dev --studio`      | Development + Studio      | ✅ Always           |
| `m dev --no-frontend` | Backend only dev          | ✅ Backend only     |


Document: docs/developer/tenancy-modes.md

---
title: Tenancy Modes
sidebar_position: 6
---

# Multi-Tenancy: Architecture and Usage Guide

This guide covers Framework M's multi-tenancy architecture, including the two tenancy modes (Single and Multi-Tenant), tenant isolation patterns, and integration patterns for both backend and frontend.

---

## Table of Contents

1. [Overview](#overview)
2. [Mode A: Single Tenant (Indie)](#mode-a-single-tenant-indie)
3. [Mode B: Multi-Tenant (Enterprise)](#mode-b-multi-tenant-enterprise)
4. [Tenant Isolation Patterns](#tenant-isolation-patterns)
5. [Using TenantContext in Code](#using-tenantcontext-in-code)
6. [Frontend Multi-Tenancy](#frontend-multi-tenancy)
7. [Switching Adapters](#switching-adapters)

---

## Overview

Framework M provides a flexible multi-tenancy system with two operational modes:

- **Mode A (Single Tenant / Indie)**: Uses `ImplicitTenantAdapter` with a fixed "default" tenant. No database lookups, perfect for standalone deployments.
- **Mode B (Multi-Tenant / Enterprise)**: Uses `HeaderTenantAdapter` to extract tenant information from gateway headers. Requires an API gateway (e.g., Keycloak, Auth0, Citadel IAM) for tenant resolution.

Both modes implement the `TenantProtocol` interface, allowing seamless switching between deployment configurations.

### TenantProtocol Interface

```python
from typing import Protocol, Any

class TenantProtocol(Protocol):
    """Protocol for tenant resolution and attribute retrieval."""

    def get_current_tenant(self) -> str:
        """Get the current tenant ID from request context."""
        ...

    def get_tenant_attributes(self, tenant_id: str) -> dict[str, Any]:
        """Get attributes for a specific tenant."""
        ...
```

### TenantContext

All tenant adapters return a `TenantContext` object:

```python
from framework_m.core.interfaces.tenant import TenantContext

ctx = TenantContext(
    tenant_id="acme-corp",
    attributes={
        "plan": "enterprise",
        "features": ["advanced_reports", "api_access"],
        "max_users": 100
    },
    is_default=False
)
```

---

## Mode A: Single Tenant (Indie)

### Overview

`ImplicitTenantAdapter` is designed for standalone deployments where you have a single tenant. It provides:

- **No database lookups**: Tenant is hardcoded or configured via `framework_config.toml`
- **Unlimited features**: Default attributes give full access to all features
- **Zero overhead**: No runtime tenant resolution cost
- **Simple deployment**: Perfect for indie apps, MVPs, and single-customer deployments

### Configuration

Configure single-tenant mode in `framework_config.toml`:

```toml
[tenancy]
mode = "single"
default_tenant_id = "default"  # Or your organization name
```

### Usage

#### Basic Usage

```python
from framework_m.adapters.tenant import ImplicitTenantAdapter

# Use default configuration
adapter = ImplicitTenantAdapter()
tenant_id = adapter.get_current_tenant()  # "default"
attrs = adapter.get_tenant_attributes("default")
# {"plan": "unlimited", "features": "*"}
```

#### Custom Tenant ID

```python
# Override tenant ID (useful for branding)
adapter = ImplicitTenantAdapter(tenant_id="acme-corp")
tenant_id = adapter.get_current_tenant()  # "acme-corp"
```

#### Custom Attributes

```python
# Override default attributes
adapter = ImplicitTenantAdapter(
    tenant_id="my-app",
    attributes={
        "plan": "pro",
        "features": ["reports", "api", "webhooks"],
        "max_storage_gb": 100
    }
)
```

#### Getting Full Context

```python
adapter = ImplicitTenantAdapter()
ctx = adapter.get_context()

print(ctx.tenant_id)       # "default"
print(ctx.attributes)      # {"plan": "unlimited", "features": "*"}
print(ctx.is_default)      # True
```

### When to Use Mode A

✅ **Use ImplicitTenantAdapter when:**

- Building an indie app or MVP
- Single customer deployment
- No need for tenant isolation
- Want to minimize complexity
- Running on-premise for one organization
- Prototype or demo environments

❌ **Don't use when:**

- You need to serve multiple customers
- Tenant isolation is required
- You plan to scale to multi-tenancy later (start with Mode B)

### Implementation Details

```python
# frameworks-m/src/framework_m/adapters/tenant.py
class ImplicitTenantAdapter:
    """Single-tenant mode adapter."""

    def __init__(
        self,
        tenant_id: str | None = None,
        attributes: dict[str, Any] | None = None,
    ) -> None:
        self._tenant_id = tenant_id or get_default_tenant_id()
        self._attributes = attributes or {
            "plan": "unlimited",
            "features": "*",
        }

    def get_current_tenant(self) -> str:
        return self._tenant_id

    def get_tenant_attributes(self, tenant_id: str) -> dict[str, Any]:
        return self._attributes.copy()

    def get_context(self) -> TenantContext:
        return TenantContext(
            tenant_id=self._tenant_id,
            attributes=self._attributes.copy(),
            is_default=True,
        )
```

---

## Mode B: Multi-Tenant (Enterprise)

### Overview

`HeaderTenantAdapter` is designed for multi-tenant SaaS deployments where an API gateway handles authentication and populates tenant information in HTTP headers.

**Key Features:**

- **Gateway integration**: Works with Keycloak, Auth0, AWS ALB, Citadel IAM
- **Zero trust**: Framework never authenticates users, only reads headers
- **Feature flags**: Per-tenant attributes control feature availability
- **Dynamic attributes**: Plan, features, limits passed in headers
- **Security**: Gateway ensures headers can't be spoofed

### Architecture

```
┌─────────┐      ┌──────────────┐      ┌────────────────┐
│ Browser │─────▶│ API Gateway  │─────▶│  Framework M   │
│         │      │ (Keycloak)   │      │   (Backend)    │
└─────────┘      └──────────────┘      └────────────────┘
                       │
                       ├─ Authenticate user
                       ├─ Resolve tenant from JWT
                       ├─ Fetch tenant attributes
                       └─ Add headers:
                           - X-Tenant-ID: acme-corp
                           - X-Tenant-Attributes: {...}
```

### Configuration

Configure multi-tenant mode in `framework_config.toml`:

```toml
[tenancy]
mode = "multi"
default_tenant_id = "default"  # Fallback if header missing
```

### Gateway Setup

#### Expected Headers

The gateway must populate these headers:

```http
X-Tenant-ID: acme-corp
X-Tenant-Attributes: {"plan": "enterprise", "features": ["reports", "api"], "max_users": 100}
```

#### Example: Keycloak Configuration

1. **Add Tenant Claim to JWT:**

```json
// Keycloak Mapper: Add "tenant_id" claim
{
  "tenant_id": "acme-corp",
  "tenant_attributes": {
    "plan": "enterprise",
    "features": ["advanced_reports", "api_access"],
    "max_users": 100
  }
}
```

2. **Configure Gateway to Extract Headers:**

```nginx
# Nginx config (or use Keycloak Gatekeeper/Louketo)
location /api/ {
    proxy_set_header X-Tenant-ID $jwt_claim_tenant_id;
    proxy_set_header X-Tenant-Attributes $jwt_claim_tenant_attributes;
    proxy_pass http://framework-m-backend:8000;
}
```

#### Example: Citadel IAM Integration

Citadel IAM can be configured to automatically populate tenant headers:

```yaml
# citadel-config.yaml
tenant_resolution:
  source: jwt_claim
  claim_name: tenant_id
  attributes_claim: tenant_attributes
  headers:
    tenant_id: X-Tenant-ID
    attributes: X-Tenant-Attributes
```

### Usage

#### Basic Usage

```python
from framework_m.adapters.tenant import HeaderTenantAdapter

# In a Litestar dependency provider
headers = {
    "x-tenant-id": "acme-corp",
    "x-tenant-attributes": '{"plan": "enterprise", "features": ["reports"]}',
}

adapter = HeaderTenantAdapter(headers=headers)
tenant_id = adapter.get_current_tenant()  # "acme-corp"
```

#### Custom Header Names

```python
# Use custom header names (e.g., for legacy systems)
adapter = HeaderTenantAdapter(
    headers=headers,
    tenant_header="x-organization-id",
    attributes_header="x-organization-data"
)
```

#### Fallback Behavior

```python
# If X-Tenant-ID header is missing, falls back to default
headers = {}  # No tenant header
adapter = HeaderTenantAdapter(headers=headers)
tenant_id = adapter.get_current_tenant()  # "default"
```

#### Getting Full Context

```python
headers = {
    "x-tenant-id": "acme-corp",
    "x-tenant-attributes": '{"plan": "enterprise", "max_users": 100}',
}

adapter = HeaderTenantAdapter(headers=headers)
ctx = adapter.get_context()

print(ctx.tenant_id)       # "acme-corp"
print(ctx.attributes)      # {"plan": "enterprise", "max_users": 100}
print(ctx.is_default)      # False
```

### When to Use Mode B

✅ **Use HeaderTenantAdapter when:**

- Building a multi-tenant SaaS product
- Serving multiple customers from one deployment
- Need tenant data isolation
- Using an API gateway (Keycloak, Auth0, etc.)
- Require feature flags per tenant
- Different plans/tiers for customers

❌ **Don't use when:**

- Single customer deployment
- No API gateway infrastructure
- Can't guarantee header security

### Implementation Details

```python
# framework-m/src/framework_m/adapters/tenant.py
class HeaderTenantAdapter:
    """Multi-tenant mode adapter."""

    def __init__(
        self,
        headers: Mapping[str, str],
        tenant_header: str = "x-tenant-id",
        attributes_header: str = "x-tenant-attributes",
        default_tenant_id: str = "default",
    ) -> None:
        self._headers = headers
        self._tenant_header = tenant_header
        self._attributes_header = attributes_header
        self._default_tenant_id = default_tenant_id

    def get_current_tenant(self) -> str:
        return self._headers.get(self._tenant_header, self._default_tenant_id)

    def get_tenant_attributes(self, tenant_id: str) -> dict[str, Any]:
        attrs_json = self._headers.get(self._attributes_header, "")
        if not attrs_json:
            return {}

        try:
            result = json.loads(attrs_json)
            return result if isinstance(result, dict) else {}
        except json.JSONDecodeError:
            return {}

    def get_context(self) -> TenantContext:
        tenant_id = self.get_current_tenant()
        return TenantContext(
            tenant_id=tenant_id,
            attributes=self.get_tenant_attributes(tenant_id),
            is_default=(tenant_id == self._default_tenant_id),
        )
```

---

## Tenant Isolation Patterns

Framework M supports three tenant isolation strategies. Choose based on your security, compliance, and scalability requirements.

### Pattern 1: Logical Isolation (Recommended)

**Description:** All tenants share the same database and tables. Row-Level Security (RLS) filters ensure tenants only see their own data.

**Implementation:**

```python
from framework_m.core.domain.base_doctype import BaseDocType
from pydantic import Field

class Invoice(BaseDocType):
    """Invoice DocType with tenant isolation."""

    tenant_id: str = Field(max_length=100)
    customer_name: str = Field(max_length=200)
    amount: float

    class Meta:
        apply_rls: bool = True
        rls_field: str = "tenant_id"  # Filter by this field
```

**How RLS Works:**

1. `GenericRepository` automatically applies RLS filters:

```python
# User queries for invoices
invoices = await repo.list_entities(session, filters=user_filters)

# Framework automatically adds: WHERE tenant_id = 'acme-corp'
# User only sees their tenant's invoices
```

2. Admins can bypass RLS:

```python
# Admin user with is_system_user=True bypasses RLS
admin_invoices = await repo.list_entities(session, filters=[], user=admin_user)
# Admin sees all invoices across all tenants
```

**Pros:**

- ✅ Simple to implement and manage
- ✅ Cost-effective (single database)
- ✅ Easy backups and maintenance
- ✅ Dynamic tenant creation (no schema changes)
- ✅ Framework handles filtering automatically

**Cons:**

- ❌ "Noisy neighbor" problem (one tenant can impact others)
- ❌ Cannot customize schema per tenant
- ❌ Compliance concerns for highly sensitive data

**Best For:**

- Most SaaS applications
- B2B products with standard features
- Startups and growing companies
- When tenant data has similar structure

### Pattern 2: Database-per-Tenant

**Description:** Each tenant gets a separate database. Complete physical isolation.

**Implementation:**

```python
# framework_config.toml
[database]
url = "postgresql://user:pass@localhost/framework_default"

# Tenant-specific database bindings
[database.tenant_binds]
"acme-corp" = "postgresql://user:pass@localhost/framework_acme"
"globex" = "postgresql://user:pass@localhost/framework_globex"
```

```python
# In repository or session factory
def get_database_url(tenant_id: str) -> str:
    """Get database URL for tenant."""
    from framework_m.cli.config import load_config

    config = load_config()
    tenant_binds = config.get("database", {}).get("tenant_binds", {})

    # Check for tenant-specific database
    if tenant_id in tenant_binds:
        return tenant_binds[tenant_id]

    # Fallback to default database
    return config["database"]["url"]
```

**Pros:**

- ✅ Complete data isolation
- ✅ Can customize schema per tenant
- ✅ Easy to backup individual tenants
- ✅ No RLS overhead
- ✅ Can move tenant data to different servers

**Cons:**

- ❌ High operational complexity
- ❌ More expensive (multiple databases)
- ❌ Schema migrations across all databases
- ❌ Connection pool management
- ❌ Cross-tenant queries impossible

**Best For:**

- Enterprise customers demanding isolation
- Regulated industries (healthcare, finance)
- Customers with custom schema needs
- When billing per database is acceptable

### Pattern 3: Schema-per-Tenant (PostgreSQL)

**Description:** All tenants in one database, but each gets a separate PostgreSQL schema.

**Implementation:**

```python
# Set PostgreSQL search_path per tenant
from sqlalchemy import text

async def set_tenant_schema(session, tenant_id: str):
    """Set PostgreSQL schema for tenant."""
    schema_name = f"tenant_{tenant_id.replace('-', '_')}"
    await session.execute(text(f"SET search_path TO {schema_name}, public"))
```

```python
# In session provider
from litestar import Request

async def provide_session(request: Request):
    """Provide database session with tenant schema."""
    tenant_ctx = request.state.tenant

    async with get_session() as session:
        # Set schema based on tenant
        await set_tenant_schema(session, tenant_ctx.tenant_id)
        yield session
```

**Pros:**

- ✅ Good isolation (separate schemas)
- ✅ Single database (easier management)
- ✅ Can customize schema per tenant
- ✅ Better than logical isolation for compliance

**Cons:**

- ❌ PostgreSQL-specific
- ❌ Schema migrations across all schemas
- ❌ Still shares database resources
- ❌ Requires careful connection management

**Best For:**

- PostgreSQL-only deployments
- Mid-tier isolation needs
- When database-per-tenant is too expensive
- Regulated data with moderate isolation needs

### Comparison Table

| Feature               | Logical Isolation | Database-per-Tenant | Schema-per-Tenant |
| --------------------- | ----------------- | ------------------- | ----------------- |
| **Setup Complexity**  | Low               | High                | Medium            |
| **Operational Cost**  | Low               | High                | Medium            |
| **Data Isolation**    | Application-level | Physical            | Schema-level      |
| **Custom Schema**     | ❌ No             | ✅ Yes              | ✅ Yes            |
| **Scalability**       | ✅ Excellent      | ⚠ Limited          | ⚠ Good           |
| **Compliance**        | ⚠ Moderate       | ✅ Excellent        | ✅ Good           |
| **Framework Support** | ✅ Built-in       | ⚠ Manual           | ⚠ Manual         |

---

## Using TenantContext in Code

### Dependency Injection Pattern

Framework M provides `TenantContext` via dependency injection:

```python
from litestar import get, post, Request
from framework_m.core.interfaces.tenant import TenantContext

@get("/api/dashboard")
async def get_dashboard(request: Request) -> dict:
    """Get tenant-specific dashboard data."""
    tenant_ctx: TenantContext = request.state.tenant

    return {
        "tenant_id": tenant_ctx.tenant_id,
        "plan": tenant_ctx.attributes.get("plan", "free"),
        "features": tenant_ctx.attributes.get("features", []),
    }
```

### Feature Flags from Tenant Attributes

```python
@post("/api/reports/advanced")
async def generate_advanced_report(request: Request) -> dict:
    """Generate advanced report (enterprise feature)."""
    tenant_ctx: TenantContext = request.state.tenant
    features = tenant_ctx.attributes.get("features", [])

    if "advanced_reports" not in features:
        raise HTTPException(
            status_code=403,
            detail="Advanced reports not available in your plan"
        )

    # Generate report...
    return {"status": "success"}
```

### Tenant-Aware Queries

When using `GenericRepository`, RLS filters are applied automatically:

```python
from framework_m.adapters.db.generic_repository import GenericRepository
from framework_m.core.doctypes.invoice import Invoice

@get("/api/invoices")
async def list_invoices(
    request: Request,
    user: UserContext,
    session: AsyncSession
) -> list[dict]:
    """List invoices for current tenant."""
    repo = GenericRepository(Invoice)

    # RLS automatically filters: WHERE tenant_id = 'acme-corp'
    invoices = await repo.list_entities(session, user=user)

    return [inv.model_dump() for inv in invoices]
```

### Cross-Tenant Queries (Admin Only)

```python
@get("/api/admin/all-invoices")
async def list_all_invoices(
    user: UserContext,
    session: AsyncSession
) -> list[dict]:
    """List invoices across all tenants (admin only)."""
    if not user.is_system_user:
        raise HTTPException(status_code=403, detail="Admin access required")

    repo = GenericRepository(Invoice)

    # Admin bypasses RLS, sees all tenants
    all_invoices = await repo.list_entities(session, user=user)

    return [inv.model_dump() for inv in all_invoices]
```

### Manual Tenant Filtering

If you need manual control:

```python
from framework_m.core.interfaces.repository import FilterSpec, FilterOperator

@get("/api/custom-query")
async def custom_query(
    request: Request,
    session: AsyncSession
) -> list[dict]:
    """Custom query with manual tenant filtering."""
    tenant_ctx: TenantContext = request.state.tenant
    repo = GenericRepository(Invoice)

    # Manual tenant filter
    filters = [
        FilterSpec(
            field="tenant_id",
            operator=FilterOperator.EQ,
            value=tenant_ctx.tenant_id
        )
    ]

    invoices = await repo.list_entities(session, filters=filters)
    return [inv.model_dump() for inv in invoices]
```

---

## Frontend Multi-Tenancy

### Extracting Tenant from JWT

If your frontend receives a JWT token, extract tenant information:

```typescript
// src/utils/auth.ts
interface JWTPayload {
  sub: string;
  tenant_id: string;
  tenant_attributes: {
    plan: string;
    features: string[];
    max_users: number;
  };
}

export function decodeToken(token: string): JWTPayload {
  const base64Url = token.split(".")[1];
  const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
  const jsonPayload = decodeURIComponent(
    atob(base64)
      .split("")
      .map(c => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
      .join(""),
  );
  return JSON.parse(jsonPayload);
}
```

### React Context for Tenant

```typescript
// src/contexts/TenantContext.tsx
import { createContext, useContext, ReactNode } from 'react';

interface TenantAttributes {
  plan: string;
  features: string[];
  max_users?: number;
}

interface TenantContextType {
  tenantId: string;
  attributes: TenantAttributes;
  hasFeature: (feature: string) => boolean;
}

const TenantContext = createContext<TenantContextType | null>(null);

export function TenantProvider({ children }: { children: ReactNode }) {
  // Extract from JWT or API response
  const token = localStorage.getItem('access_token');
  const payload = token ? decodeToken(token) : null;

  const value: TenantContextType = {
    tenantId: payload?.tenant_id || 'default',
    attributes: payload?.tenant_attributes || { plan: 'free', features: [] },
    hasFeature: (feature: string) =>
      payload?.tenant_attributes.features.includes(feature) || false,
  };

  return (
    <TenantContext.Provider value={value}>
      {children}
    </TenantContext.Provider>
  );
}

export function useTenant() {
  const context = useContext(TenantContext);
  if (!context) {
    throw new Error('useTenant must be used within TenantProvider');
  }
  return context;
}
```

### Feature-Gated Components

```typescript
// src/components/FeatureGate.tsx
import { useTenant } from '../contexts/TenantContext';

interface FeatureGateProps {
  feature: string;
  children: ReactNode;
  fallback?: ReactNode;
}

export function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
  const { hasFeature } = useTenant();

  if (!hasFeature(feature)) {
    return fallback || <div>This feature is not available in your plan.</div>;
  }

  return <>{children}</>;
}
```

```typescript
// Usage in components
import { FeatureGate } from './components/FeatureGate';

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      <FeatureGate feature="advanced_reports">
        <AdvancedReports />
      </FeatureGate>

      <FeatureGate feature="api_access">
        <APISettings />
      </FeatureGate>
    </div>
  );
}
```

### Tenant-Specific Branding

```typescript
// src/hooks/useTenantBranding.ts
import { useTenant } from "../contexts/TenantContext";

interface BrandingConfig {
  logo: string;
  primaryColor: string;
  companyName: string;
}

export function useTenantBranding(): BrandingConfig {
  const { attributes } = useTenant();

  return {
    logo: attributes.logo_url || "/default-logo.svg",
    primaryColor: attributes.primary_color || "#3b82f6",
    companyName: attributes.company_name || "Framework M",
  };
}
```

```typescript
// Usage in Navbar
function Navbar() {
  const branding = useTenantBranding();

  return (
    <nav style={{ backgroundColor: branding.primaryColor }}>
      <img src={branding.logo} alt={branding.companyName} />
      <span>{branding.companyName}</span>
    </nav>
  );
}
```

### Tenant Selector (Admin UI)

For admin users managing multiple tenants:

```typescript
// src/components/TenantSelector.tsx
import { useState, useEffect } from 'react';

export function TenantSelector() {
  const [tenants, setTenants] = useState<string[]>([]);
  const [currentTenant, setCurrentTenant] = useState<string>('');

  useEffect(() => {
    // Fetch available tenants (admin only)
    fetch('/api/admin/tenants')
      .then(res => res.json())
      .then(data => setTenants(data.tenants));
  }, []);

  const switchTenant = (tenantId: string) => {
    // Store selected tenant in localStorage
    localStorage.setItem('admin_selected_tenant', tenantId);
    setCurrentTenant(tenantId);

    // Reload data for new tenant
    window.location.reload();
  };

  return (
    <select value={currentTenant} onChange={(e) => switchTenant(e.target.value)}>
      {tenants.map(tenant => (
        <option key={tenant} value={tenant}>{tenant}</option>
      ))}
    </select>
  );
}
```

---

## Switching Adapters

### Configuration-Based Selection

The simplest approach is to use `create_tenant_adapter_from_headers()`, which automatically selects the adapter based on `framework_config.toml`:

```python
from framework_m.adapters.tenant import create_tenant_adapter_from_headers

# In Litestar dependency provider
async def provide_tenant_adapter(request: Request):
    """Provide tenant adapter based on configuration."""
    adapter = create_tenant_adapter_from_headers(
        headers=request.headers if request else None
    )
    return adapter
```

### Factory Logic

```python
# framework-m/src/framework_m/adapters/tenant.py
def create_tenant_adapter_from_headers(
    headers: Mapping[str, str] | None = None,
) -> ImplicitTenantAdapter | HeaderTenantAdapter:
    """Create appropriate tenant adapter based on config."""
    from framework_m.core.interfaces.tenant import is_multi_tenant

    # If multi-tenant mode AND headers provided, use HeaderTenantAdapter
    if is_multi_tenant() and headers is not None:
        return HeaderTenantAdapter(headers=headers)

    # Otherwise use ImplicitTenantAdapter (single-tenant mode)
    return ImplicitTenantAdapter()
```

### Environment Variable

```bash
# .env or framework_config.toml
TENANT_MODE=multi  # or "single"
```

```toml
# framework_config.toml
[tenancy]
mode = "multi"  # Uses HeaderTenantAdapter
# OR
mode = "single"  # Uses ImplicitTenantAdapter
```

### Custom Adapter via Entrypoint

For advanced use cases, register a custom tenant adapter:

```python
# my_app/custom_tenant_adapter.py
from framework_m.core.interfaces.tenant import TenantProtocol, TenantContext

class DatabaseTenantAdapter:
    """Load tenant from database instead of headers."""

    def __init__(self, request_id: str):
        self.request_id = request_id

    def get_current_tenant(self) -> str:
        # Look up tenant from database using request_id
        return lookup_tenant_from_db(self.request_id)

    def get_tenant_attributes(self, tenant_id: str) -> dict:
        # Fetch attributes from database
        return fetch_tenant_attributes(tenant_id)
```

Register via entrypoint:

```python
# setup.py or pyproject.toml
[project.entry-points."framework_m.tenant_adapters"]
database = "my_app.custom_tenant_adapter:DatabaseTenantAdapter"
```

### Testing with MockTenantAdapter

```python
# tests/conftest.py
import pytest
from framework_m.core.interfaces.tenant import TenantContext

class MockTenantAdapter:
    """Mock adapter for testing."""

    def __init__(self, tenant_id: str = "test-tenant"):
        self.tenant_id = tenant_id

    def get_current_tenant(self) -> str:
        return self.tenant_id

    def get_tenant_attributes(self, tenant_id: str) -> dict:
        return {
            "plan": "enterprise",
            "features": ["all"],
        }

    def get_context(self) -> TenantContext:
        return TenantContext(
            tenant_id=self.tenant_id,
            attributes=self.get_tenant_attributes(self.tenant_id),
            is_default=False,
        )

@pytest.fixture
def mock_tenant_adapter():
    """Provide mock tenant adapter for tests."""
    return MockTenantAdapter(tenant_id="test-tenant")
```

---

## Best Practices

### 1. Always Use TenantContext from Request State

```python
# ✅ Good: Use request.state.tenant
async def my_endpoint(request: Request):
    tenant_ctx = request.state.tenant
    tenant_id = tenant_ctx.tenant_id

# ❌ Bad: Don't try to resolve tenant manually
async def my_endpoint(request: Request):
    tenant_id = request.headers.get("x-tenant-id")  # Don't do this!
```

### 2. Rely on RLS for Data Isolation

```python
# ✅ Good: Let framework handle tenant filtering
invoices = await repo.list_entities(session, user=user)

# ❌ Bad: Manual filtering (fragile and error-prone)
invoices = await session.execute(
    select(Invoice).where(Invoice.tenant_id == tenant_id)
)
```

### 3. Test Both Tenancy Modes

```python
# Test single-tenant mode
@pytest.mark.parametrize("tenancy_mode", ["single"])
def test_single_tenant(tenancy_mode):
    adapter = ImplicitTenantAdapter()
    assert adapter.get_current_tenant() == "default"

# Test multi-tenant mode
@pytest.mark.parametrize("tenancy_mode", ["multi"])
def test_multi_tenant(tenancy_mode):
    headers = {"x-tenant-id": "acme"}
    adapter = HeaderTenantAdapter(headers=headers)
    assert adapter.get_current_tenant() == "acme"
```

### 4. Document Tenant Attributes Schema

```python
# Define expected tenant attributes in documentation
"""
Tenant Attributes Schema:
{
    "plan": str,              # "free" | "pro" | "enterprise"
    "features": list[str],    # ["reports", "api", "webhooks"]
    "max_users": int,         # Maximum users allowed
    "max_storage_gb": int,    # Storage quota in GB
    "custom_domain": str,     # Optional custom domain
}
"""
```

### 5. Handle Missing Tenant Gracefully

```python
async def get_tenant_safely(request: Request) -> TenantContext:
    """Get tenant context with fallback."""
    try:
        return request.state.tenant
    except AttributeError:
        # Fallback for public endpoints
        return TenantContext(
            tenant_id="public",
            attributes={"plan": "free", "features": []},
            is_default=True,
        )
```

---

## Migration Path

### From Single-Tenant to Multi-Tenant

If you start with Mode A and need to migrate to Mode B:

1. **Add tenant_id column to all DocTypes:**

```python
# Add to all DocTypes that need isolation
class Invoice(BaseDocType):
    tenant_id: str = Field(max_length=100, default="default")

    class Meta:
        apply_rls = True
        rls_field = "tenant_id"
```

2. **Generate migration:**

```bash
m migrate generate "add_tenant_id_to_all_tables"
```

3. **Update existing data:**

```python
# In migration file
def upgrade():
    # Set all existing rows to "default" tenant
    op.execute("UPDATE invoice SET tenant_id = 'default' WHERE tenant_id IS NULL")
```

4. **Switch configuration:**

```toml
[tenancy]
mode = "multi"  # Switch from "single" to "multi"
```

5. **Deploy gateway:**

Set up Keycloak/Auth0 to populate `X-Tenant-ID` headers.

6. **Test thoroughly:**

Ensure existing "default" tenant users still have access.

---

## Troubleshooting

### Tenant Not Resolved

**Symptom:** `request.state.tenant` is None or missing

**Solution:**

1. Check `framework_config.toml` has `[tenancy]` section
2. Verify `mode = "multi"` if using HeaderTenantAdapter
3. Ensure gateway is populating `X-Tenant-ID` header
4. Check middleware order (tenant middleware must run early)

### Users See Wrong Tenant's Data

**Symptom:** Users see data from other tenants

**Solution:**

1. Verify `apply_rls = True` in DocType Meta
2. Check `rls_field` points to correct column (usually `tenant_id`)
3. Ensure queries use `GenericRepository` (not raw SQLAlchemy)
4. Verify user context has correct tenant_id

### Feature Flags Not Working

**Symptom:** Features available when they shouldn't be

**Solution:**

1. Check `X-Tenant-Attributes` header is populated
2. Verify JSON is valid in attributes header
3. Ensure frontend checks `tenant.attributes.features` array
4. Log `tenant_ctx.attributes` to debug

### Performance Issues

**Symptom:** Slow queries in multi-tenant mode

**Solution:**

1. Add database index on `tenant_id` column:

```sql
CREATE INDEX idx_invoice_tenant_id ON invoice(tenant_id);
```

2. Use database-per-tenant for large tenants
3. Consider read replicas for heavy tenants
4. Cache tenant attributes in Redis

---

## Summary

Framework M's multi-tenancy system provides:

- **Two modes**: Single-tenant (ImplicitTenantAdapter) and multi-tenant (HeaderTenantAdapter)
- **Three isolation patterns**: Logical (RLS), database-per-tenant, schema-per-tenant
- **Automatic RLS**: Framework handles tenant filtering transparently
- **Feature flags**: Per-tenant attributes control feature availability
- **Easy testing**: Mock adapters for unit tests
- **Migration path**: Start single, scale to multi-tenant

Choose the mode and isolation pattern that fits your requirements, and let Framework M handle the complexity.


Document: docs/developer/theming.md

---
title: Theming
sidebar_position: 8
---

# Theming in Framework M

This guide covers the theming system in Framework M's frontend, including light/dark mode, custom color schemes, and user preference management.

---

## Table of Contents

1. [Overview](#overview)
2. [Light/Dark Mode](#lightdark-mode)
3. [Using the Theme System](#using-the-theme-system)
4. [Custom Color Schemes](#custom-color-schemes)
5. [CSS Variables Reference](#css-variables-reference)
6. [Best Practices](#best-practices)

---

## Overview

Framework M includes a built-in theming system that provides:

- **Light/Dark Mode**: Automatic theme switching with user preference persistence
- **System Theme Detection**: Respects OS-level dark mode preferences
- **LocalStorage Persistence**: User preferences saved across sessions
- **CSS Variables**: Easy customization of all colors and styles
- **React Context**: Theme state accessible throughout the app

### Features

✅ **Automatic Detection**: Detects system theme preference on first load
✅ **Toggle Component**: Icon-based theme switcher in navbar
✅ **Smooth Transitions**: CSS transitions for theme changes
✅ **Persistent**: Saves preference in localStorage
✅ **Accessible**: Keyboard navigation and ARIA labels

---

## Light/Dark Mode

### How It Works

The theme system uses a React Context (`ThemeContext`) to manage the current theme state and CSS variables for styling.

**Theme Resolution Order:**

1. **LocalStorage**: Checks for saved user preference (`framework-m-theme`)
2. **System Preference**: Uses `prefers-color-scheme` media query
3. **Default**: Falls back to `light` theme

**CSS Implementation:**

The `dark` class is applied to the `<html>` element when dark mode is active:

```html
<!-- Light mode -->
<html>
  <!-- Dark mode -->
  <html class="dark"></html>
</html>
```

All color variables are defined in `:root` for light mode and `.dark` for dark mode in `App.css`.

---

## Using the Theme System

### Basic Usage

Import and use the `useTheme` hook in any component:

```typescript
import { useTheme } from '../contexts/ThemeContext';

function MyComponent() {
  const { theme, setTheme, toggleTheme } = useTheme();

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
      <button onClick={() => setTheme('dark')}>Force Dark</button>
      <button onClick={() => setTheme('light')}>Force Light</button>
    </div>
  );
}
```

### ThemeContext API

```typescript
interface ThemeContextType {
  theme: Theme; // Current theme: "light" | "dark"
  setTheme: (theme: Theme) => void; // Set theme explicitly
  toggleTheme: () => void; // Toggle between light and dark
}
```

### ThemeProvider Setup

The `ThemeProvider` is already configured in `App.tsx`:

```typescript
// src/App.tsx
import { ThemeProvider } from './contexts/ThemeContext';

export const App = () => (
  <BrowserRouter>
    <ThemeProvider>
      <AppContent />
    </ThemeProvider>
  </BrowserRouter>
);
```

### ThemeToggle Component

The navbar includes a `ThemeToggle` button:

```typescript
// src/layout/Navbar.tsx
import { ThemeToggle } from '../components/ThemeToggle';

export function Navbar() {
  return (
    <header>
      {/* Other navbar items */}
      <ThemeToggle />
    </header>
  );
}
```

**Features:**

- Sun icon in dark mode (click to switch to light)
- Moon icon in light mode (click to switch to dark)
- Tooltip showing current theme
- Keyboard accessible
- Smooth hover/active states

---

## Custom Color Schemes

### Using CSS Variables

All colors are defined as CSS variables. To customize, override them:

```css
/* Custom color scheme */
:root {
  --color-primary: #8b5cf6; /* Purple primary */
  --color-primary-dark: #7c3aed;
  --color-success: #10b981; /* Emerald green */
}

.dark {
  --color-primary: #a78bfa; /* Lighter purple for dark mode */
  --color-primary-dark: #8b5cf6;
}
```

### Per-Component Styling

Use CSS variables in your components:

```typescript
function CustomButton() {
  return (
    <button
      style={{
        background: 'var(--color-primary)',
        color: 'white',
        border: 'none',
        padding: '0.5rem 1rem',
        borderRadius: 'var(--radius-md)',
      }}
    >
      Click Me
    </button>
  );
}
```

### Theme-Aware Components

Create components that respond to theme changes:

```typescript
function ThemedCard({ children }: { children: React.ReactNode }) {
  const { theme } = useTheme();

  return (
    <div
      style={{
        background: 'var(--color-bg)',
        border: '1px solid var(--color-border)',
        borderRadius: 'var(--radius-lg)',
        padding: 'var(--spacing-lg)',
        boxShadow: theme === 'dark' ? 'var(--shadow-md)' : 'var(--shadow-sm)',
      }}
    >
      {children}
    </div>
  );
}
```

---

## CSS Variables Reference

### Light Mode Colors (`:root`)

```css
--color-bg: #ffffff; /* Main background */
--color-bg-secondary: #f8fafc; /* Secondary background */
--color-bg-tertiary: #f1f5f9; /* Tertiary background */
--color-border: #e2e8f0; /* Border color */
--color-text: #0f172a; /* Primary text */
--color-text-secondary: #64748b; /* Secondary text */
--color-text-muted: #94a3b8; /* Muted text */
--color-primary: #0ea5e9; /* Primary brand color */
--color-primary-dark: #0284c7; /* Primary hover/active */
--color-sidebar: #1e293b; /* Sidebar background */
--color-sidebar-text: #e2e8f0; /* Sidebar text */
--color-sidebar-hover: #334155; /* Sidebar hover state */
--color-success: #22c55e; /* Success state */
--color-warning: #f59e0b; /* Warning state */
--color-error: #ef4444; /* Error state */
```

### Dark Mode Colors (`.dark`)

```css
--color-bg: #0f172a; /* Main background (dark) */
--color-bg-secondary: #1e293b; /* Secondary background (dark) */
--color-bg-tertiary: #334155; /* Tertiary background (dark) */
--color-border: #334155; /* Border color (dark) */
--color-text: #f1f5f9; /* Primary text (light) */
--color-text-secondary: #cbd5e1; /* Secondary text (light) */
--color-text-muted: #94a3b8; /* Muted text */
--color-primary: #38bdf8; /* Primary brand (lighter) */
--color-primary-dark: #0ea5e9; /* Primary hover/active */
--color-sidebar: #020617; /* Sidebar background (darker) */
--color-sidebar-text: #e2e8f0; /* Sidebar text */
--color-sidebar-hover: #1e293b; /* Sidebar hover state */
```

### Spacing

```css
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
```

### Border Radius

```css
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
```

### Shadows

```css
/* Light mode */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);

/* Dark mode */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
```

### Typography

```css
--font-sans:
  "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
```

---

## Best Practices

### 1. Always Use CSS Variables

```typescript
// ✅ Good: Uses CSS variable
<div style={{ background: 'var(--color-bg)' }} />

// ❌ Bad: Hardcoded color
<div style={{ background: '#ffffff' }} />
```

### 2. Test in Both Themes

Always test your components in both light and dark mode:

```typescript
// Use the theme toggle in navbar to switch modes
// Ensure text is readable and colors have good contrast
```

### 3. Use Semantic Color Names

```css
/* ✅ Good: Semantic names */
--color-success: #22c55e;
--color-error: #ef4444;

/* ❌ Bad: Color-based names */
--color-green: #22c55e;
--color-red: #ef4444;
```

### 4. Respect User Preference

```typescript
// ✅ Good: Let users choose
const { theme, toggleTheme } = useTheme();

// ❌ Bad: Force a theme
document.documentElement.classList.add("dark"); // Don't do this
```

### 5. Provide Visual Feedback

```css
.theme-toggle {
  transition: all 0.2s ease;
}

.theme-toggle:hover {
  background: var(--hover-bg);
}

.theme-toggle:active {
  transform: scale(0.95);
}
```

### 6. Consider Accessibility

```typescript
// Include ARIA labels
<button
  onClick={toggleTheme}
  aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
  {/* Icon */}
</button>
```

---

## Advanced Usage

### Custom Theme Provider

Create a custom theme provider for additional themes:

```typescript
// src/contexts/CustomThemeContext.tsx
type ExtendedTheme = 'light' | 'dark' | 'blue' | 'purple';

export function CustomThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<ExtendedTheme>('light');

  useEffect(() => {
    // Apply custom theme classes
    document.documentElement.className = theme;
  }, [theme]);

  return (
    <CustomThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </CustomThemeContext.Provider>
  );
}
```

```css
/* Define custom themes */
.blue {
  --color-primary: #3b82f6;
  --color-bg: #eff6ff;
}

.purple {
  --color-primary: #8b5cf6;
  --color-bg: #faf5ff;
}
```

### Syncing with Backend

Store user theme preference in backend:

```typescript
import { useUpdate } from "@refinedev/core";

function ThemeSyncer() {
  const { theme } = useTheme();
  const { mutate } = useUpdate();

  useEffect(() => {
    // Save to UserPreferences
    mutate({
      resource: "UserPreferences",
      id: "current",
      values: { theme },
    });
  }, [theme, mutate]);

  return null;
}
```

### Per-Tenant Theming

Combine with tenant context for custom branding:

```typescript
import { useTenant } from '../contexts/TenantContext';
import { useTheme } from '../contexts/ThemeContext';

function BrandedApp() {
  const { attributes } = useTenant();
  const { theme } = useTheme();

  useEffect(() => {
    // Apply tenant brand colors
    if (attributes.primary_color) {
      document.documentElement.style.setProperty(
        '--color-primary',
        attributes.primary_color
      );
    }
  }, [attributes]);

  return <div>{/* App content */}</div>;
}
```

---

## Troubleshooting

### Theme Not Persisting

**Problem:** Theme resets to light on page reload

**Solution:** Check localStorage is enabled:

```typescript
// Test localStorage
try {
  localStorage.setItem("test", "test");
  localStorage.removeItem("test");
  console.log("localStorage is working");
} catch (e) {
  console.error("localStorage is disabled");
}
```

### Flicker on Page Load

**Problem:** Brief flash of light theme before dark mode applies

**Solution:** Add inline script in `index.html` before React loads:

```html
<!-- public/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <script>
      // Apply theme immediately (before React loads)
      const savedTheme = localStorage.getItem("framework-m-theme");
      const systemDark = window.matchMedia(
        "(prefers-color-scheme: dark)",
      ).matches;
      const theme = savedTheme || (systemDark ? "dark" : "light");
      if (theme === "dark") {
        document.documentElement.classList.add("dark");
      }
    </script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
```

### Colors Not Updating

**Problem:** CSS variables not reflecting in components

**Solution:** Ensure you're using `var()` syntax:

```css
/* ✅ Correct */
background: var(--color-bg);

/* ❌ Wrong */
background: --color-bg;
```

---

## Summary

Framework M's theming system provides:

- ✅ **Light/Dark Mode**: Built-in toggle with user preference
- ✅ **System Detection**: Respects OS theme preference
- ✅ **Persistent**: Saves to localStorage
- ✅ **Customizable**: CSS variables for all colors
- ✅ **Accessible**: Keyboard navigation and ARIA support
- ✅ **Extensible**: Easy to add custom themes

The system is production-ready and can be extended for tenant-specific branding, custom color schemes, or additional theme variants.