Skip to content

UI components

UI components

Bolt ships collection surfacesCollectionTable , CollectionKanban , and CollectionForm — that read and write a collection with zero wiring: schema-derived, policy-filtered, and live by default . 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, 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 , 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" defaultValues={selectedRow} />

Notable props:

  • defaultValues — the row being edited, or a partial seed for a new record: a value carrying the row key is an update, anything else is a draft. There is deliberately no recordId prop.
  • 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 }: 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 — a repayment schedule, a point in the site model, an address — it becomes a custom type : one schema authority plus one renderer, declared as a directory under src/custom-types/ .

src/datatypes/
└── site_coordinates/
    ├── +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/datatypes/site_coordinates/+definition.ts
import { defineCustomType } from '@norbital-ai/bolt/authoring';
import { Schema } from 'effect';

export default defineCustomType({
	name: 'site_coordinates',
	description:
		'An x, y, and z point in the site model, with any axis that was never surveyed left empty.',
	schema: Schema.Struct({
		x: Schema.NullOr(Schema.Number),
		y: Schema.NullOr(Schema.Number),
		z: Schema.NullOr(Schema.Number)
	})
});

A model uses the type with custom('<name>') ; the schema factory infers its optional options argument:

// src/collections/projects/+model.ts
import { defineModel, custom, text } from '@norbital-ai/bolt/authoring';

export default defineModel(
	{
		name: text().notNull(),
		coordinates: custom('site_coordinates')
	},
	{ description: 'Construction project', recordLabel: 'name' }
);
  • Custom values are stored as JSONB and validated at the boundary.
  • The platform already owns money and instant_range — access both through custom('<name>') instead of redeclaring them; a datatype that shadows a platform name is a compile error. The custom('money') call uses the same validation and renderer path and can narrow its currency list.
  • 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/datatypes/site_coordinates/+renderer.svelte -->
<script lang="ts">
	import type { RendererProps } from './$types.js';

	let props: RendererProps = $props();
</script>

{#if props.mode === 'display'}
	{props.value ? `(${props.value.x ?? '—'}, ${props.value.y ?? '—'}, ${props.value.z ?? '—'})` : '—'}
{:else}
	<input
		type="number"
		value={props.value?.x ?? ''}
		disabled={props.disabled}
		onchange={(e) => props.onValueChange({ ...props.value, x: Number(e.currentTarget.value) })}
	/>
{/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 live data layer .
  • Layout — the app body contract surfaces render inside
  • Apps — compose surfaces inside tenant application entry components
  • Collections — define the models surfaces read and write
  • Live data — the live queries and optimistic writes behind every surface