Rendering System
Seven render families, three-tier fallback, UIIntent, auto-renderers, and custom component registration.
Overview
The rendering system bridges the headless core (types, stores, builders) to visual output. It is designed so that the core never depends on React or any UI framework, while the presentation layer can render any entity type generically.
Seven Render Families
Every piece of visual output falls into one of seven families:
| Family | Purpose | Examples |
|---|---|---|
block | Content blocks in layouts | Stat block, text block, chart block |
card | Entity summary cards | Entity card, compact card, kanban card |
table | Tabular data views | Entity list, data table, comparison table |
form | Data input | Entity create form, field editor, settings |
chart | Data visualization | Bar chart, line chart, pie chart |
graph | Relationship visualization | Entity graph, dependency graph |
custom | Escape hatch | Anything that does not fit the above |
import type { RenderFamily } from "@sprinterai/core";
const family: RenderFamily = "card";Three-Tier Fallback
When rendering an entity or field, the platform checks three levels:
Tier 1: Custom renderer
A registered custom component for the specific entity type or block type:
// Custom card for "opportunity" entity type
import type { RendererDescriptor } from "@sprinterai/core";
const opportunityCard: RendererDescriptor = {
id: "opportunity-card",
family: "card",
label: "Opportunity Card",
match: { entityTypeSlug: "opportunity" },
};Tier 2: Schema-driven auto-renderer
The platform reads the entity type's json_schema and renders fields automatically. Text fields render as text, select fields render as dropdowns, currency fields render with formatting, dates render as date pickers.
Tier 3: Generic fallback
A minimal key-value display that works for any entity regardless of schema.
UIIntent
A UIIntent describes what the user wants to see without prescribing how to render it:
import type { UIIntent } from "@sprinterai/core";
// "Show me opportunities as a kanban board"
const intent: UIIntent = {
family: "card",
entityTypeSlug: "opportunity",
layout: "kanban",
};
// "Show me company details"
const detailIntent: UIIntent = {
family: "block",
entityTypeSlug: "company",
entityId: "uuid-here",
layout: "bento",
};
// "Show me a revenue chart"
const chartIntent: UIIntent = {
family: "chart",
params: { metric: "revenue", groupBy: "sector" },
};The rendering system takes a UIIntent and resolves it through the three-tier fallback to find the best renderer.
RendererDescriptor
Renderers register themselves with a descriptor:
interface RendererDescriptor {
/** Unique renderer identifier. */
id: string;
/** What family of rendering this handles. */
family: RenderFamily;
/** Human-readable name for admin UI. */
label: string;
/** When this renderer should be used. */
match?: {
entityTypeSlug?: string;
blockType?: string;
viewLayout?: string;
};
}The match object determines when the renderer is selected. A renderer with match.entityTypeSlug: "opportunity" will be used whenever an opportunity entity needs to be rendered in that family.
Auto-Rendering from Schema
The platform reads the json_schema from the entity type and automatically generates:
- Forms -- input fields matching property types (text input, select dropdown, number input, date picker, URL input, toggle)
- Cards -- summary display with key fields highlighted
- Detail views -- full field layout with sections
- Table columns -- column definitions from schema properties
Field type mapping:
| JSON Schema | Auto-rendered as |
|---|---|
{ type: "string" } | Text input / text display |
{ type: "string", enum: [...] } | Select dropdown / badge |
{ type: "number" } | Number input / formatted number |
{ type: "number", format: "currency" } | Currency input / currency display |
{ type: "string", format: "date" } | Date picker / formatted date |
{ type: "string", format: "uri" } | URL input / clickable link |
{ type: "string", format: "email" } | Email input / mailto link |
{ type: "boolean" } | Toggle / check/cross icon |
{ type: "array" } | Tag input / tag list |
Views
Views are config-driven layouts that compose blocks and renderers:
- List view -- table or grid of entities
- Detail view -- single entity with blocks in a bento grid
- Workspace view -- multi-panel layout with mixed content
Views are stored in the views table and can be created, edited, and shared. The manageView admin tool allows agents to create views programmatically.
Registry Pattern
The shadcn registry provides pre-built UI blocks:
# Install a card component
npx shadcn@latest add https://ui.sprinterai.dev/r/entity-card.json
# Install a chart block
npx shadcn@latest add https://ui.sprinterai.dev/r/chart-block.jsonRegistry blocks are installed into your source code, not consumed as npm dependencies. You own the code and can customize it freely.
Field Renderer Suite
The render-registry block ships a complete field-renderer suite. It covers all 12 FieldType values and exposes two public dispatcher components: FieldInput and FieldDisplay.
Install the block once:
npx shadcn@latest add https://ui.sprinterai.dev/r/render-registry.jsonFieldInput and FieldDisplay
import { FieldInput, FieldDisplay } from "@/blocks/render-registry/field-renderer-dispatch";Both components accept a FieldDefinition (from @sprinterai/core) and route to the correct leaf renderer based on field.type. Unknown types fall back to text.
// Read-only display
<FieldDisplay
field={field}
value={entity[field.key]}
mode="card"
context={ctx}
/>
// Editable input
<FieldInput
field={field}
value={formState[field.key]}
onChange={(next) => setField(field.key, next)}
mode="full"
context={ctx}
error={errors[field.key]}
/>FieldRenderMode
Controls visual size and surface treatment. Every leaf renderer respects this prop.
| Mode | Use |
|---|---|
full | Entity edit forms, modal sheets — default |
compact | Table cells, bulk editors |
card | Field-card blocks, hover-edit surfaces |
panel | KPI strips, summary blocks |
inline | Text-only references in chat |
edit-inline | Hover-to-edit in-place controls |
FieldRenderContext
A context bag threaded through to all renderers. Pass it via the context prop on both FieldInput and FieldDisplay.
interface FieldRenderContext {
surface?: "form" | "bento" | "table" | "card" | "kanban" | "canvas" | "chat";
viewId?: string;
entityId?: string;
isDraft?: boolean;
/** PATCH-style write sink. The host routes writes through its own transport. */
onPatch?: (patch: { key: string; value: unknown }) => void;
/** Upload a File, return the CDN URL. Required for MediaInput file uploads. */
onUpload?: (file: File) => Promise<string>;
/** Search related entities by type slug and query string. Required for RelationInput suggestions. */
onSearchRelations?: (
typeSlug: string,
query: string,
) => Promise<Array<{ id: string; title: string }>>;
}onUpload and onSearchRelations are optional. When absent, MediaInput hides its upload button and RelationInput shows no suggestions — neither component errors.
Callback injection pattern
Because the registry block has no direct dependency on Supabase Storage or any specific entity store, async operations are injected by the host:
// Host wires its own transport
const ctx: FieldRenderContext = {
surface: "form",
entityId: entity.id,
onUpload: async (file) => {
const { data } = await supabase.storage
.from("attachments")
.upload(`${tenantId}/${file.name}`, file);
return supabase.storage.from("attachments").getPublicUrl(data.path).data.publicUrl;
},
onSearchRelations: async (typeSlug, query) => {
const { data } = await supabase
.from("entities")
.select("id, title")
.eq("entity_type_slug", typeSlug)
.ilike("title", `%${query}%`)
.limit(10);
return data ?? [];
},
};
<FieldInput field={field} value={value} onChange={onChange} context={ctx} />Prop interfaces
interface FieldInputProps<T = unknown> {
field: FieldDefinition;
value: T | undefined;
onChange: (next: T | null | undefined) => void;
mode?: FieldRenderMode; // default "full"
disabled?: boolean;
error?: string;
context?: FieldRenderContext;
onBlur?: () => void;
onFocus?: () => void;
name?: string;
}
interface FieldDisplayProps<T = unknown> {
field: FieldDefinition;
value: T | undefined;
mode?: FieldRenderMode; // default "full"
context?: FieldRenderContext;
className?: string;
}Cleared-value sentinels
onChange emits these values when a field is cleared:
| Field type | Cleared value |
|---|---|
text | "" |
number | null |
date | null |
enum (single) | null |
enum (multi) | [] |
relation (single) | null |
relation (multi) | [] |
media | [] |
boolean | true or false — never null |
Composite components
| Component | Purpose |
|---|---|
FieldDefinitionDisplay | Read-only metadata chip or row with colour-coded type badge. variant="row" (default) or variant="chip". |
FieldDefinitionEditor | Admin CRUD control for a single FieldDefinition: key, label, type, display type, required, read-only, description, plus per-type config panels. |
FieldDefinitionForm | Wraps FieldDefinitionEditor in a submit/cancel shell for use inside sheets and dialogs. |
import { FieldDefinitionForm } from "@/blocks/render-registry/field-definition-form";
<FieldDefinitionForm
initialField={draft}
onSubmit={(field) => save(field)}
onCancel={() => setOpen(false)}
typeSlugOptions={["company", "contact", "deal"]}
/>Format utilities
field-renderer-utils.ts exports pure formatting helpers with no dependencies:
| Function | What it does |
|---|---|
humanize(key) | "first_name" → "First Name" |
formatAbsoluteDate(value) | ISO string → "May 6, 2026". Handles date-only strings without timezone shift. |
formatNumber(value, precision?) | Locale-formatted number with optional decimal precision. |
formatCurrency(value) | USD currency with no decimal places. |
formatPercentage(value) | "42.0%" |
formatPhone(value) | 10-digit US → "(555) 867-5309", 11-digit with country code → "+1 (555) 867-5309". |
formatUrl(value) | Strips https://www. prefix and trailing slash. Unsafe protocols return just the filename. |
isUnsafeUrl(value) | Returns true for file:, javascript:, data:, vbscript: schemes. |
guessMediaKind(url) | "image" or "video" based on file extension. |
normalizeUrls(value) | Coerces a string or string array to string[], filtering empties. |
Array and object: render-props pattern
ArrayInput/ArrayDisplay and ObjectInput/ObjectDisplay accept renderItem and renderField render props respectively. The dispatcher (field-renderer-dispatch.tsx) injects itself to handle recursive field trees without creating a circular module import:
// The dispatcher does this automatically — you do not need to pass renderItem yourself
// when using FieldInput / FieldDisplay. This is only relevant if you import
// ArrayInput or ObjectInput directly.
<ArrayInput
field={arrayField}
value={values}
onChange={setValues}
renderItem={({ field, value, onChange, mode, disabled, context }) => (
<FieldInput field={field} value={value} onChange={onChange}
mode={mode} disabled={disabled} context={context} />
)}
/>