Skip to content

UI components

UI components

Bolt ships collection surfaces — CollectionTable , CollectionKanban , and CollectionForm — that read and write a collection with a wired client: 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 and the submit lifecycle, and its children snippet must declare every writable field exactly once:

<script lang="ts">
  import { client } from '$bolt/client';
  import type { RepresentationProps } from './$types.js';
  import { getCollectionClientForSurface } from '@norbital-ai/ui/collection-runtime';
  import { CollectionForm } from '@norbital-ai/ui/collection-form';
  import { Grid } from '@norbital-ai/ui/layout';

  let { record, close }: RepresentationProps = $props();
  const workspaceClient = getCollectionClientForSurface(client, 'tasks form');
</script>

<CollectionForm
  client={workspaceClient}
  collection="tasks"
  defaultValues={record ?? undefined}
  onAfterSubmit={record ? undefined : close}
>
  {#snippet children({ Field })}
    <Grid minimum="compact">
      <Field name="title" />
      <Field name="status" />
      <Field name="internal_value" hidden />
    </Grid>
  {/snippet}
</CollectionForm>

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.
  • semantic — an async semantic check for cross-field rules, when field schema validation is not enough
  • children — required; one <Field> per writable field, in the order shown, with <Field hidden> for a value users must not edit
  • onAfterSubmit , deleteAction , disabled , loading

Field-level renderer props let one field use a custom control without leaving the form. Submits land through client.collection.<collection> , so the server targets affected registered live queries and pushes their patches — there is no query invalidation or refetch anywhere in this path.

The override: +representation.svelte

A model and a table alone provide no form. Every collection users create or open needs one collection-owned file; it renders create, display, and edit 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 | null — null 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 representation is the single source of create, display, and edit behavior. Attach +representation.svelte to every collection users create or open.

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/ : `+definition.ts` exports the schema and `+renderer.svelte` presents it.

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 — there is no registration step. A kind without a renderer falls back to the built-in JSON renderer, and a failed load names the datatype and cause. 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