UI components
UI components
Pod ships collection surfaces — CollectionTable, CollectionKanban, and CollectionForm — that read and write a collection
with zero wiring: schema-derived, policy-filtered, and live through the sync engine. This page covers the surfaces, the +representation.svelte override, and how custom data types get their own rendering.
CollectionTable
A table is the default view of a collection. It requires an explicit columns snippet — table UI does not auto-derive columns from the model — and takes
the client from the generated surface context:
<CollectionTable collection="tasks">
{#snippet columns({ Column })}
<Column name="title" />
<Column name="status" />
<Column name="assignee" />
{/snippet}
</CollectionTable>
Notable props:
query— a reactive query: filters, sorts, search, pagination apply on top of the collectionview— names this surface. Two tables over the same collection in one app need distinct viewsfeatures—{ search, filter, bulk, create }toggles the toolbar affordancesexportPipelines/importPipelines— run the collection's pipelines from the toolbar, with selection-aware disable reasonsintegrations— inline status badges for the collection's integrationsrowActions— per-row action snippetsListCard— mobile card override; omitted, cards derive from the column rolesselectable,disabled,title,description,searchPlaceholder,emptyPlaceholder
CollectionKanban
A kanban groups the collection by one field and moves records between lanes with an optimistic write:
<CollectionKanban collection="tasks" groupBy="status" />
Notable props:
groupBy— the field lanes are built from (required)lanes— lane subset, order, labels, and colors; omitted, lanes derive from the fieldrows— visual lane rows, for multi-row boardsonCardMove— move handler; the default writes the target lane into thegroupByfield optimistically and rolls back on failureCard— card snippet overridequery,view,selectable, and the same pipeline props as the table
CollectionForm
A form creates or edits one record through the same policy-filtered mutation path as every other client. It owns validation, editable-field selection, and the submit lifecycle:
<CollectionForm collection="tasks" recordId={selectedId} />
Notable props:
recordId— edit an existing record; omitted (ornull) means createdefaultValues— initial values for createvalidation—{ schema, semantic }: a standard schema for field validation (including refinements) plus an async semantic check for cross-field rulesfields— an ordered field pick for the auto-emitted form; omitted, every writable field is emitted in declaration orderchildren— a full field composition snippet with a form controller, when the auto layout is not enoughonSubmit,onAfterSubmit,deleteAction,disabled,loading
Field-level renderer props let one field use a custom control without leaving the
form. Submits land through client.db, so affected live queries re-evaluate locally —
there is no query invalidation or refetch anywhere in this path.
The override: +representation.svelte
Schema-derived create, display, and edit are the defaults. When they are not enough, one collection-owned file overrides all three modes at once:
src/collections/<collection>/+representation.svelte <script lang="ts">
import type { RepresentationProps } from './$types.js';
let { record, close, refresh }: RepresentationProps = $props();
</script>
{#if record === null}
{!-- create mode --}
<!-- display/edit mode: record is the row -->
{record.title}
{/if}
- Generated
RepresentationPropsarrive from the adjacent./$types.js:{ record, close, refresh }, whererecord: Row | null—nullis create, a row is display/edit. - There is no separate create role and no call-site registration: table, kanban, and detail views resolve the same file from the generated static map.
- Keep editable controls inside the form. Do not repeat the same editable fact in a read-only summary. Shared presentation belongs in ordinary adjacent Svelte components, not the representation.
+representation.svelte only for a genuine override.Custom data types
When a domain value has more shape than a scalar column — money with a currency, a date range with
rules, a repayment schedule — it becomes a custom type: one schema authority plus
one renderer, declared as a directory under src/custom-types/.
src/custom-types/
└── money/
├── +definition.ts # schema — the only source of truth for the value
└── +renderer.svelte # required — how the value renders and edits The definition default-exports defineCustomType with a schema — or a schema factory whose
options flow through to the model:
// src/custom-types/money/+definition.ts
import { defineCustomType } from '@norbital-ai/pod/authoring';
import { z } from 'zod';
export default defineCustomType({
name: 'money',
schema: (options: { allowedCurrencies?: readonly string[] } = {}) =>
z
.object({
value: z.number().finite(),
currency: options.allowedCurrencies
? z.enum(options.allowedCurrencies)
: z.string().regex(/^[A-Z]3$/)
})
.strict()
});
A model uses the type with custom('<name>'); the schema factory infers its
optional options argument:
// src/collections/quotes/+model.ts
import { defineModel, custom, numeric } from '@norbital-ai/pod/authoring';
export default defineModel(
{
total: custom('money', { allowedCurrencies: ['MYR'] }),
tax_rate: numeric()
},
{ description: 'Customer quotation', recordLabel: 'quote_number' }
);
- Custom values are stored as JSONB and validated at the boundary.
- There are no built-in exceptions — a workspace that uses
moneyships its ownmoneydefinition and renderer. - The definition is the single inferred value type. Never cast it — the schema is what validates the data.
Renderers
The +renderer.svelte is the only UI for the value. It receives a discriminated RendererProps prop from its own generated ./$types.js:
- display —
{ mode: 'display', field, value }, for tables, kanban cards, and details - edit —
{ mode: 'edit', field, value, disabled, onValueChange }, for forms;onValueChangereports edits
<!-- src/custom-types/money/+renderer.svelte -->
<script lang="ts">
import type { RendererProps } from './$types.js';
let { props }: { props: RendererProps } = $props();
</script>
{#if props.mode === 'display'}
{props.value ? `${props.value.value.toFixed(2)} {props.value.currency}` : '—'}
{:else}
<input
type="number"
value={props.value?.value ?? 0}
disabled={props.disabled}
onchange={(e) => props.onValueChange({ value: Number(e.currentTarget.value), currency: props.value?.currency ?? 'MYR' })}
/>
{/if}
Renderers are discovered statically by the compiler — there is no registration step, and a missing
renderer is a compile error. A renderer override may also be applied per field inside a CollectionForm composition, for one-off fields that do not deserve a full custom type.
Composition rules
- Surfaces render inside the app body region — see Layout.
- Record navigation — clicking a row opens the detail stack — is handled by the shell; see Navigation state.
- All reads and writes go through the sync engine.
Related guides
- Layout — the app body contract surfaces render inside
- Apps — compose surfaces inside tenant application entry components
- Collections — define the models surfaces read and write
- Sync engine — the live queries and optimistic writes behind every surface