Skip to content

UI components

UI components

Pod ships collection surfacesCollectionTable, 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 collection
  • view — names this surface. Two tables over the same collection in one app need distinct views
  • features{ search, filter, bulk, create } toggles the toolbar affordances
  • exportPipelines / importPipelines — run the collection's pipelines from the toolbar, with selection-aware disable reasons
  • integrations — inline status badges for the collection's integrations
  • rowActions — per-row action snippets
  • ListCard — mobile card override; omitted, cards derive from the column roles
  • selectable, 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 field
  • rows — visual lane rows, for multi-row boards
  • onCardMove — move handler; the default writes the target lane into the groupBy field optimistically and rolls back on failure
  • Card — card snippet override
  • query, 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 (or null) means create
  • defaultValues — initial values for create
  • validation{ schema, semantic }: a standard schema for field validation (including refinements) plus an async semantic check for cross-field rules
  • fields — an ordered field pick for the auto-emitted form; omitted, every writable field is emitted in declaration order
  • children — a full field composition snippet with a form controller, when the auto layout is not enough
  • onSubmit, 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 RepresentationProps arrive from the adjacent ./$types.js: { record, close, refresh }, where record: Row | nullnull is 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.
Override only when needed
The schema-derived defaults are the single source of form and detail behavior. Reach for +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 money ships its own money definition 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; onValueChange reports 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.
  • 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