Awesome-omni-skill apprun-skills
End-to-end guidance for AppRun apps in TypeScript using MVU: component patterns, event handling, state management (including async generators), routing/navigation with params and guards, and testing with vitest. Use when designing or reviewing AppRun components, wiring routes, managing state flows, or writing AppRun tests.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/apprun-skills" ~/.claude/skills/diegosouzapw-awesome-omni-skill-apprun-skills && rm -rf "$T"
skills/development/apprun-skills/SKILL.mdAppRun Skills
Overview
- Build AppRun apps with MVU (Model-View-Update) in TypeScript.
- Prefer pure update functions for testability.
- Use
for components embedded in JSX.mounted() - Use
only for top-level routed pages that must load async data.state = async
Project Setup
Recommended Project Structure
web/ # Frontend application root ├── index.html # Entry HTML file ├── package.json # Dependencies and scripts ├── vite.config.js # Vite configuration ├── src/ │ ├── main.tsx # Application entry point (routes registration) │ ├── api.ts # REST API client (optional) │ ├── styles.css # Application styles │ ├── tsconfig.json # TypeScript configuration │ ├── components/ # Reusable UI components │ │ ├── Layout.tsx # Root layout container │ │ └── ... # Other reusable components │ ├── domain/ # Business logic modules (optional) │ │ └── ... # Pure functions and business logic │ ├── pages/ # Top-level page components │ │ ├── Home.tsx # Example: Home page │ │ └── ... # Other route pages │ ├── types/ # TypeScript type definitions │ │ ├── index.ts # Shared types │ │ └── jsx.d.ts # JSX type declarations │ └── utils/ # Utility functions └── public/ # Static assets (optional)
Vite Configuration
import { defineConfig } from 'vite' export default defineConfig({ build: { outDir: 'dist', emptyOutDir: true, }, server: { port: 8080, open: true, historyApiFallback: true, // SPA mode proxy: { // Proxy API requests to backend '/api': { target: 'http://127.0.0.1:3000', changeOrigin: true, secure: false } } } })
Package.json
{ "name": "my-apprun-app", "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "check": "tsc --noEmit" }, "devDependencies": { "apprun": "^3.38.0", "typescript": "^5.0.0", "vite": "^5.0.0" } }
TypeScript Configuration
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "jsx": "react", "jsxFactory": "app.createElement", "jsxFragmentFactory": "app.Fragment", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] }
Critical Settings for AppRun:
- Enables JSX syntaxjsx: "react"
- Uses AppRun's JSX factoryjsxFactory: "app.createElement"
- Uses AppRun's Fragment supportjsxFragmentFactory: "app.Fragment"
- Optimized for VitemoduleResolution: "bundler"
Entry Points
HTML Entry (
):index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>My AppRun App</title> </head> <body> <div id="root"></div> <script type="module" src="src/main.tsx"></script> </body> </html>
Application Entry (
):src/main.tsx
import app from 'apprun'; import Layout from './components/Layout'; import Home from './pages/Home'; import About from './pages/About'; import './styles.css'; app.render('#root', <Layout />); app.addComponents('#pages', { '/': Home, '/about': About, });
Layout Component (
):src/components/Layout.tsx
import app from 'apprun'; export default () => ( <div id="app"> <div id="pages"></div> </div> );
Styling Options
Option 1: Vanilla CSS
/* src/styles.css */ :root { --color-primary: #007bff; --color-text: #333; --spacing-unit: 8px; } body { font-family: system-ui, -apple-system, sans-serif; color: var(--color-text); margin: 0; padding: 0; }
Option 2: Tailwind CSS v4
Install Tailwind v4:
npm install -D tailwindcss@next @tailwindcss/vite@next
Update
vite.config.js:
import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [tailwindcss()], // ... other config })
Import in
src/styles.css:
@import "tailwindcss";
Use in components:
<div className="flex items-center gap-4 p-4 bg-white rounded-lg shadow"> <h1 className="text-2xl font-bold">Hello World</h1> </div>
Option 3: CSS Modules
import styles from './MyComponent.module.css'; export default () => ( <div className={styles.container}> <h1 className={styles.title}>Hello</h1> </div> );
API Client Pattern
// src/api.ts const API_BASE_URL = '/api'; interface RequestOptions extends RequestInit { params?: Record<string, string>; } async function request<T>( endpoint: string, options: RequestOptions = {} ): Promise<T> { const { params, ...fetchOptions } = options; let url = `${API_BASE_URL}${endpoint}`; if (params) { const query = new URLSearchParams(params).toString(); url += `?${query}`; } const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...fetchOptions.headers, }, ...fetchOptions, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.message || `HTTP ${response.status}`); } return response.json(); } export const api = { get: <T>(endpoint: string, params?: Record<string, string>) => request<T>(endpoint, { method: 'GET', params }), post: <T>(endpoint: string, data?: unknown) => request<T>(endpoint, { method: 'POST', body: JSON.stringify(data), }), put: <T>(endpoint: string, data?: unknown) => request<T>(endpoint, { method: 'PUT', body: JSON.stringify(data), }), delete: <T>(endpoint: string) => request<T>(endpoint, { method: 'DELETE' }), }; export default api;
Quick Start
# 1. Create project npm create vite@latest my-apprun-app -- --template vanilla-ts cd my-apprun-app # 2. Install AppRun npm install npm install -D apprun # 3. Configure TypeScript (update tsconfig.json with settings above) # 4. Rename entry file mv src/main.ts src/main.tsx # 5. Create basic app structure # (Add Layout, pages, components as shown above) # 6. Run development server npm run dev # 7. Build for production npm run build npm run preview
Why Vite + AppRun?
Why Vite:
- Fast development with instant HMR
- Optimized builds with Rollup
- First-class TypeScript support
- Minimal configuration
Why AppRun:
- Lightweight (~7KB gzipped)
- Simple MVU pattern
- Direct DOM updates (no virtual DOM)
- Full TypeScript support
- Built-in routing
Component Patterns - Decision Tree
- Manages state + user interactions? → Stateful Class Component
- Popup/modal/overlay? → Modal Component (use
)mounted() - Display-only from props? → Functional Component
- 10+ events needing type safety? → Typed Events Pattern
Stateful Class Component
Structure Order: Imports → Interfaces → Helpers → Actions → Component
import { app, Component } from 'apprun'; interface Props { data?: any; } export interface State { loading: boolean; error: string | null; successMessage?: string; // ... specific fields } const getStateFromProps = (props: Props): State => ({ /* ... */ }); export const saveData = async function* (state: State): AsyncGenerator<State> { // Validation if (!state.data.name.trim()) { yield { ...state, error: 'Name required' }; return; } // Loading yield { ...state, loading: true, error: null }; // API call try { await api.save(state.data); yield { ...state, loading: false, successMessage: 'Saved!' }; app.run('data-saved'); } catch (error: any) { yield { ...state, loading: false, error: error.message }; } }; export default class MyComponent extends Component<State> { declare props: Readonly<Props>; mounted = (props: Props): State => getStateFromProps(props); view = (state: State) => { if (state.loading) return <div>Loading...</div>; if (state.error) return <div className="error">{state.error}</div>; return ( <form> <input $bind="data.name" /> <button $onclick={[saveData]} disabled={state.loading}>Save</button> </form> ); }; }
View Pattern: Guard clauses → Early returns → Main content
Modal Component
CRITICAL: Must use
mounted() (embedded in JSX), not state = async
export default class Modal extends Component<State> { declare props: Readonly<Props>; mounted = (props: Props): State => getStateFromProps(props); view = (state: State) => ( <div className="modal-backdrop" onclick={closeModal}> <div className="modal-content" onclick={(e) => e.stopPropagation()}> <button onclick={closeModal}>×</button> {/* content */} </div> </div> ); }
Requirements: Close button + backdrop click + stopPropagation
Functional Component
export interface Props { data: DataType[]; onItemClick?: (item: DataType) => void; } export default function DisplayComponent({ data, onItemClick }: Props) { if (!data?.length) return <div>No items</div>; return ( <ul> {data.map(item => ( <li onclick={() => onItemClick?.(item)}>{item.name}</li> ))} </ul> ); }
Pattern: Destructure → Guard clauses → Main render
Typed Events Pattern
Payload Rules:
- Single value →
| Call:payload: string$onclick={['delete', id]} - Multiple values →
| Call:payload: { id: string; name: string }$onclick={['edit', { id, name }]} - No payload →
| Call:payload: void$onclick="save" - Input events →
payload: { target: { value: string } }
// types/events.ts export type MyEvents = | { name: 'save'; payload: void } | { name: 'delete'; payload: string } | { name: 'edit'; payload: { id: string; name: string } }; export type MyEventName = MyEvents['name']; // Component class MyComponent extends Component<State, MyEventName> { override update = myHandlers; } // Handlers (OBJECT format, not array) export const myHandlers: Update<State, MyEventName> = { save: (state): State => ({ ...state, saved: true }), delete: (state, id: string): State => ({ ...state, items: state.items.filter(i => i.id !== id) }), edit: (state, { id, name }: { id: string; name: string }): State => ({ ...state, editing: { id, name } }) };
stopPropagation: Add event as last parameter
'click-item': (state, id: string, e?: Event): State => { e?.stopPropagation(); return { ...state, selected: id }; }
Event Directives
AppRun Directives (Trigger Update Handlers)
| Directive | Use Case | Example |
|---|---|---|
| Two-way binding (PREFERRED for forms) | |
| Nested property | |
| String action | |
| Action with params | |
| Direct function | |
| Custom input handling | |
Other directives:
$onchange, $onsubmit, $onfocus, $onblur, $onkeydown
Standard HTML Events (DOM Manipulation)
Use
onclick, oninput, etc. for direct DOM manipulation only:
<div onclick={(e) => e.stopPropagation()}>Content</div>
When to Use What
- ✅
- Simple form fields (no handler needed)$bind - ✅
- Validation, transformation, debouncing$oninput - ✅
- Trigger update handlers$onclick - ❌ Never -
$onclick={() => app.run('action')}
Validation Example:
$oninput="validate-email" 'validate-email': (state, e: Event) => { const email = (e.target as HTMLInputElement).value; const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return { ...state, email, emailError: valid ? null : 'Invalid' }; }
Update Handlers
Sync: Return new state
'increment': (state) => ({ ...state, count: state.count + 1 })
Async: Use
async
'load': async (state) => { this.setState({ ...state, loading: true }); const data = await api.fetch(); return { ...state, data, loading: false }; }
Generator: Multi-step with intermediate renders (PREFERRED for complex flows)
'save': async function* (state) { yield { ...state, loading: true }; await api.save(state.data); yield { ...state, loading: false, success: true }; }
Side Effects: No return = no re-render
'navigate': (state) => { window.location.href = '/path'; // No return - no re-render }
Component Communication
| Pattern | Use Case | Implementation |
|---|---|---|
| Props | Parent → Child | Pass data via props |
| Callbacks | Child → Parent | Pass function via props |
| Global Events | Any → Any | |
Global Events:
// Modal component class Modal extends Component { is_global_event = () => true; update = { 'open-modal': (state, data) => ({ ...state, visible: true, data }), 'close-modal': (state) => ({ ...state, visible: false }) }; } // Any component can trigger <button onclick={() => app.run('open-modal', data)}>Open</button>
Critical Rules
State Initialization
| Component Type | Use | Example |
|---|---|---|
| JSX Embedded | | |
| Top-Level Routed | | |
❌ NEVER mix both
mounted() and state = async
State Updates
Returning state triggers re-render:
- Immutable (recommended):
return { ...state, field: value } - Mutable (allowed):
state.field = value; return state - Side effects only: Don't return (no re-render)
Required State Properties
interface State { loading: boolean; // For async operations error: string | null; // For error messages successMessage?: string; // For success feedback // ... specific fields }
Deep Cloning
// Nested object update return { ...state, user: { ...state.user, profile: { ...state.user.profile, name } } };
Anti-Patterns
❌ DON'T:
// Don't use $onclick with arrow functions calling app.run $onclick={() => app.run('action')} // Don't forget error handling in async async function save() { await api.save(); } // No try-catch! // Don't use manual input when $bind available $oninput={(e) => setState({ ...state, field: e.target.value })} // Don't use state = async for JSX embedded components class Modal extends Component { state = async () => { /* WRONG */ }; } // Don't forget defensive programming messages.map() // messages might be undefined - use messages?.map() // Don't use array format for update handlers update = [['event', handler]] // WRONG - use object format // Don't mutate state directly state.count++; // WRONG
Routing, Linking, and Component Registration
This section explains how AppRun applications handle routing, page navigation, and component registration.
Overview
The app uses AppRun's built-in routing system without any external router libraries. Routes are defined declaratively, and navigation uses standard HTML anchor tags or programmatic methods.
1. Component Registration
Routes are registered centrally in
main.tsx:
import app from 'apprun'; import Layout from './components/Layout'; import Home from './pages/Home'; import World from './pages/World'; app.render('#root', <Layout />); app.addComponents('#pages', { '/': Home, '/World': World, // '/Agent': Agent, // commented out // '/Settings': Settings, // commented out });
How It Works:
: Renders the top-level Layout component into theapp.render('#root', <Layout />)
DOM element#root
: Registers route-to-component mappingsapp.addComponents('#pages', {...})- Key: Route path (e.g.,
,'/'
)'/World' - Value: Component class (e.g.,
,Home
)World - Components are rendered into the
container defined in Layout#pages
- Key: Route path (e.g.,
2. Layout Container
The Layout component provides the rendering container for routed pages:
// web/src/components/Layout.tsx export default () => <div id="main" className="w-full min-h-screen"> <div id="pages"></div> </div>
- Minimal wrapper with full-width, full-height container
- The
div is where route components are dynamically rendered#pages - AppRun automatically swaps components based on the current route
3. Page Linking (Declarative Navigation)
The app uses standard HTML anchor tags for navigation:
Example from Home Component:
// Navigate to a specific world <a href={'/World/' + worldName}> <button className="btn btn-primary"> Enter {worldName} </button> </a>
Example from World Component:
// Navigate back to home <a href="/"> <button className="back-button" title="Back to Worlds"> <span className="world-back-icon">←</span> </button> </a>
How It Works:
- Standard
links trigger AppRun's routing<a href=""> - AppRun intercepts link clicks and updates the route without full page reload
- Route parameters (like world name) are included in the URL path
- No special Link component required—just plain HTML
4. Programmatic Navigation
Components can navigate programmatically using
window.location.href:
Example from Home Component Update Handler:
update = { 'enter-world': (state: HomeState, world: World): void => { // Navigate to the world page window.location.href = '/World/' + world.name; } }
When to Use:
- Inside event handlers that need to navigate after logic
- When navigation is a side effect (return
instead of new state)void - For conditional navigation based on user actions
5. Route Parameters
Routes can include dynamic parameters in the path:
URL Pattern:
/World/:worldName
Parsing Parameters:
Components can access route parameters from the URL:
// Example: /World/MyWorld const worldName = window.location.pathname.split('/')[2]; // "MyWorld"
Route Handler Pattern:
update = { '/World': async (state, worldName: string) => { // worldName is parsed from the URL return { ...state, worldName, // ... load world data }; } }
6. Component Architecture (MVU Pattern)
Page components follow AppRun's Model-View-Update pattern:
export default class PageComponent extends Component<StateType> { // 1. STATE: Initial data and loading states state = { loading: true, data: null, // ... }; // 2. VIEW: Render function that returns JSX view = (state: StateType) => { return <div> {/* JSX markup */} </div>; }; // 3. UPDATE: Event handlers update = { 'event-name': (state, payload) => { // Return new state to trigger re-render return { ...state, newData: payload }; }, 'navigation-event': (state) => { // Return void for side effects (no re-render) window.location.href = '/path'; } }; }
Key Principles:
- State: Plain object with component data
- View: Pure function that converts state to JSX
- Update: Event handlers that return new state or void
- Immutability: Always return new state objects, never mutate
7. Event System
Local vs Global Events:
Components can be configured to listen to global events:
export default class WorldComponent extends Component { // Make all events global (visible across components) override is_global_event = () => true; }
Event Propagation:
- Local events: Only visible within the component
- Global events: Can be triggered from child components or other parts of the app
- Use
to trigger events programmaticallyapp.run('event-name', payload)
Event Handler Types:
update = { // Returns new state → triggers re-render 'update-data': (state, newData) => ({ ...state, data: newData }), // Returns void → no re-render (side effect only) 'navigate': (state) => { window.location.href = '/path'; } }
8. Best Practices
Navigation:
- ✅ Use
for simple links<a href=""> - ✅ Use
for programmatic navigationwindow.location.href - ✅ Include route parameters in the path:
/World/${name} - ❌ Don't use client-side routing for external URLs
Component Registration:
- ✅ Register all routes in a single place (
)main.tsx - ✅ Use clear, semantic route paths
- ✅ Keep the route structure flat and simple
- ❌ Don't nest routes deeply
Event Handling:
- ✅ Return new state to trigger re-render
- ✅ Return void for navigation or side effects
- ✅ Use descriptive event names:
,'load-world''delete-chat' - ❌ Don't mutate state directly
URL Structure:
/ → Home page (world selection) /World/:name → World page (chat interface) /Agent/:id → Agent page (currently disabled) /Settings → Settings page (currently disabled)
9. Example Flow: Entering a World
Step 1: User clicks "Enter World" button on Home page
// Home.tsx <a href={'/World/' + world.name}> <button className="btn btn-primary"> Enter {world.name} </button> </a>
Step 2: AppRun intercepts the link and updates route
- URL changes to
/World/MyWorld - AppRun's router detects the route change
- Router looks up the registered component for
/World
Step 3: World component is mounted and initialized
// World.tsx update = { '/World': async (state, worldName: string) => { // Load world data from API const world = await api.getWorld(worldName); const messages = await api.getMessages(worldName); return { ...state, worldName, world, messages, loading: false }; } }
Step 4: World component renders with loaded data
- View function receives the updated state
- Chat interface displays with agents and messages
- Component is now interactive and listening for events
10. Debugging Tips
Check Current Route:
console.log(window.location.pathname); // "/World/MyWorld"
Monitor Route Changes:
app.on('//', (route) => { console.log('Route changed to:', route); });
Verify Component Registration:
// Check if component is registered for a route // Look for the component rendering in #pages container console.log(document.querySelector('#pages').innerHTML);
Summary
- Registration:
app.addComponents('#pages', { path: Component }) - Navigation: Use
or<a href="">window.location.href - Route Params: Parsed from URL path in route handlers
- Component Pattern: MVU (Model-View-Update)
- Events: Local by default, can be made global with
is_global_event() - No Router Library: AppRun's built-in routing handles everything
Testing (Vitest)
- Unit test pure update functions.
- Iterate async generators to capture each yield.
- Mock APIs with
.vi.mock
import { describe, it, expect, vi } from 'vitest'; import { save } from './Form'; import api from '../api'; vi.mock('../api'); describe('save', () => { it('yields validation then stops', async () => { const state = { loading: false, error: null, form: { name: '' } } as State; const gen = save(state); const first = await gen.next(); expect(first.value?.error).toBe('Name is required'); }); });
Development Checklist
Component Structure
- Imports at top
- Props interface with
for optional? - State interface (exported)
- Helper functions
- Action functions (exported for
and testing)$onclick - Component class with
ormountedstate = async
TypeScript Types
- Props interface
- State interface exported
- Event types for 10+ events (discriminated union)
- Generic types:
Component<State, EventName> - Async generators:
AsyncGenerator<State>
View Method
- Guard clauses first (loading, error, success)
- Early returns for special states
- Main content last
- Defensive programming (
, defaults)data?.map()
State Management
- Include
,loading
,errorsuccessMessage? - Return new state to re-render
- Use
for JSX embeddedmounted() - Use
only for routed pagesstate = async - Never mix both
Event Handling
- Use
for simple forms$bind - Use
(not$onclick
)onclick={() => app.run()} - Export action functions for reusability
- Use async generators for multi-step
- Add try-catch in async functions
Error Handling
- Try-catch in async operations
- Error state in interface
- Error display in view
- Loading states during async
- Success messages
Best Practices
- Keep update logic pure when possible
- Use global events for cross-component
- Add catch-all route for 404
- Test update logic and error paths
- Use descriptive event names
Quick Reference
Component Selection:
- State + interactions → Stateful Class
- Modal/popup → Modal Component (
)mounted() - Display only → Functional
- 10+ events → Typed Events
State Init:
- JSX embedded →
mounted() - Routed page →
state = async
Events:
for forms (preferred)$bind
for actions$onclick- Typed for large components
Updates:
- Return state → re-render
- No return → side effect
- Generators → multi-step
Communication:
- Props: parent → child
- Callbacks: child → parent
- Global: any → any