Frontend Customization Guide
This guide explains how to work with Framework M's bundled Desk UI and customize it for your needs.
Overview
Framework M ships with a pre-built Desk UI (similar to Frappe's Desk), providing a batteries-included experience. You can:
- Use the bundled UI - Zero npm setup required
- Customize the bundled UI - Run Vite dev server for live development
- Create custom frontends - Scaffold new UIs from templates
Quick Start: Bundled Desk UI
No npm Required
The bundled Desk UI is included in the Python package. Just install and run:
# Install Framework M
pip install framework-m
# Start the application in production mode
m prod
That's it! Open http://127.0.0.1:8000 in your browser. The Desk UI is pre-built and served automatically from the inner package assets.
How It Works
- Pre-built static files are bundled in the PyPI
framework-m-standardpackage - Served automatically by the backend when no custom frontend is active
- No build step needed - zero-configuration setup for immediate use
Development Mode: Live Customization
When you need to customize the UI or build your own plugins, use m dev. This command is orchestration-heavy and requires Framework M Studio to be installed in your environment.
Starting with Frontend Dev Server
m dev
By default, m dev:
- Auto-detects if a
frontend/directory exists. - Starts the Backend (uvicorn) with auto-reload.
- Starts the Frontend (Vite) concurrently with HMR (Hot Module Replacement).
- Starts Studio on port 9999 for visual editing.
Development Workflow
# Start the full dev stack
m dev
# Backend typically runs on http://127.0.0.1:8000
# Frontend runs on http://localhost:5173
# Studio runs on http://localhost:9999
# Edit files in frontend/src/ or use Studio to scaffold
# Changes appear instantly!
File-Based UI Overrides (Zero-Cliff)
Framework M supports a "Zero-Cliff" customization pattern. You don't need to rebuild the entire UI just to change one form or list. By simply creating files in specific directories, the framework automatically intercepts standard routes and renders your custom components, utilizing a Last-In-Wins priority overriding system.
Customizing a DocType Layout
To completely override or wrap the standard form for a Todo DocType, create a file at frontend/src/overrides/Todo/FormView.tsx:
// frontend/src/overrides/Todo/FormView.tsx
import { YStack, Card, Heading, MText } from "@framework-m/ui";
import type { FormViewProps } from "@framework-m/desk";
export default function CustomTodoFormView({ doctype, id, children }: FormViewProps) {
return (
<YStack padding="$lg" gap="$md" flex={1}>
{/* 1. Add your custom bespoke UI */}
<Card elevated padding="$md" backgroundColor="rgba(24, 144, 255, 0.1)" borderColor="$primary" borderWidth={1}>
<Heading size="$sm" color="$primary">🚀 Custom Todo Override</Heading>
<MText>Intercepting the standard form for: {id || "New Record"}</MText>
</Card>
{/* 2. Render the standard framework form underneath! */}
{children}
</YStack>
);
}
The @framework-m/vite-plugin automatically discovers this file and routes users to your custom view whenever they navigate to /app/Todo/new or /app/Todo/detail/:id.
Advanced Layout Overrides
Beyond FormView.tsx, you can override other standard layouts by creating appropriately named files in the src/overrides/[DocType]/ directory:
ListView.tsxKanbanView.tsxTreeView.tsxCalendarView.tsxGanttView.tsx
For non-DocType global page overrides (e.g., replacing the login page), place them in src/overrides/Core/:
src/overrides/Core/LoginPage.tsx
Expanding the Toolbox (The Field Registry)
While Form Overrides replace the entire page for a specific DocType, the Field Registry allows you to add new reusable tools to the global system. This is the preferred method for adding specialized inputs (e.g., Signature Pad, Barcode Scanner, Map Location) that should be available to any DocType via metadata.
Why not just override the form?
| Strategy | Property | Use Case |
|---|---|---|
| Field Extension | Lightweight / Systemic | General widgets (Signature, Rating, Color) |
| Form Override | Heavyweight / Bespoke | Entirely custom UX (Point of Sale, Dashboard) |
| Leverage | High (Build once, use in all DocTypes) | Low (Specific to one DocType) |
Registering a Custom Field
To add a new field type, create your React component and register it with the FieldRegistry.
- Create the Component:
// frontend/src/components/fields/SignatureField.tsx
import { YStack, MText } from "@framework-m/ui";
export function SignatureField({ label, value, onChange }: any) {
return (
<YStack gap="$2" padding="$2" borderStyle="dashed" borderWidth={1} borderColor="$borderColor">
<MText size="sm" fontWeight="600">{label}</MText>
{/* Imagine a canvas signature pad here */}
<MText color="$colorMuted" size="xs">Signature Pad Placeholder</MText>
</YStack>
);
}
- Register it (Global Entry Point):
Usually this is done in your src/index.tsx or a dedicated plugin initialization file.
// frontend/src/index.tsx
import { FieldRegistry } from "@framework-m/desk";
import { SignatureField } from "./components/fields/SignatureField";
// Register the widget name "signature"
FieldRegistry.register("signature", SignatureField);
Using it in Backend Metadata
Once registered, you can use the field in any DocType by setting the ui_widget (or x-fieldtype) in the Python Meta class.
# backend/models/contract.py
class Contract(BaseDocType):
signature: str = Field(description="Client Signature")
class Meta:
ui_meta = {
"form": {
"sections": [
{
"fields": [
{"name": "signature", "ui_widget": "signature"}
]
}
]
}
}
The AutoField in the Desk UI will automatically resolve the "signature" key to your SignatureField component from the registry.
High-Density Spreadsheet UX (Child Tables)
For complex data entry like Sales Invoice Items or Task Lists, Framework M provides a Spreadsheet UX powered by the ListField component. This system is designed for high-velocity entry while maintaining deep property access.
Dual-Resolution Pattern
Framework M implements a tiered editing strategy for child tables:
- Selective Grid (The Fast Path): The main spreadsheet view only renders properties defined in
grid_columns(or a smart fallback). This maintains high density and focus for rapid row entry. - Exhaustive Detail Modal (The Source of Truth): Clicking the edit icon (🖉) opens a centered Detail Modal. This modal renders every property defined in the child table schema, allowing access to hidden metadata, settings, or secondary fields that would clutter the grid.
Universal Portals (Cross-Platform Parity)
The Edit Modal uses Universal Portals to ensure stability:
- Global Projection: Modals are projected out of the grid's
overflow: scrollcontainers to the document root, definitively preventing clipping and layout shifts. - Web & Native Parity: The system uses
ReactDOM.createPortalon Web and a state-based host on React Native, ensuring your child table detail views work seamlessly across all platforms. - Theme Sync: Portals automatically synchronize with the application theme scope, ensuring colors and CSS variables are always correct.
Keyboard Power Productivity
The spreadsheet is optimized for keyboard-first navigation:
Ctrl + Enter: Global shortcut to instantly insert and focus a new row.- Natural Tab Flow: Moving between cells, actions, and the "Add Button" follows a logical sequence to prevent focus trapping.
- Space/Enter: Standardized interactions for checkboxes and buttons within the high-density grid.
Configuring in Backend Metadata
You can control this UX directly from your Python models:
class Task(BaseDocType):
items: list[TaskItem] = Field(description="Sub-tasks")
class Meta:
ui_meta = {
"grid_columns": ["title", "assigned_to", "is_done"]
}
In this example, only those three fields appear in the high-density spreadsheet, but all fields of TaskItem will be editable in the Portal-based Detail Modal.
Adding Sidebar Menus
To add or modify sidebar navigation menus, export your menu definition from src/menu.ts. The Vite plugin automatically discovers this file and aggregates its contents into the shell's navigation tree.
// frontend/src/menu.ts
export const menu = [
{
label: "WMS",
icon: "warehouse",
children: [
{ label: "Warehouses", to: "/app/Warehouse" },
{ label: "Inventory", to: "/app/inventory-dashboard" }
]
}
];
Bypassing Standard Routing (Traversals)
Sometimes you want clicking a record or a menu item to take the user to a custom dashboard widget instead of the standard Form or List views. You can configure custom routing by exporting route resolver functions from src/overrides/traversals.ts. The shell intercepts navigation events and respects your custom trajectory.
// frontend/src/overrides/traversals.ts
export function resolveDetailRoute(doctype: string, id: string): string | null {
if (doctype === "Project") {
// Navigate to a bespoke project dashboard instead of the standard form
return `/app/Project/dashboard/${id}`;
}
return null; // Fallback to standard routing ('/app/{doctype}/detail/{id}')
}
export function resolveListRoute(doctype: string): string | null {
if (doctype === "Project") {
// Navigate to a custom project portfolio view instead of the standard generic list
return `/app/Project/portfolio`;
}
return null; // Fallback to standard routing ('/app/{doctype}/list')
}
Creating Custom Frontends
Initialize Frontend Template (Requires Studio)
Scaffolding new frontends is a developer-only feature provided by the m studio toolkit. You cannot scaffold in a production environment as the templates and logic are not installed there.
# Initialize in ./frontend using studio CLI
m studio new frontend
# Or specify custom location
m studio new frontend ../my-custom-ui
This scaffolds a complete React + TypeScript + Vite application with:
- @framework-m/desk npm package (providers pre-configured)
- Refine.dev setup for rapid admin UI development
- React Router for client-side routing
- Tailwind CSS + shadcn/ui components
- Vite for fast development and optimized builds
Template Structure
frontend/
├── package.json # Dependencies and scripts
├── .npmrc # GitLab registry configuration
├── vite.config.ts # Vite build configuration
├── tsconfig.json # TypeScript configuration
├── src/
│ ├── index.tsx # Application entry point
│ ├── App.tsx # Refine.dev setup
│ ├── pages/ # Page components
│ │ ├── Dashboard.tsx
│ │ └── Login.tsx
│ └── components/ # Reusable components
├── public/ # Static assets
└── README.md # Setup instructions
Install Dependencies
After scaffolding, install dependencies:
cd frontend
pnpm install
Run Development Server
# Option 1: Run frontend only
cd frontend
pnpm dev
# Option 2: Run both backend + frontend (recommended)
cd ..
m prod --with-frontend
Using @framework-m/desk Package
What is @framework-m/desk?
The @framework-m/desk npm package provides pre-built providers for Refine.dev:
frameworkMDataProvider- CRUD operations via REST APIauthProvider- Cookie-based authenticationliveProvider- WebSocket real-time updates- Constants - API URLs configured from environment
This package is automatically included in templates created with m new frontend .
Basic Usage
import { Refine } from "@refinedev/core";
import { BrowserRouter } from "react-router-dom";
import {
frameworkMDataProvider,
authProvider,
liveProvider,
} from "@framework-m/desk";
export default function App() {
return (
<BrowserRouter>
<Refine
dataProvider={frameworkMDataProvider}
authProvider={authProvider}
liveProvider={liveProvider}
resources={[
{
name: "User",
list: "/users",
show: "/users/:id",
edit: "/users/:id/edit",
create: "/users/new",
},
]}
>
{/* Your application routes */}
</Refine>
</BrowserRouter>
);
}
API Configuration
The package uses globalThis.__FRAMEWORK_CONFIG__ for API URLs:
// Set in index.html or environment
declare global {
interface Window {
__FRAMEWORK_CONFIG__?: {
API_URL?: string;
META_URL?: string;
WS_URL?: string;
};
}
}
// Default values (from vite.config.ts proxy)
globalThis.__FRAMEWORK_CONFIG__ = {
API_URL: "http://127.0.0.1:8888/api/v1",
META_URL: "http://127.0.0.1:8888/api/v1/meta",
WS_URL: "ws://127.0.0.1:8888/api/v1/live",
};
Authentication Flow
The authProvider handles cookie-based authentication:
// Login
await authProvider.login({
email: "admin@example.com",
password: "password",
});
// Check authentication status
const { authenticated } = await authProvider.check();
// Get current user identity
const identity = await authProvider.getIdentity();
// Logout
await authProvider.logout();
Real-time Updates
The liveProvider enables WebSocket subscriptions:
import { liveProvider, closeAllConnections } from "@framework-m/desk";
// Subscribe to User changes
liveProvider.subscribe({
channel: "User",
types: ["created", "updated", "deleted"],
callback: event => {
console.log("User changed:", event);
},
});
// Cleanup on unmount
useEffect(() => {
return () => closeAllConnections();
}, []);
GitLab Registry Configuration
Why GitLab Registry?
The @framework-m/desk package is published to your project's GitLab Package Registry (private by default). This ensures:
- Version control - Package versions tied to git tags
- Private packages - Not exposed to public npm registry
- CI/CD integration - Automatic publishing on releases
Configure Access
The template includes a .npmrc file:
@framework-m:registry=https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
Local Development
For local development outside CI/CD, create a Personal Access Token:
- GitLab Settings → Access Tokens
- Create token with
read_apiscope - Configure npm:
# In your frontend directory
echo "//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${YOUR_TOKEN}" >> .npmrc
CI/CD Authentication
In GitLab CI/CD, authentication is automatic:
# .gitlab-ci.yml
install-frontend:
script:
- cd frontend
- echo "@framework-m:registry=https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/" > .npmrc
- echo "//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}" >> .npmrc
- pnpm install
The CI_JOB_TOKEN is automatically available in GitLab CI/CD pipelines.
Publishing Updates
To publish a new version of @framework-m/desk:
# Update version in package.json
cd libs/framework-m-desk
npm version patch # or minor, major
# Create git tag
git tag framework-m-desk@v0.1.1
git push origin framework-m-desk@v0.1.1
# GitLab CI will build and publish automatically
CLI Reference
m prod
Start Framework M in production mode.
m prod [OPTIONS]
Options:
--port PORT- Backend port (default: 8000)--host HOST- Host to bind to (default: 0.0.0.0)--with-frontend- Serve custom built assets fromfrontend/distvia backend.--frontend-dir PATH- Frontend directory path (default: ./frontend)
Examples:
# Production mode - serve bundled assets
m prod
# Production mode - serve custom built assets from frontend/dist
m prod --with-frontend
# Custom port
m prod --port 3000
m dev (Development Only)
Standard full-stack development command. Starts backend, frontend (Vite), and Studio.
m dev [OPTIONS]
Options:
--port PORT- Backend port (default: 8000)--studio-port PORT- Studio port (default: 9999)--no-frontend- Disable the Vite frontend process.--enable-worker- Start the Taskiq background worker concurrently.
m studio new frontend (Requires Studio)
Initialize a new frontend from the standard template.
m studio new frontend [TARGET]
Arguments:
TARGET- Target directory for frontend (default: ./frontend)
Examples:
# Initialize in ./frontend
m studio new frontend
# Initialize in custom directory
m studio new frontend ../my-ui
What It Does:
- Copies template from
apps/studio/frontend - Creates directory structure with all configuration files
- Runs
pnpm installto install dependencies - Provides instructions for next steps
Deployment
Building for Production
cd frontend
pnpm build
This creates optimized static files in frontend/dist/.
Serving Production Build
The production build can be served in two ways:
Option 1: Bundle with PyPI package (recommended)
Include the built frontend in your PyPI distribution:
# Build frontend
cd frontend
pnpm build
# Copy to package static directory
cp -r dist/* ../libs/framework-m/src/framework_m/static/
# Build Python package
cd ../libs/framework-m
uv build
# Deploy
pip install dist/framework_m-*.whl
m prod
Option 2: Separate static file server
Serve frontend/dist/ with nginx, Caddy, or any static file server. Configure CORS to allow API requests from your frontend domain.
Distributed UI & Macroservices
For large applications, you can decompose your UI into independent macroservices. See the Distributed UI Guide for more information on how to configure remote MFE discovery and transparent proxying.
Environment Variables
Configure API endpoints for production:
<!-- frontend/index.html -->
<script>
window.__FRAMEWORK_CONFIG__ = {
API_URL: "https://api.yourdomain.com/api/v1",
META_URL: "https://api.yourdomain.com/api/v1/meta",
WS_URL: "wss://api.yourdomain.com/api/v1/live",
};
</script>
Or use environment variables with Vite:
# .env.production
VITE_API_URL=https://api.yourdomain.com/api/v1
VITE_META_URL=https://api.yourdomain.com/api/v1/meta
VITE_WS_URL=wss://api.yourdomain.com/api/v1/live
Troubleshooting
Studio Features Not Found
Symptom: m studio new frontend or m dev fails with "Command not found".
Solution: Ensure framework-m-studio is installed in your development environment. This package is typically not included in production-lean images.
uv pip install framework-m-studio
npm Package Not Found
Symptom: npm ERR! 404 Not Found - GET https://gitlab.com/...
Solutions:
- Check
.npmrcconfiguration:cat frontend/.npmrc - Verify CI_PROJECT_ID is set correctly
- Create Personal Access Token for local development
- Check package registry permissions in GitLab
Port Already in Use
Symptom: Error: Address already in use
Solutions:
# Use different port
m prod --port 8001
# Kill process using port 8888
lsof -ti:8888 | xargs kill -9
WebSocket Connection Failed
Symptom: Live updates not working
Solutions:
- Check WS_URL in configuration
- Verify WebSocket endpoint is accessible
- Check firewall/proxy settings allow WebSocket connections
- Use secure WebSocket (wss://) in production with HTTPS
Build Failures
Symptom: pnpm build fails with errors
Solutions:
- Clear node_modules and reinstall:
rm -rf node_modules pnpm-lock.yamlpnpm install
- Check Node.js version (requires Node 20+)
- Verify TypeScript errors:
pnpm tsc --noEmit
Best Practices
Development Workflow
- Start with bundled UI - Verify backend works first
- Scaffold frontend - Only when customization is needed
- Use
m prod --with-frontend- Simplifies dual-server management - Hot reload is automatic - Backend and frontend reload on changes
- Commit often - Frontend changes tracked in git
Project Structure
my-project/
├── app.py # Backend application entry
├── frontend/ # Custom frontend (gitignored by default)
│ ├── src/
│ │ ├── App.tsx
│ │ └── pages/
│ └── package.json
├── models/ # Your DocTypes
├── controllers/ # Business logic
└── pyproject.toml # Python dependencies
Version Control
Recommended .gitignore:
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.npmrc # Contains auth tokens
# Python
__pycache__/
*.pyc
.venv/
dist/
*.egg-info/
Commit frontend source:
- ✅ Commit
frontend/src/,frontend/package.json,frontend/vite.config.ts - ❌ Don't commit
node_modules/,dist/,.npmrcwith tokens
Performance Optimization
- Use production builds -
pnpm buildcreates optimized bundles - Enable gzip/brotli - Compress static assets
- Use CDN - Serve static files from CDN in production
- Lazy load routes - Split code by route for faster initial load
- Cache API responses - Use Refine's built-in query caching
Next Steps
- DocType Guide - Learn how to create data models
- Protocol Reference - Explore protocol interfaces
- Refine Documentation - Learn Refine.dev framework
- Vite Documentation - Understand Vite build tool
Support
- GitHub Issues: Report bugs or request features
- Community Forum: Ask questions and share knowledge
- Documentation: Browse full documentation