protomobilekit
v2.0.0
Published
React component library for rapid mobile app prototyping with iOS/Android styles
Maintainers
Readme
ProtoMobileKit
React component library for rapid mobile app prototyping. Build iOS and Android-style interfaces with a unified API, complete with navigation, authentication, state management, and 50+ UI components.
Table of Contents
- Installation
- Quick Start
- Best Practices
- Core Concepts
- UI Components
- Theming
- Utilities
- DevTools
- Automation (MCP/Puppeteer)
- TypeScript
Installation
npm install protomobilekitPeer Dependencies
npm install react react-dom zustandTailwind CSS 4 Setup
ProtoMobileKit uses Tailwind CSS 4. Configure your CSS file:
/* src/index.css */
@import "tailwindcss";
/* Include protomobilekit classes */
@source "../node_modules/protomobilekit/dist/**/*.js";Quick Start
import { Canvas, defineApp, Navigator, Screen, Header, Button, Text, ThemeProvider, DevTools } from 'protomobilekit'
function App() {
return (
<ThemeProvider defaultPlatform="ios">
<Canvas
apps={[
defineApp({
id: 'myapp',
name: 'My App',
device: 'iphone-14',
component: () => <MyApp />,
}),
]}
layout="row"
showLabels
/>
<DevTools position="right" devOnly={false} />
</ThemeProvider>
)
}
function MyApp() {
return (
<Navigator initial="home">
<Navigator.Screen name="home" component={HomeScreen} />
<Navigator.Screen name="details" component={DetailsScreen} />
</Navigator>
)
}
function HomeScreen() {
const { navigate } = useNavigate()
return (
<Screen header={<Header title="Home" />}>
<div className="p-4">
<Text size="xl" bold>Welcome!</Text>
<Button onClick={() => navigate('details', { id: '123' })}>
View Details
</Button>
</div>
</Screen>
)
}Best Practices
Follow these conventions for a well-organized prototype that's easy to maintain, document, and automate.
Project Structure
Recommended folder structure for multi-app prototypes:
src/
├── apps/ # Each app in separate folder
│ ├── customer/
│ │ ├── index.tsx # App component with Navigator
│ │ ├── screens/ # Screen components
│ │ │ ├── HomeScreen.tsx
│ │ │ ├── OrdersScreen.tsx
│ │ │ └── ProfileScreen.tsx
│ │ ├── frames.ts # Frame definitions for this app
│ │ └── users.ts # Test users for this app
│ ├── admin/
│ │ ├── index.tsx
│ │ ├── screens/
│ │ ├── frames.ts
│ │ └── users.ts
│ └── courier/
│ └── ...
├── entities/ # Shared entity definitions
│ ├── index.ts # Export all + seed function
│ ├── Order.ts
│ ├── Restaurant.ts
│ ├── User.ts
│ └── Courier.ts
├── flows/ # User flow definitions
│ └── index.ts
├── App.tsx # Main app with Canvas
└── main.tsx # Entry pointEntity Registration
Rule: Define all entities BEFORE app renders. Entities are shared across all apps.
// src/entities/Order.ts
import { entity } from 'protomobilekit'
import type { InferEntity } from 'protomobilekit'
export const Order = entity({
name: 'Order',
fields: {
status: { type: 'enum', values: ['pending', 'preparing', 'delivering', 'delivered'] as const },
customerId: 'string',
courierId: { type: 'string', default: null },
restaurantId: 'string',
items: 'string', // JSON string
total: 'number',
address: 'string',
createdAt: 'date',
},
})
export type Order = InferEntity<typeof Order>// src/entities/index.ts
import { useStore, resetStore } from 'protomobilekit'
// Import all entities (this registers them)
export * from './Order'
export * from './Restaurant'
export * from './User'
export * from './Courier'
// Seed initial data
export function seedData() {
const store = useStore.getState()
const silent = { silent: true } // No events during seeding
// Check if already seeded
if (store.getAll('Restaurant').length > 0) return
// Restaurants
store.create('Restaurant', { id: 'r1', name: 'Sushi Master', rating: 4.8 }, silent)
store.create('Restaurant', { id: 'r2', name: 'Pizza Place', rating: 4.5 }, silent)
// Orders with different statuses
store.create('Order', {
id: 'o1',
status: 'pending',
customerId: 'alice',
restaurantId: 'r1',
items: JSON.stringify([{ name: 'Dragon Roll', qty: 2, price: 450 }]),
total: 900,
address: '123 Main St',
}, silent)
// ... more seed data
}// src/main.tsx
import { seedData } from './entities'
// Seed data BEFORE rendering
seedData()
ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
)Screen Registration
Use defineScreen to create screens. This:
- Creates a React component for use in Navigator
- Automatically registers screen for DevTools
- Enables direct URL access to screens
- Separates View (UI) from useCase (logic)
// src/apps/customer/screens/restaurant/index.ts
import { defineScreen } from 'protomobilekit'
import { RestaurantView } from './RestaurantView'
import { useRestaurantCase } from './useRestaurantCase'
import { resolveRestaurantParams } from './resolve'
import { restaurantParamsCodec } from './params'
export const RestaurantScreen = defineScreen({
appId: 'customer',
name: 'restaurant',
View: RestaurantView,
useCase: useRestaurantCase,
resolveParams: resolveRestaurantParams,
paramsCodec: restaurantParamsCodec,
tags: ['detail', 'restaurant'],
description: 'Restaurant menu with dishes',
})Then use in Navigator:
// src/apps/customer/index.tsx
import { RestaurantScreen } from './screens/restaurant'
<Navigator initial="home" type="tabs">
<Navigator.Screen name="home" component={HomeScreen} />
<Navigator.Screen name="restaurant" component={RestaurantScreen} />
<Navigator.Screen name="orders" component={OrdersScreen} />
</Navigator>Frame Registration (Optional)
For additional DevTools organization (flows, custom navigation), use frames:
import { defineFrames, createFrame } from 'protomobilekit'
import { RestaurantScreen } from './screens/restaurant'
const restaurantFrame = createFrame({
id: 'restaurant',
name: '1.2 Restaurant',
description: 'Restaurant menu with dishes',
// Can use defineScreen component
component: RestaurantScreen,
tags: ['detail'],
// Custom navigation with default params
onNavigate: (nav) => nav.navigate('restaurant', { id: 'r1' }),
})
defineFrames({
appId: 'customer',
appName: 'Customer App',
initial: 'home',
frames: [homeFrame, restaurantFrame, ordersFrame],
})Note: defineScreen already registers screens for DevTools. Frames are only needed for:
- Custom display names (e.g., "1.2 Restaurant")
- Custom navigation handlers with default params
- Organizing screens into flows
User & Role Registration
Rule: Define test users for EACH app. This enables:
- Quick user switching in DevTools Auth Panel
- Testing different roles/permissions
- Realistic prototype demos
// src/apps/customer/users.ts
import { defineUsers, defineRoles } from 'protomobilekit'
// Define roles first
defineRoles({
appId: 'customer',
roles: [
{ value: 'regular', label: 'Regular' },
{ value: 'premium', label: 'Premium', color: '#f59e0b' },
{ value: 'vip', label: 'VIP', color: '#8b5cf6' },
],
})
// Define test users
defineUsers({
appId: 'customer',
users: [
{
id: 'alice',
name: 'Alice Johnson',
phone: '+1 234 567 8901',
role: 'premium',
avatar: 'https://i.pravatar.cc/150?u=alice',
// Custom fields for this app
address: '123 Main Street, New York',
defaultPayment: 'card',
},
{
id: 'bob',
name: 'Bob Smith',
phone: '+1 234 567 8902',
role: 'regular',
avatar: 'https://i.pravatar.cc/150?u=bob',
},
{
id: 'charlie',
name: 'Charlie Brown',
phone: '+1 234 567 8903',
role: 'vip',
avatar: 'https://i.pravatar.cc/150?u=charlie',
},
],
})// src/apps/admin/users.ts
import { defineUsers, defineRoles } from 'protomobilekit'
defineRoles({
appId: 'admin',
roles: [
{ value: 'manager', label: 'Manager' },
{ value: 'superadmin', label: 'Super Admin', color: '#ef4444' },
],
})
defineUsers({
appId: 'admin',
users: [
{ id: 'admin1', name: 'Admin User', phone: '+1 999 000 0001', role: 'manager' },
{ id: 'super', name: 'Super Admin', phone: '+1 999 000 0000', role: 'superadmin' },
],
})Tip: User IDs should match entity foreign keys (e.g., order.customerId = 'alice').
Flow Registration
Rule: Define user flows for key journeys. This enables:
- Task tracking in DevTools Flows Panel
- Acceptance criteria documentation
- QA testing checklists
// src/flows/index.ts
import { defineFlow } from 'protomobilekit'
import { homeFrame, ordersFrame, orderDetailsFrame } from '../apps/customer/frames'
import { checkoutFrame } from '../apps/customer/frames'
// Order placement flow
defineFlow({
id: 'place-order',
name: 'Place Order',
description: 'Complete flow from browsing to order confirmation',
appId: 'customer',
steps: [
{
frame: homeFrame,
tasks: [
'Browse restaurant list',
'Use search to find restaurant',
'Apply cuisine filter',
'Select a restaurant',
],
},
{
frame: menuFrame,
tasks: [
'View menu categories',
'Add items to cart',
'Customize item (if available)',
'View cart summary',
],
},
{
frame: checkoutFrame,
tasks: [
'Confirm delivery address',
'Select payment method',
'Apply promo code (optional)',
'Place order',
],
},
{
frame: orderDetailsFrame,
tasks: [
'View order confirmation',
'See estimated delivery time',
'Track order status',
],
},
],
})
// Order tracking flow
defineFlow({
id: 'track-order',
name: 'Track Order',
description: 'Monitor order from preparation to delivery',
appId: 'customer',
steps: [
{
frame: ordersFrame,
tasks: ['View active orders', 'Select order to track'],
},
{
frame: orderDetailsFrame,
tasks: [
'View order timeline',
'See courier information',
'Contact courier (if available)',
'Confirm delivery',
],
},
],
})Complete Setup Example
// src/main.tsx
import ReactDOM from 'react-dom/client'
import { ThemeProvider, DevTools } from 'protomobilekit'
// 1. Import entities (registers them)
import { seedData } from './entities'
// 2. Import user definitions (registers them)
import './apps/customer/users'
import './apps/admin/users'
import './apps/courier/users'
// 3. Import frame definitions (registers them)
import './apps/customer/frames'
import './apps/admin/frames'
import './apps/courier/frames'
// 4. Import flow definitions (registers them)
import './flows'
// 5. Seed data
seedData()
// 6. Import main app
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<App />
<DevTools />
</ThemeProvider>
)Summary Checklist
Before considering your prototype complete:
- [ ] Entities: All data types defined with proper fields and types
- [ ] Seed Data: Realistic test data for all entities
- [ ] Frames: Every screen registered with description and tags
- [ ] Users: Test users defined for each app with appropriate roles
- [ ] Flows: Key user journeys documented as flows
- [ ] Hash Routing: Navigator uses
useHashfor URL-based navigation - [ ] DevTools: Enabled for easy debugging and demonstration
This structure ensures your prototype is:
- Discoverable - All screens accessible via Frame Browser
- Testable - Quick user switching and flow tracking
- Automatable - MCP/Puppeteer can navigate and screenshot any screen
- Documentable - Frame metadata can generate documentation
Core Concepts
Canvas & Apps
Canvas is the main container that displays multiple app instances in device frames.
Basic Setup
import { Canvas, defineApp, ThemeProvider, DevTools } from 'protomobilekit'
function App() {
return (
<ThemeProvider defaultPlatform="android">
<Canvas
apps={[
defineApp({
id: 'customer',
name: 'Customer App',
device: 'iphone-14',
component: () => <CustomerApp />,
}),
defineApp({
id: 'admin',
name: 'Admin Panel',
device: 'iphone-14-pro-max',
component: () => <AdminApp />,
}),
]}
layout="row" // 'row' | 'grid' | 'freeform'
gap={24} // Gap between devices (px)
scale={1} // Device scale (0.5 - 1.5)
showLabels // Show app names below devices
background="#f3f4f6"
/>
<DevTools position="right" devOnly={false} />
</ThemeProvider>
)
}Available Devices
// Device presets
type DeviceType =
| 'iphone-14'
| 'iphone-14-pro'
| 'iphone-14-pro-max'
| 'iphone-se'
| 'pixel-7'
| 'pixel-7-pro'
| 'galaxy-s23'
| 'galaxy-s23-ultra'
// Or custom dimensions
defineApp({
id: 'custom',
name: 'Custom Device',
deviceConfig: {
width: 375,
height: 812,
borderRadius: 40,
notch: true,
},
component: () => <MyApp />,
})useApp Hook
Access app context and auth from any component:
import { useApp } from 'protomobilekit'
function ProfileScreen() {
const {
appId, // Current app ID
appName, // Current app name
user, // Current authenticated user
userId, // User ID (string | null)
isAuthenticated,
login,
logout,
} = useApp()
return (
<Screen>
<Text>App: {appName}</Text>
<Text>User: {user?.name}</Text>
<Button onClick={logout}>Logout</Button>
</Screen>
)
}Canvas SDK
Programmatic API for controlling Canvas - show/hide apps, fullscreen mode, and navigation.
import { canvas } from 'protomobilekit'
// Get apps
canvas.getApps() // All registered apps
canvas.getApp('admin') // Single app by ID
canvas.getVisibleApps() // Only visible apps
canvas.getHiddenApps() // Only hidden apps
// Visibility control
canvas.show('admin') // Show app
canvas.hide('admin') // Hide app
canvas.toggle('admin') // Toggle visibility
canvas.showAll() // Show all apps
canvas.showOnly('admin') // Hide all except one
canvas.isVisible('admin') // Check if visible
// Fullscreen mode (single app, no device frame, max-width 720px)
canvas.fullscreen('admin') // Enter fullscreen
canvas.exitFullscreen() // Exit fullscreen
canvas.toggleFullscreen('admin') // Toggle fullscreen
canvas.isFullscreen('admin') // Check if fullscreen
canvas.hasFullscreen() // Any app in fullscreen?
canvas.getFullscreenApp() // Get fullscreen app or null
// Navigation (for automation/MCP)
canvas.navigateTo('admin', 'orders') // Navigate to screen
canvas.navigateTo('admin', 'orderDetails', { id: 'o1' }) // With params
canvas.getCurrentRoute() // { appId, screen, params }
canvas.getScreens('admin') // All screens for app
canvas.getScreenNames('admin') // ['home', 'orders', ...]
// Subscribe to changes
const unsubscribe = canvas.subscribe(() => {
console.log('Canvas state changed')
})
// Reset state
canvas.reset() // Show all, exit fullscreenCanvas Props
<Canvas
apps={apps}
layout="row"
gap={32}
scale={1}
showLabels={true}
background="#f3f4f6"
// Fullscreen mode options
hideExitFullscreen={false} // Hide "Exit Fullscreen" button
showFrameInFullscreen={false} // Show device frame in fullscreen (for screenshots)
/>Preview Mode
Preview mode allows rendering screens in isolation - without Navigator guards, with mock auth, and with device frames. Perfect for:
- Automated screenshots
- Documentation generation
- Component testing
- LLM/MCP integration
usePreviewMode Hook
Parse URL parameters to detect preview mode:
// URL: /prototype?mode=preview&screen=home&user=user-1&app=customer
import { usePreviewMode, ScreenPreview, Canvas } from 'protomobilekit'
function App() {
const { isPreview, screenId, userId, appId } = usePreviewMode()
if (isPreview && screenId) {
return (
<ScreenPreview
screen={screenId}
appId={appId}
userId={userId}
showFrame={true}
device="iphone-14"
/>
)
}
return <Canvas apps={apps} />
}ScreenPreview Component
Render any registered screen in isolation:
import { ScreenPreview } from 'protomobilekit'
// Basic - no frame
<ScreenPreview screen="home" appId="customer" />
// With device frame (for screenshots)
<ScreenPreview
screen="profile"
appId="customer"
userId="alice" // Mock auth with test user
showFrame={true}
device="iphone-14"
scale={1}
platform="ios"
/>
// With route params
<ScreenPreview
screen="order-details"
appId="customer"
params={{ orderId: 'o1' }}
/>
// Custom background
<ScreenPreview
screen="home"
appId="customer"
background="#1a1a1a"
/>MockAuthProvider
Wrap components with mock auth state (used internally by ScreenPreview):
import { MockAuthProvider } from 'protomobilekit'
// Using test user from registry
<MockAuthProvider appId="customer" userId="alice">
<ProfileScreen />
</MockAuthProvider>
// Using direct user object
<MockAuthProvider user={{ id: '1', name: 'Test', phone: '+123' }}>
<ProfileScreen />
</MockAuthProvider>
// Unauthenticated state
<MockAuthProvider isAuthenticated={false}>
<LoginScreen />
</MockAuthProvider>Screenshot Automation Example
// Puppeteer script for automated screenshots
const screens = ['home', 'orders', 'profile', 'settings']
const users = ['alice', 'bob']
for (const screen of screens) {
for (const user of users) {
const url = `http://localhost:5173/prototype?mode=preview&screen=${screen}&user=${user}&app=customer`
await page.goto(url)
await page.waitForTimeout(500)
await page.screenshot({ path: `screenshots/${screen}-${user}.png` })
}
}URL Parameters
| Parameter | Description | Example |
|-----------|-------------|---------|
| mode | Must be preview to enable preview mode | mode=preview |
| screen | Screen name to render | screen=home |
| user | Test user ID for mock auth | user=alice |
| app | App ID | app=customer |
| navigator | Navigator ID (default: main) | navigator=main |
Global SDK for Automation (MCP/Puppeteer)
Canvas SDK is exposed globally as window.__CANVAS_SDK__ for browser automation:
// In Puppeteer
await page.goto('http://localhost:5173')
// Get list of apps
const apps = await page.evaluate(() => {
return window.__CANVAS_SDK__.getApps().map(a => ({ id: a.id, name: a.name }))
})
// [{ id: 'customer', name: 'Customer App' }, { id: 'admin', name: 'Admin' }]
// Get screens for an app
const screens = await page.evaluate(() => {
return window.__CANVAS_SDK__.getScreenNames('admin')
})
// ['home', 'orders', 'orderDetails', 'settings']
// Navigate to specific screen
await page.evaluate(() => {
window.__CANVAS_SDK__.navigateTo('admin', 'orders')
})
// Wait for render and take screenshot
await page.waitForTimeout(500)
await page.screenshot({ path: 'admin-orders.png' })Use cases:
- MCP Server - LLM can request screenshots of specific screens
- Visual testing - Automated screenshot comparison
- Documentation - Generate screen captures programmatically
- E2E testing - Navigate and verify screen state
Navigation
Unified navigation system supporting both stack and tab patterns.
Stack Navigation
import { Navigator, useNavigate, useRoute } from 'protomobilekit'
function App() {
return (
<Navigator initial="home">
<Navigator.Screen name="home" component={HomeScreen} />
<Navigator.Screen name="details" component={DetailsScreen} />
<Navigator.Screen name="settings" component={SettingsScreen} />
</Navigator>
)
}
function HomeScreen() {
const { navigate, goBack, replace, reset, canGoBack } = useNavigate()
return (
<Screen>
{/* Navigate to screen with params */}
<Button onClick={() => navigate('details', { id: '123' })}>
View Details
</Button>
{/* Replace current screen */}
<Button onClick={() => replace('settings')}>
Replace with Settings
</Button>
{/* Reset navigation stack */}
<Button onClick={() => reset('home')}>
Reset to Home
</Button>
{/* Go back */}
{canGoBack() && (
<Button onClick={goBack}>Back</Button>
)}
</Screen>
)
}
function DetailsScreen() {
const { params } = useRoute<{ id: string }>()
return (
<Screen header={<Header title="Details" showBack />}>
<Text>Item ID: {params.id}</Text>
</Screen>
)
}Tab Navigation
import { Navigator } from 'protomobilekit'
// Icons (use any icon library)
const HomeIcon = () => <svg>...</svg>
const OrdersIcon = () => <svg>...</svg>
const ProfileIcon = () => <svg>...</svg>
function App() {
return (
<Navigator
initial="home"
type="tabs"
tabBarPosition="bottom" // 'bottom' | 'top'
>
<Navigator.Screen
name="home"
component={HomeScreen}
icon={<HomeIcon />}
label="Home"
/>
<Navigator.Screen
name="orders"
component={OrdersScreen}
icon={<OrdersIcon />}
label="Orders"
badge={3} // Badge count
/>
<Navigator.Screen
name="profile"
component={ProfileScreen}
icon={<ProfileIcon />}
label="Profile"
/>
{/* Non-tab screens (no icon) - accessible via navigate() */}
<Navigator.Screen name="order-details" component={OrderDetailsScreen} />
</Navigator>
)
}Navigation Options
<Navigator
initial="home"
type="stack" // 'stack' | 'tabs'
tabBarPosition="bottom" // 'bottom' | 'top' (for tabs)
tabBarHidden={false} // Hide tab bar
tabBarStyle={{ // Custom tab bar styles
backgroundColor: '#fff',
}}
screenOptions={{ // Default options for all screens
headerShown: true,
}}
useHash={false} // Enable hash-based URL routing
id="main" // Navigator ID for screen registry
>Hash-Based URL Routing
Enable URL synchronization with useHash prop. Navigation state syncs to URL hash (e.g., #/screen?param=value).
// Enable hash routing
<Navigator initial="home" useHash>
<Navigator.Screen name="home" component={HomeScreen} />
<Navigator.Screen name="orders" component={OrdersScreen} />
<Navigator.Screen name="details" component={DetailsScreen} />
</Navigator>
// URLs:
// #/home
// #/orders
// #/details?id=123Benefits:
- Bookmarkable - users can share/bookmark specific screens
- Browser navigation - back/forward buttons work
- External access - LLM bots and documentation tools can link to screens
- Deep linking - open app at specific screen with params
Screen Registry API
Access registered screens programmatically for external routing, documentation, or tooling.
import {
getScreens,
getScreenNames,
hasScreen,
subscribeToScreenRegistry,
parseHash,
buildHash,
} from 'protomobilekit'
// Get all registered screens
const screens = getScreens()
// [
// { name: 'home', navigatorId: 'main', navigatorType: 'stack', options: {...} },
// { name: 'orders', navigatorId: 'main', navigatorType: 'tabs', tab: { label: 'Orders', icon: ... } },
// ]
// Get screens for specific navigator
const mainScreens = getScreens('main')
// Get just screen names
const names = getScreenNames()
// ['home', 'orders', 'profile', 'details']
// Check if screen exists
if (hasScreen('orders')) {
// screen exists
}
// Subscribe to registry changes
const unsubscribe = subscribeToScreenRegistry(() => {
console.log('Screens updated:', getScreens())
})
// Hash utilities for manual URL building
parseHash('#/orders?id=123')
// { screen: 'orders', params: { id: '123' } }
buildHash('orders', { id: '123', tab: 'active' })
// '#/orders?id=123&tab=active'Use cases:
- Generate documentation - list all screens automatically
- External navigation - route to screens from outside React
- Testing - verify screen registration
- LLM integration - bots can discover available screens
Screen Architecture (defineScreen)
Create screens with View/ViewModel separation for better testability and code organization.
Basic Usage
import { defineScreen, useNavigate, useQuery, useRepo } from 'protomobilekit'
import type { ViewModel } from 'protomobilekit'
// 1. Define types
interface RestaurantParams {
id: string
}
interface RestaurantState {
restaurant: Restaurant | null
dishes: Dish[]
}
interface RestaurantActions {
goBack: () => void
orderDish: (dishId: string) => void
}
type RestaurantVM = ViewModel<RestaurantState, RestaurantActions>
// 2. Create View (pure UI, only renders VM)
function RestaurantView({ vm }: { vm: RestaurantVM }) {
const { state, actions } = vm
if (!state.restaurant) {
return <Text>Restaurant not found</Text>
}
return (
<Screen header={<Header title={state.restaurant.name} showBack />}>
<List
items={state.dishes}
renderItem={(dish) => (
<ListItem onPress={() => actions.orderDish(dish.id)}>
{dish.name} - ${dish.price}
</ListItem>
)}
/>
</Screen>
)
}
// 3. Create useCase (logic + data → ViewModel)
function useRestaurantCase(params: RestaurantParams): RestaurantVM {
const { goBack } = useNavigate()
const { items: restaurants } = useQuery<Restaurant>('Restaurant', {
filter: r => r.id === params.id
})
const { items: dishes } = useQuery<Dish>('Dish', {
filter: d => d.restaurantId === params.id
})
const { create: createOrder } = useRepo('Order')
return {
state: {
restaurant: restaurants[0] ?? null,
dishes,
},
actions: {
goBack,
orderDish: (dishId) => {
const dish = dishes.find(d => d.id === dishId)
if (dish) {
createOrder({ dishId, price: dish.price })
}
},
},
}
}
// 4. Define screen (returns React component)
export const RestaurantScreen = defineScreen({
appId: 'customer',
name: 'restaurant',
View: RestaurantView,
useCase: useRestaurantCase,
})
// 5. Use in Navigator
<Navigator initial="home">
<Navigator.Screen name="home" component={HomeScreen} />
<Navigator.Screen name="restaurant" component={RestaurantScreen} />
</Navigator>With Params Resolution
Use resolveParams to fill in missing params from context (e.g., get first restaurant if no ID provided):
import { defineScreen, type ResolverContext, type ResolveResult } from 'protomobilekit'
// Resolver function - fills defaults, validates params
function resolveRestaurantParams(
given: Partial<RestaurantParams>,
ctx: ResolverContext
): ResolveResult<RestaurantParams> {
// If ID provided, use it
if (given.id) {
return { ok: true, params: { id: given.id } }
}
// Otherwise, get first restaurant
const restaurant = ctx.repo('Restaurant').first()
if (restaurant) {
return { ok: true, params: { id: restaurant.id } }
}
// No restaurant available
return { ok: false, reason: 'No restaurants available' }
}
export const RestaurantScreen = defineScreen({
appId: 'customer',
name: 'restaurant',
View: RestaurantView,
useCase: useRestaurantCase,
resolveParams: resolveRestaurantParams, // ← Added
})With URL Params Coercion
Use paramsCodec to convert URL string params to typed values:
import { defineScreen, coerce, coerceString } from 'protomobilekit'
// Codec for URL → typed params conversion
const restaurantParamsCodec = {
coerce: (raw: Record<string, unknown>) => ({
id: coerceString(raw.id),
}),
serialize: (params: RestaurantParams) => ({
id: params.id,
}),
}
export const RestaurantScreen = defineScreen({
appId: 'customer',
name: 'restaurant',
View: RestaurantView,
useCase: useRestaurantCase,
resolveParams: resolveRestaurantParams,
paramsCodec: restaurantParamsCodec, // ← Added
})ResolverContext API
The ctx object in resolveParams provides type-safe data access:
function resolveParams(given: Partial<Params>, ctx: ResolverContext) {
// Access entity repository
const restaurant = ctx.repo('Restaurant').first()
const order = ctx.repo('Order').get(given.orderId)
const pending = ctx.repo('Order').find(o => o.status === 'pending')
const all = ctx.repo('Order').all()
// Access fixture refs (deterministic defaults)
const defaultId = ctx.ref('customer', 'defaultRestaurantId')
// Current user
const user = ctx.user
// App ID
const appId = ctx.appId
return { ok: true, params: { ... } }
}Type-Safe Entities (Module Augmentation)
For full type safety in ctx.repo(), augment the EntityMap:
// src/entities/types.ts
import type { Restaurant, Order, Dish } from './index'
declare module 'protomobilekit' {
interface EntityMap {
Restaurant: Restaurant
Order: Order
Dish: Dish
}
}Now ctx.repo('Restaurant') returns EntityRepo<Restaurant> with proper typing.
Fixture Refs
Set deterministic default values for screens accessed via DevTools or direct URL:
import { setFixtureRefs } from 'protomobilekit'
// Set after seeding data
setFixtureRefs('customer', {
defaultRestaurantId: 'r1',
defaultOrderId: 'o1',
})
// Use in resolveParams
function resolveParams(given, ctx) {
const id = given.id ?? ctx.ref('customer', 'defaultRestaurantId')
// ...
}Coerce Helpers
Built-in helpers for URL param coercion:
import {
coerceString, // unknown → string | undefined
coerceNumber, // unknown → number | undefined
coerceBoolean, // unknown → boolean | undefined
coerceEnum, // unknown → EnumValue | undefined
coerceJson, // unknown → ParsedJSON | undefined
} from 'protomobilekit'
const paramsCodec = {
coerce: (raw) => ({
id: coerceString(raw.id),
page: coerceNumber(raw.page),
active: coerceBoolean(raw.active),
status: coerceEnum(raw.status, ['pending', 'completed'] as const),
filters: coerceJson(raw.filters),
}),
serialize: (params) => ({ ... }),
}File Structure
Recommended structure for defineScreen:
screens/
└── restaurant/
├── index.ts # defineScreen + exports
├── types.ts # Params, State, Actions, VM types
├── RestaurantView.tsx # Pure UI component
├── useRestaurantCase.ts # useCase hook
├── resolve.ts # resolveParams function
└── params.ts # paramsCodecState Management
Entity-based state management with Zustand, automatic persistence to localStorage.
Entity Definition
import { entity, seed, fake } from 'protomobilekit'
import type { InferEntity } from 'protomobilekit'
// Define entity with type inference
const Order = entity({
name: 'Order',
fields: {
status: { type: 'enum', values: ['pending', 'confirmed', 'delivered'] as const },
customerId: 'string',
courierId: { type: 'string', default: null },
total: 'number',
items: 'string', // JSON string
address: 'string',
createdAt: 'date',
},
// Optional: custom mock generator
mock: () => ({
total: Math.random() * 100,
items: JSON.stringify([{ name: 'Pizza', qty: 1 }]),
}),
})
// Infer TypeScript type from entity
type Order = InferEntity<typeof Order>
// { id: string, status: 'pending' | 'confirmed' | 'delivered', customerId: string, ... }Field Types
type FieldType =
| 'string' // Random lorem words
| 'number' // Random integer 1-1000
| 'boolean' // Random true/false
| 'date' // Timestamp (Date.now())
| 'email' // faker.internet.email()
| 'phone' // faker.phone.number()
| 'url' // faker.internet.url()
| 'image' // faker.image.url()
| 'uuid' // UUID string
| 'enum' // Random from values array
| 'relation' // Foreign key (null by default)
// Extended field definition
const User = entity({
name: 'User',
fields: {
// Simple type
name: 'string',
// With custom faker path
firstName: { type: 'string', faker: 'person.firstName' },
lastName: { type: 'string', faker: 'person.lastName' },
avatar: { type: 'image', faker: 'image.avatar' },
// With default value
role: { type: 'enum', values: ['user', 'admin'] as const, default: 'user' },
// Enum type
status: { type: 'enum', values: ['active', 'inactive'] as const },
// Relation (foreign key)
companyId: { type: 'relation', entity: 'Company' },
},
})CRUD Operations with useRepo
import { useRepo, useQuery, useEntity, useRelation } from 'protomobilekit'
function OrdersScreen() {
// Get repository for CRUD operations
const orders = useRepo<Order>('Order')
// Create
const createOrder = () => {
const newOrder = orders.create({
status: 'pending',
customerId: 'user-1',
total: 29.99,
items: JSON.stringify([{ name: 'Burger', qty: 2 }]),
address: '123 Main St',
})
console.log('Created:', newOrder.id)
}
// Read all
const allOrders = orders.getAll()
// Read by ID
const order = orders.getById('order-123')
// Update
const confirmOrder = (id: string) => {
orders.update(id, { status: 'confirmed' })
}
// Delete
const cancelOrder = (id: string) => {
orders.remove(id)
}
return (...)
}useQuery for Filtered Data
import { useQuery } from 'protomobilekit'
function PendingOrders() {
const { items, total, isEmpty, hasMore } = useQuery<Order>('Order', {
// Filter
filter: (order) => order.status === 'pending',
// Sort (newest first)
sort: (a, b) => b.createdAt - a.createdAt,
// Pagination
limit: 10,
offset: 0,
})
if (isEmpty) {
return <Text>No pending orders</Text>
}
return (
<List
items={items}
keyExtractor={(o) => o.id}
renderItem={(order) => (
<ListItem>Order #{order.id}</ListItem>
)}
/>
)
}useEntity for Single Item
import { useEntity } from 'protomobilekit'
function OrderDetails({ orderId }: { orderId: string }) {
const order = useEntity<Order>('Order', orderId)
if (!order) {
return <Text>Order not found</Text>
}
return (
<Card>
<Text>Order #{order.id}</Text>
<Text>Status: {order.status}</Text>
<Text>Total: ${order.total}</Text>
</Card>
)
}useRelation for Related Data
import { useRelation, useRelations } from 'protomobilekit'
function OrderWithCustomer({ orderId }: { orderId: string }) {
// Get single related entity
const customer = useRelation<Order, User>('Order', orderId, 'customerId', 'User')
return (
<Card>
<Text>Customer: {customer?.name}</Text>
</Card>
)
}
function CustomerOrders({ customerId }: { customerId: string }) {
// Get all related entities (one-to-many)
const orders = useRelations<Order>('Order', 'customerId', customerId)
return (
<List
items={orders}
renderItem={(order) => <ListItem>{order.id}</ListItem>}
/>
)
}Data Seeding
import { seed, fake, useStore, resetStore } from 'protomobilekit'
// Seed multiple records
function initializeData() {
resetStore() // Clear existing data
// Generate 10 fake orders
const orders = seed(Order, 10)
console.log('Created orders:', orders)
// Generate single fake data (without saving)
const fakeOrder = fake(Order)
console.log('Fake order:', fakeOrder)
}
// Manual seeding with specific data
function seedProducts() {
const store = useStore.getState()
const products = [
{ id: 'p1', name: 'Pizza', price: 15, category: 'food' },
{ id: 'p2', name: 'Burger', price: 12, category: 'food' },
{ id: 'p3', name: 'Soda', price: 3, category: 'drink' },
]
for (const product of products) {
store.create('Product', product, { silent: true }) // No events
}
}Server Sync
import { defineConfig, useSync } from 'protomobilekit'
// Configure at app startup
defineConfig({
data: {
// Load data from server
onPull: async () => {
const res = await fetch('/api/data')
const data = await res.json()
// Return format: { CollectionName: { id: entity } }
return {
Order: data.orders.reduce((acc, o) => ({ ...acc, [o.id]: o }), {}),
User: data.users.reduce((acc, u) => ({ ...acc, [u.id]: u }), {}),
}
},
// Save data to server
onPush: async (data) => {
await fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
},
},
})
// Use in components
function SyncButton() {
const { pull, push, isSyncing, lastSyncAt } = useSync()
useEffect(() => {
pull() // Load on mount
}, [])
return (
<Button onPress={push} loading={isSyncing}>
Save to Server
</Button>
)
}Direct Store Access
import { useStore } from 'protomobilekit'
// Outside React components
const store = useStore.getState()
// All store methods
store.create('Order', { status: 'pending', ... })
store.update('Order', 'id', { status: 'confirmed' })
store.delete('Order', 'id')
store.getAll<Order>('Order')
store.getById<Order>('Order', 'id')
store.query<Order>('Order', o => o.status === 'pending')
// Sync helpers
store._mergeData({ Order: { 'id': {...} } })
store._getData() // Get all collectionsAuthentication
Built-in OTP authentication with user registry for testing.
Define Users and Roles
import { defineUsers, defineRoles } from 'protomobilekit'
// Define roles for an app
defineRoles({
appId: 'customer',
roles: [
{ value: 'regular', label: 'Regular Customer' },
{ value: 'premium', label: 'Premium', color: '#f59e0b' },
{ value: 'vip', label: 'VIP', color: '#8b5cf6' },
],
})
// Define test users
defineUsers({
appId: 'customer',
users: [
{
id: 'alice',
name: 'Alice Johnson',
phone: '+1234567890',
role: 'premium',
avatar: 'https://example.com/alice.jpg',
// Custom fields
email: '[email protected]',
address: '123 Main St',
},
{
id: 'bob',
name: 'Bob Smith',
phone: '+0987654321',
role: 'regular',
},
],
})OTP Auth Component
import { OTPAuth, useAuth, useIsAuthenticated } from 'protomobilekit'
function LoginScreen() {
const { navigate } = useNavigate()
return (
<OTPAuth
onSuccess={() => navigate('home')}
countryCode="US" // Default country
otpLength={4} // OTP code length
allowTestUsers // Show "Quick Login" for test users
logo={<Logo />} // Optional logo
title="Welcome" // Optional title
subtitle="Sign in to continue"
/>
)
}
function ProfileScreen() {
const { user, logout, isAuthenticated, updateUser } = useAuth()
if (!isAuthenticated) {
return <LoginScreen />
}
return (
<Screen>
<Avatar src={user?.avatar} name={user?.name} size="xl" />
<Text>{user?.name}</Text>
<Text>{user?.phone}</Text>
<Button onClick={() => updateUser({ name: 'New Name' })}>
Update Name
</Button>
<Button variant="danger" onClick={logout}>
Log Out
</Button>
</Screen>
)
}Auth Guards
import { RequireAuth, RequireRole, AuthGuard, RoleGuard } from 'protomobilekit'
// Require authentication
function ProtectedScreen() {
return (
<RequireAuth fallback={<LoginScreen />}>
<Dashboard />
</RequireAuth>
)
}
// Require specific role
function AdminPanel() {
return (
<RequireRole
roles={['admin', 'superadmin']}
fallback={<AccessDenied message="Admin access required" />}
>
<AdminDashboard />
</RequireRole>
)
}
// AuthGuard component (same as RequireAuth)
<AuthGuard>
<ProtectedContent />
</AuthGuard>
// RoleGuard component (same as RequireRole)
<RoleGuard roles={['premium']}>
<PremiumFeatures />
</RoleGuard>Auth Hooks
import { useAuth, useUser, useIsAuthenticated, useCurrentUserId, useRequireAuth } from 'protomobilekit'
function MyComponent() {
// Full auth state
const { user, isAuthenticated, isLoading, login, logout, updateUser } = useAuth()
// Just the user
const user = useUser()
// Boolean check
const isLoggedIn = useIsAuthenticated()
// User ID only
const userId = useCurrentUserId()
// Redirect to login if not authenticated
useRequireAuth('/login')
}Quick Switch (DevTools)
import { quickSwitch, quickLogout, getAppUsers } from 'protomobilekit'
// Switch user instantly (for testing)
function DevUserSwitcher() {
const users = getAppUsers('customer')
return (
<List
items={users}
renderItem={(user) => (
<ListItem onPress={() => quickSwitch('customer', user.id)}>
{user.name}
</ListItem>
)}
/>
)
}
// Logout from all apps
<Button onClick={() => quickLogout('customer')}>
Logout
</Button>Events
Global event bus for cross-component communication.
Basic Events
import { dispatch, subscribe, useEvent, useDispatch } from 'protomobilekit'
// Dispatch event (anywhere)
dispatch('order:created', { orderId: '123', total: 29.99 }, 'checkout')
// Subscribe to event (outside React)
const unsubscribe = subscribe('order:created', (payload, event) => {
console.log('Order created:', payload)
console.log('Event ID:', event.id)
console.log('Timestamp:', event.timestamp)
console.log('Source:', event.source)
})
// useEvent hook (in React)
function NotificationHandler() {
useEvent('order:created', (payload) => {
showToast(`Order ${payload.orderId} created!`)
})
return null
}
// useDispatch hook
function CheckoutButton() {
const dispatchEvent = useDispatch()
const handleCheckout = () => {
dispatchEvent('order:created', { orderId: '123' })
}
return <Button onClick={handleCheckout}>Checkout</Button>
}Typed Events
import { defineEvents, createEvent } from 'protomobilekit'
// Define typed events
const OrderEvents = defineEvents({
'order:created': (orderId: string, total: number) => ({ orderId, total }),
'order:updated': (orderId: string, changes: Partial<Order>) => ({ orderId, changes }),
'order:cancelled': (orderId: string, reason: string) => ({ orderId, reason }),
})
// Create type-safe dispatcher
const orderCreated = createEvent(OrderEvents, 'order:created')
// Use with full type safety
orderCreated.dispatch('order-123', 29.99)
// Subscribe with typed payload
orderCreated.subscribe((payload) => {
console.log(payload.orderId) // TypeScript knows this is string
console.log(payload.total) // TypeScript knows this is number
})Event History
import { getEventHistory, clearEventHistory, useEventHistory, useLatestEvent } from 'protomobilekit'
// Get all events
const history = getEventHistory()
// Clear history
clearEventHistory()
// React hooks
function EventDebugger() {
// All events
const allEvents = useEventHistory()
// Filtered events
const orderEvents = useEventHistory(['order:created', 'order:updated'])
// Latest event of type
const lastOrder = useLatestEvent<{ orderId: string }>('order:created')
return (
<List
items={allEvents}
renderItem={(event) => (
<ListItem>
{event.name}: {JSON.stringify(event.payload)}
</ListItem>
)}
/>
)
}Wildcard Subscription
// Subscribe to ALL events
subscribe('*', (payload, event) => {
console.log(`[${event.name}]`, payload)
})Forms
Complete form state management with validation.
useForm Hook
import { useForm, required, email, minLength, compose } from 'protomobilekit'
function RegistrationForm() {
const form = useForm({
values: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
validate: {
name: compose(required(), minLength(2)),
email: compose(required(), email()),
password: compose(required(), minLength(8)),
confirmPassword: compose(
required(),
match('password', 'Passwords must match')
),
},
validateOnBlur: true, // Validate when field loses focus
validateOnChange: false, // Don't validate on every keystroke
onSubmit: async (values) => {
await api.register(values)
},
onChange: (values) => {
console.log('Form changed:', values)
},
})
return (
<Form form={form}>
<FormField name="name" label="Full Name">
<Input placeholder="John Doe" />
</FormField>
<FormField name="email" label="Email">
<Input type="email" placeholder="[email protected]" />
</FormField>
<FormField name="password" label="Password">
<Input type="password" />
</FormField>
<FormField name="confirmPassword" label="Confirm Password">
<Input type="password" />
</FormField>
<FormActions>
<Button type="submit" loading={form.submitting}>
Register
</Button>
<Button variant="ghost" onClick={() => form.reset()}>
Reset
</Button>
</FormActions>
</Form>
)
}Built-in Validators
import {
required,
minLength,
maxLength,
email,
phone,
url,
pattern,
range,
min,
max,
match,
custom,
compose,
optional,
} from 'protomobilekit'
const validators = {
// Required field
name: required('Name is required'),
// String length
username: compose(
required(),
minLength(3, 'Min 3 characters'),
maxLength(20, 'Max 20 characters')
),
// Email validation
email: compose(required(), email('Invalid email')),
// Phone by country
phone: phone('us'), // 'us' | 'ru' | 'ua' | 'kz' | 'by' | 'default'
// URL validation
website: optional(url('Invalid URL')),
// Regex pattern
zipCode: pattern(/^\d{5}$/, 'Invalid zip code'),
// Number range
age: compose(required(), range(18, 120)),
quantity: compose(required(), min(1), max(100)),
// Match another field
confirmPassword: match('password', 'Passwords must match'),
// Custom validator
noSpaces: custom(
(value) => !value.includes(' '),
'No spaces allowed'
),
// Async validator
uniqueEmail: async(
async (email) => {
const exists = await api.checkEmail(email)
return !exists
},
'Email already exists'
),
}Form Components
import { Form, FormField, FormRow, FormSection, FormActions } from 'protomobilekit'
function ComplexForm() {
const form = useForm({ values: {...} })
return (
<Form form={form}>
{/* Section grouping */}
<FormSection title="Personal Info" description="Your basic information">
{/* Horizontal row */}
<FormRow>
<FormField name="firstName" label="First Name">
<Input />
</FormField>
<FormField name="lastName" label="Last Name">
<Input />
</FormField>
</FormRow>
<FormField name="email" label="Email" helper="We'll never share it">
<Input type="email" />
</FormField>
<FormField name="bio" label="Bio" optional>
<TextArea rows={3} />
</FormField>
</FormSection>
<FormSection title="Preferences">
<FormField name="notifications" label="Notifications">
<Switch />
</FormField>
<FormField name="theme" label="Theme">
<Select
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
/>
</FormField>
</FormSection>
<FormActions align="right">
<Button variant="ghost" onClick={() => form.reset()}>Cancel</Button>
<Button type="submit">Save</Button>
</FormActions>
</Form>
)
}Form State Access
const form = useForm({ values: {...} })
// Read values
form.values // All values
form.getValue('email') // Single value
// Set values
form.setValue('email', '[email protected]')
form.setValues({ email: '[email protected]', name: 'New Name' })
// Errors
form.errors // All errors
form.getError('email') // Single error
form.setError('email', 'Custom error')
form.setErrors({ email: 'Error 1', name: 'Error 2' })
// Touched state
form.touched // All touched
form.isTouched('email') // Single field
form.setTouched('email') // Mark as touched
// Form state
form.dirty // Any field changed from initial
form.valid // No errors
form.submitting // Submit in progress
form.submitted // Submit completed
// Validation
await form.validateField('email')
const isValid = await form.validateAll()
// Operations
await form.submit() // Validate + call onSubmit
form.reset() // Reset to initial values
form.reset({ email: '' }) // Reset with new values
form.clear() // Empty all fields
// Field props (for custom binding)
const props = form.getFieldProps('email')
// { value, onChange, onBlur, error, touched, disabled }
const props = form.field('email') // AliasSpecialized Form Inputs
import { PhoneInput, OTPInput, PinInput } from 'protomobilekit'
// Phone input with country code
<FormField name="phone" label="Phone">
<PhoneInput
defaultCountry="US"
placeholder="(555) 123-4567"
/>
</FormField>
// OTP input (verification code)
<OTPInput
length={6}
onComplete={(code) => verifyCode(code)}
autoFocus
/>
// PIN input (secure)
<PinInput
length={4}
secure // Hide digits
onComplete={(pin) => verifyPin(pin)}
/>Form Wizard
import { FormWizard, useWizard } from 'protomobilekit'
function MultiStepForm() {
return (
<FormWizard
steps={[
{
id: 'personal',
title: 'Personal Info',
component: PersonalInfoStep,
},
{
id: 'address',
title: 'Address',
component: AddressStep,
},
{
id: 'payment',
title: 'Payment',
component: PaymentStep,
},
]}
onComplete={(data) => submitForm(data)}
/>
)
}
function PersonalInfoStep() {
const { next, data, setData } = useWizard()
return (
<div>
<Input
value={data.name || ''}
onChange={(e) => setData({ name: e.target.value })}
/>
<Button onClick={next}>Continue</Button>
</div>
)
}Frames & Flows
Define screen frames and user flows for documentation and testing.
Define Frames
import { defineFrames, createFrame } from 'protomobilekit'
// Create reusable frame definitions
const homeFrame = createFrame({
id: 'home',
name: '1.1 Home',
description: 'Restaurant list with search and filters',
component: HomeScreen,
tags: ['main', 'entry'],
})
const menuFrame = createFrame({
id: 'menu',
name: '1.2 Menu',
description: 'Restaurant menu with categories',
component: MenuScreen,
tags: ['menu'],
// Custom navigation handler
onNavigate: (nav) => {
nav.navigate('menu', { restaurantId: 'demo' })
},
})
const cartFrame = createFrame({
id: 'cart',
name: '1.3 Cart',
description: 'Shopping cart with order summary',
component: CartScreen,
tags: ['checkout'],
})
// Register frames for an app
defineFrames({
appId: 'customer',
appName: 'Customer App',
initial: 'home',
frames: [homeFrame, menuFrame, cartFrame],
})Define Flows
import { defineFlow, getFlowProgress, toggleStepComplete, toggleTaskComplete } from 'protomobilekit'
// Define user flow (journey)
defineFlow({
id: 'order-flow',
name: 'Order Journey',
description: 'Complete flow from browsing to order delivery',
appId: 'customer',
steps: [
{
frame: homeFrame,
tasks: ['Browse restaurants', 'Use search', 'Apply filters'],
},
{
frame: menuFrame,
tasks: ['View menu', 'Add items to cart', 'Customize order'],
},
{
frame: cartFrame,
tasks: ['Review order', 'Apply promo code', 'Proceed to checkout'],
},
{
frame: checkoutFrame,
tasks: ['Enter address', 'Select payment', 'Place order'],
},
],
})
// Track flow progress
const progress = getFlowProgress('order-flow')
// { stepIndex: 1, completedSteps: [0], completedTasks: { 0: [0, 1] } }
// Toggle step completion
toggleStepComplete('order-flow', 0) // Toggle step 0
// Toggle task completion
toggleTaskComplete('order-flow', 1, 2) // Toggle task 2 in step 1Frame Registry Access
import {
getAllApps,
getAppFrames,
getFrame,
searchFrames,
navigateToFrame,
} from 'protomobilekit'
// Get all registered apps
const apps = getAllApps()
// [{ appId, appName, frames, initial }, ...]
// Get frames for specific app
const customerFrames = getAppFrames('customer')
// Get specific frame
const frame = getFrame('customer', 'home')
// Search frames
const results = searchFrames('menu')
// [{ app: AppFrames, frame: Frame }, ...]
// Navigate to frame programmatically
navigateToFrame('customer', 'cart')Frame Hooks
import { useFrameRegistry, useAppFrames, useFrame } from 'protomobilekit'
function FrameList() {
// All apps with frames
const { apps, frameCount } = useFrameRegistry()
// Frames for specific app
const frames = useAppFrames('customer')
// Specific frame
const frame = useFrame('customer', 'home')
return (...)
}UI Components
Layout Components
Screen
Main screen wrapper with header/footer support.
import { Screen, Header, BackButton } from 'protomobilekit'
<Screen
header={<Header title="Home" />}
footer={<TabBar />}
scrollable={true} // Enable scrolling (default: true)
padding={true} // Add padding (default: false)
>
<Content />
</Screen>
// With back button
<Screen
header={
<Header
title="Details"
left={<BackButton />}
right={<IconButton icon={<SettingsIcon />} onPress={openSettings} />}
/>
}
>Header
<Header
title="Page Title"
subtitle="Optional subtitle"
left={<BackButton />}
right={<IconButton icon={<MenuIcon />} onPress={...} />}
transparent={false}
large={false} // iOS large title style
/>ScrollView
<ScrollView
horizontal={false}
showsScrollIndicator={true}
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} />}
>
<Content />
</ScrollView>Section
<Section
title="Section Title"
subtitle="Optional description"
action={<TextButton onPress={...}>See All</TextButton>}
>
<Content />
</Section>Card
<Card
variant="elevated" // 'elevated' | 'outlined' | 'filled'
padding="md" // 'none' | 'sm' | 'md' | 'lg'
onPress={() => ...} // Makes card clickable
>
<Content />
</Card>Divider & Spacer
<Divider />
<Divider label="Or continue with" />
<Spacer size={16} /> // Fixed size in px
<Spacer size="md" /> // Preset: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
<Spacer size="flex" /> // Flexible spacer (flex: 1)Interactive Components
Button
<Button
variant="primary" // 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link'
size="md" // 'sm' | 'md' | 'lg'
fullWidth={false}
loading={false}
disabled={false}
icon={<PlusIcon />}
iconRight={<ArrowIcon />}
onClick={() => ...}
>
Button Text
</Button>
// Text button (iOS style)
<TextButton
color="primary" // 'primary' | 'danger' | 'secondary'
onPress={() => ...}
>
Cancel
</TextButton>
// Icon button
<IconButton
icon={<DeleteIcon />}
variant="danger"
size="md"
onPress={() => ...}
/>Input
<Input
label="Email"
placeholder="[email protected]"
type="email" // HTML input types
size="md" // 'sm' | 'md' | 'lg'
variant="outline" // 'outline' | 'filled' | 'underline'
error="Invalid email"
helper="We'll never share your email"
leftAddon={<EmailIcon />}
rightAddon={<ClearButton />}
disabled={false}
/>
// TextArea
<TextArea
label="Description"
rows={4}
maxLength={500}
showCount
/>Select
<Select
label="Country"
placeholder="Select country"
value={country}
onChange={setCountry}
options={[
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'de', label: 'Germany' },
]}
error="Please select a country"
/>
// Searchable select
<SearchableSelect
label="City"
options={cities}
value={city}
onChange={setCity}
searchPlaceholder="Search cities..."
emptyMessage="No cities found"
/>
// Autocomplete (with custom input)
<Autocomplete
label="Product"
options={suggestions}
value={product}
onChange={setProduct}
onSearch={(query) => fetchSuggestions(query)}
renderOption={(option) => (
<div>{option.label} - ${option.price}</div>
)}
/>Switch & Checkbox
<Switch
label="Notifications"
value={enabled}
onChange={setEnabled}
/>
<Checkbox
label="I agree to terms"
checked={agreed}
onChange={setAgreed}
/>
// Radio group
<RadioGroup
label="Payment Method"
value={payment}
onChange={setPayment}
options={[
{ value: 'card', label: 'Credit Card' },
{ value: 'cash', label: 'Cash on Delivery' },
{ value: 'wallet', label: 'Digital Wallet' },
]}
/>Slider
<Slider
label="Volume"
value={volume}
onChange={setVolume}
min={0}
max={100}
step={1}
showValue
/>SearchBar
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search..."
onSubmit={(query) => search(query)}
showCancel
onCancel={() => setQuery('')}
/>Data Display
Text
Typography component. Renders as <span> (inline) by default.
// Basic usage (inline by default)
<Text>Inline text</Text> // → <span>...</span>
<Text secondary>Secondary color</Text>
<Text primary bold>Primary bold</Text>
<Text danger>Error message</Text>
// Sizes
<Text size="xs">Extra small</Text>
<Text size="sm">Small</Text>
<Text size="md">Medium (default)</Text>
<Text size="lg">Large</Text>
<Text size="xl">Extra large</Text>
<Text size="2xl">2X large</Text>
// Weights
<Text light>Light</Text>
<Text medium>Medium</Text>
<Text semibold>Semibold</Text>
<Text bold>Bold</Text>
// Block display (renders as <div> automatically)
<Text block>Block-level text</Text> // → <div>...</div>
// Change HTML element with 'as' prop
<Text as="p">Paragraph</Text> // → <p>...</p>
<Text as="h1" size="2xl" bold>Heading</Text> // → <h1>...</h1>
<Text as="div">Div wrapper</Text> // → <div>...</div>
<Text as="label">Form label</Text> // → <label>...</label>
// Alignment
<Text center>Centered</Text>
<Text right>Right aligned</Text>
// Shorthand components (pre-configured)
<Title>Page Title</Title> // h2, xl, bold
<Subtitle>Section</Subtitle> // h3, lg, semibold
<Paragraph>Body text</Paragraph> // p, block element
<Caption>Small note</Caption> // xs, secondary
<Label>Form Label</Label> // label, sm, mediumList & ListItem
<List
items={orders}
keyExtractor={(o) => o.id}
renderItem={(order, index) => (
<ListItem
left={<Avatar src={order.avatar} />}
right={<Badge>{order.status}</Badge>}
subtitle={`$${order.total}`}
description={order.address}
onPress={() => openOrder(order.id)}
showChevron
>
Order #{order.id}
</ListItem>
)}
dividers={true}
dividerInset="left" // 'none' | 'left' | 'both'
header="Recent Orders"
footer="Load more..."
emptyContent={<Text>No orders yet</Text>}
/>
// MenuItem (for settings-like lists)
<MenuItem
label="Account Settings"
value="John Doe"
icon={<UserIcon />}
onPress={() => navigate('settings')}
/>Avatar
<Avatar
src="https://example.com/photo.jpg"
name="John Doe" // Fallback initials
size="md" // 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/>
// Avatar group
<AvatarGroup
avatars={[
{ src: '...', name: 'Alice' },
{ src: '...', name: 'Bob' },
{ src: '...', name: 'Charlie' },
]}
max={3}
size="sm"
/>Badge & Chip
<Badge variant="primary">New</Badge>
<Badge variant="success">Active</Badge>
<Badge variant="warning">Pending</Badge>
<Badge variant="danger">Error</Badge>
<Badge variant="info">Info</Badge>
<Chip
label="React"
onPress={() => selectTag('react')}
selected={selectedTags.includes('react')}
dismissible
onDismiss={() => removeTag('react')}
/>Status Badges
// Generic status badge
<StatusBadge
status="active"
config={{
active: { label: 'Active', color: '#22c55e' },
inactive: { label: 'Inactive', color: '#ef4444' },
pending: { label: 'Pending', color: '#f59e0b' },
}}
/>
// Pre-built status badges
<OrderStatusBadge status="delivered" />
<UserStatusBadge status="online" />InfoRow & InfoGroup
<InfoRow label="Email" value="[email protected]" />
<InfoRow label="Phone" value="+1 234 567 890" copyable />
<InfoRow label="Website" value="example.com" onPress={() => openUrl('...')} />
<InfoGroup
items={[
{ label: 'Name', value: 'John Doe' },
{ label: 'Email', value: '[email protected]' },
{ label: 'Role', value: 'Admin' },
]}
/>StatCard & DashboardStats
<StatCard
title="Total Orders"
value={1234}
change={+12.5} // Percentage change
icon={<OrdersIcon />}
/>
<StatGrid
stats={[
{ title: 'Orders', value: 1234, change: +5 },
{ title: 'Revenue', value: '$12,345', change: +12 },
{ title: 'Customers', value: 567, change: -2 },
]}
columns={3}
/>
<DashboardStats
stats={[...]}
layout="grid" // 'grid' | 'row'
/>Tabs
<Tabs
tabs={[
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active', badge: 5 },
{ id: 'completed', label: 'Completed' },
]}
activeTab={activeTab}
onChange={setActiveTab}
/>
// Tab bar (bottom navigation style)
<TabBar
items={[
{ id: 'home', label: 'Home', icon: <HomeIcon /> },
{ id: 'orders', label: 'Orders', icon: <OrdersIcon />, badge: 3 },
{ id: 'profile', label: 'Profile', icon: <UserIcon /> },
]}
activeItem={activeTab}
onChange={setActiveTab}
/>Accordion
<AccordionGroup>
<AccordionItem title="Section 1" defaultOpen>
<Text>Content 1</Text>
</AccordionItem>
<AccordionItem title="Section 2">
<Text>Content 2</Text>
</AccordionItem>
</AccordionGroup>
// Single accordion
<Accordion
title="Show Details"
open={isOpen}
onChange={setIsOpen}
>
<Content />
</Accordion>Carousel
<Carousel
items={[
{ id: '1', image: '...', title: 'Slide 1' },
{ id: '2', image: '...', title: 'Slide 2' },
]}
renderItem={(item) => (
<div>
<img src={item.image} />
<Text>{item.title}</Text>
</div>
)}
autoPlay
interval={5000}
showDots
showArrows
/>Menus
// Dropdown menu
<DropdownMenu
trigger={<IconButton icon={<MoreIcon />} onPress={() => {}} />}
items={[
{ label: 'Edit', icon: <EditIcon />, onPress: () => ... },
{ label: 'Share', icon: <ShareIcon />, onPress: () => ... },
{ type: 'divider' },
{ label: 'Delete', icon: <DeleteIcon />, onPress: () => ..., dest