UI Generation
December 4, 2025 · View on GitHub
Praxis automatically generates Svelte components from your schema definitions. This document explains how component generation works and how to customize the output.
Overview
From a single schema definition, Praxis generates:
- Svelte 5 components with runes
- TypeScript type definitions
- Data binding and validation
- Event handlers
- Documentation
flowchart LR
Schema[PSF Schema] --> Generator[Component Generator]
Generator --> Svelte[Svelte Components]
Generator --> Types[TypeScript Types]
Generator --> Docs[Documentation]
Component Types
Form Components
Forms are generated from model definitions:
Schema:
{
"components": [
{
"id": "comp_user_form",
"name": "UserForm",
"type": "form",
"model": "User",
"description": "Form for creating users"
}
]
}
Generated Component:
<script lang="ts">
import type { User } from '../models';
interface Props {
user?: User;
onsubmit?: (user: User) => void;
oncancel?: () => void;
}
let { user = $bindable(), onsubmit, oncancel }: Props = $props();
let formData = $state({
username: user?.username ?? '',
email: user?.email ?? '',
role: user?.role ?? 'user',
});
let errors = $state<Record<string, string>>({});
function validate(): boolean {
errors = {};
if (!formData.username) errors.username = 'Username is required';
if (!formData.email) errors.email = 'Email is required';
return Object.keys(errors).length === 0;
}
function handleSubmit(e: Event) {
e.preventDefault();
if (validate()) {
onsubmit?.(formData as User);
}
}
</script>
<form onsubmit={handleSubmit}>
<div class="field">
<label for="username">Username</label>
<input id="username" bind:value={formData.username} />
{#if errors.username}<span class="error">{errors.username}</span>{/if}
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" bind:value={formData.email} />
{#if errors.email}<span class="error">{errors.email}</span>{/if}
</div>
<div class="field">
<label for="role">Role</label>
<select id="role" bind:value={formData.role}>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="guest">Guest</option>
</select>
</div>
<div class="actions">
<button type="submit">Save</button>
<button type="button" onclick={oncancel}>Cancel</button>
</div>
</form>
Display Components
Display components show data in read-only format:
Schema:
{
"components": [
{
"id": "comp_user_card",
"name": "UserCard",
"type": "display",
"model": "User",
"description": "Displays user information"
}
]
}
Generated Component:
<script lang="ts">
import type { User } from '../models';
interface Props {
user: User;
onclick?: (user: User) => void;
}
let { user, onclick }: Props = $props();
</script>
<div class="user-card" onclick={() => onclick?.(user)}>
<h3>{user.username}</h3>
<p class="email">{user.email}</p>
<span class="role">{user.role}</span>
</div>
List Components
List components display collections:
Schema:
{
"components": [
{
"id": "comp_user_list",
"name": "UserList",
"type": "list",
"model": "User",
"props": [
{ "name": "users", "type": "User[]", "required": true },
{ "name": "filter", "type": "string", "required": false }
]
}
]
}
Generated Component:
<script lang="ts">
import type { User } from '../models';
import UserCard from './UserCard.svelte';
interface Props {
users: User[];
filter?: string;
onselect?: (user: User) => void;
}
let { users, filter = '', onselect }: Props = $props();
let filtered = $derived(
filter
? users.filter(u => u.username.includes(filter))
: users
);
</script>
<div class="user-list">
{#each filtered as user (user.id)}
<UserCard {user} onclick={onselect} />
{/each}
{#if filtered.length === 0}
<p class="empty">No users found</p>
{/if}
</div>
Editor Components
Editor components provide rich editing capabilities:
Schema:
{
"components": [
{
"id": "comp_form_builder",
"name": "FormBuilder",
"type": "editor",
"description": "Visual form builder"
}
]
}
Customizing Components
Custom Props
Define custom props in the schema:
{
"components": [
{
"id": "comp_product_card",
"name": "ProductCard",
"type": "display",
"model": "Product",
"props": [
{ "name": "product", "type": "Product", "required": true },
{ "name": "showPrice", "type": "boolean", "default": true },
{ "name": "currency", "type": "string", "default": "USD" }
]
}
]
}
Custom Events
Define events the component emits:
{
"components": [
{
"id": "comp_cart_item",
"name": "CartItem",
"type": "display",
"props": [{ "name": "item", "type": "CartItem", "required": true }],
"events": [
{ "name": "remove", "payload": "{ itemId: string }" },
{ "name": "updateQuantity", "payload": "{ itemId: string; quantity: number }" }
]
}
]
}
Extending Generated Components
Generated components can be extended without modifying them:
<!-- CustomProductCard.svelte -->
<script lang="ts">
import ProductCard from './generated/ProductCard.svelte';
import type { Product } from './generated/models';
interface Props {
product: Product;
}
let { product }: Props = $props();
</script>
<div class="custom-wrapper">
<ProductCard {product} showPrice={true} currency="EUR" />
<div class="custom-badge">New!</div>
</div>
Field Type Mapping
Praxis maps schema field types to HTML input types:
| Schema Type | HTML Input | Component |
|---|---|---|
string | <input type="text"> | Text input |
number | <input type="number"> | Number input |
boolean | <input type="checkbox"> | Checkbox |
datetime | <input type="datetime-local"> | Date picker |
{ enum: [...] } | <select> | Dropdown |
{ array: {...} } | Custom | Array editor |
object | Nested form | Fieldset |
uuid | Hidden | Auto-generated |
Validation
Generated forms include validation based on field definitions:
Schema:
{
"models": [
{
"name": "User",
"fields": [
{ "name": "email", "type": "string", "validation": { "format": "email" } },
{ "name": "age", "type": "number", "validation": { "min": 0, "max": 150 } },
{ "name": "username", "type": "string", "validation": { "minLength": 3, "maxLength": 20 } }
]
}
]
}
Generated Validation:
<script lang="ts">
function validate(): boolean {
errors = {};
if (!isValidEmail(formData.email)) {
errors.email = 'Invalid email format';
}
if (formData.age < 0 || formData.age > 150) {
errors.age = 'Age must be between 0 and 150';
}
if (formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
if (formData.username.length > 20) {
errors.username = 'Username must be at most 20 characters';
}
return Object.keys(errors).length === 0;
}
</script>
Svelte 5 Runes
Generated components use Svelte 5 runes for reactivity:
$props
Component props use the $props rune:
<script lang="ts">
interface Props {
user: User;
editable?: boolean;
}
let { user, editable = false }: Props = $props();
</script>
$state
Local state uses the $state rune:
<script lang="ts">
let formData = $state({
name: '',
email: ''
});
let errors = $state<Record<string, string>>({});
</script>
$derived
Computed values use the $derived rune:
<script lang="ts">
let filteredItems = $derived(
items.filter(item => item.name.includes(searchTerm))
);
let total = $derived(
items.reduce((sum, item) => sum + item.price, 0)
);
</script>
$bindable
Two-way binding uses $bindable:
<script lang="ts">
interface Props {
value?: string;
}
let { value = $bindable('') }: Props = $props();
</script>
<input bind:value />
Integration with Logic Engine
Generated components integrate with the Praxis engine:
<script lang="ts">
import { usePraxisEngine } from '@plures/praxis/svelte';
import { engine } from '../engine';
import { AddToCart, RemoveFromCart } from '../generated/events';
const { context, dispatch } = usePraxisEngine(engine);
function handleAddToCart(productId: string) {
dispatch([AddToCart.create({ productId, quantity: 1 })]);
}
function handleRemoveFromCart(itemId: string) {
dispatch([RemoveFromCart.create({ itemId })]);
}
</script>
<ProductList
products={context.products}
onaddtocart={handleAddToCart}
/>
<ShoppingCart
items={context.cart.items}
onremove={handleRemoveFromCart}
/>
Styling
Default Styles
Generated components include minimal default styles:
<style>
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.25rem;
}
input, select, textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.error {
color: red;
font-size: 0.875rem;
}
</style>
Custom Styling
Override styles by:
- Component-level styles: Add to wrapper component
- Global CSS: Import a stylesheet
- CSS variables: Use CSS custom properties
- Tailwind/UnoCSS: Configure utility classes
Generation Commands
Generate All Components
praxis generate --schema ./schema.psf.json --output ./generated
Generate Specific Components
praxis generate --schema ./schema.psf.json --only components --output ./generated/components
Watch Mode
praxis generate --schema ./schema.psf.json --watch
Custom Templates
Use custom templates for generation:
praxis generate --schema ./schema.psf.json --templates ./my-templates
Best Practices
1. Keep Components Focused
Each component should have a single responsibility:
{
"components": [
{ "name": "UserAvatar", "type": "display" },
{ "name": "UserName", "type": "display" },
{ "name": "UserCard", "type": "composite" }
]
}
2. Use Model References
Reference models for type safety:
{
"components": [
{
"name": "UserForm",
"type": "form",
"model": "User"
}
]
}
3. Define Clear Events
Document what each event represents:
{
"events": [
{
"name": "submit",
"payload": "User",
"description": "Emitted when form is submitted with valid data"
}
]
}
4. Preserve Customizations
Extend generated components instead of modifying them:
components/
├── generated/ # Auto-generated (don't edit)
│ ├── UserForm.svelte
│ └── UserList.svelte
├── UserForm.svelte # Your customizations
└── UserList.svelte
Next: PluresDB Integration