Sprinter Docs

Field Rendering

The unified field-rendering substrate at features/schemas/ — one FieldDefinition shape, one set of inputs and displays, one slot-keyed extension seam. Used by entity edit forms, criteria responses, field cards, FormSpec runtime, and tool output displays.

Field Rendering

Amble has one canonical field-rendering substrate at features/schemas/. Every surface that draws a field input or display — entity edit forms, criteria response submissions, field cards, FormSpec tool inputs, tool output formatters — delegates to the same <FieldInput /> / <FieldDisplay /> components and registers extensions through the same slot seam.

See ADR-0015 — Unified field rendering for the rationale and rejected alternatives. This page covers the public API and how to extend it.

Overview

A field has two vocabularies:

  • FieldType — storage type. The 12 storage primitives that drive Zod validation: text, number, boolean, date, enum, media, url, email, phone, relation, object, array.
  • FieldDefinition.displayType — optional rendering hint. A free-string slot key that overrides the per-FieldType default ("long-text", "currency", "rating", "likert", "rank", "tags", …). Third parties register new values by registering a slot.

Every consumer either holds a FieldDefinition directly or runs an adapter that produces one from its native vocabulary (entity types, criteria dimensions, FormSpec fields, form-flow blocks).

Key Concepts

FieldDefinition

The canonical authoring shape — what the substrate consumes.

interface FieldDefinition {
  key: string;                      // identifier; also the JSON Schema property key
  type: FieldType;                  // storage type
  displayType?: string;             // optional rendering hint
  label?: string;                   // falls back to humanize(key)
  description?: string;
  required?: boolean;
  readOnly?: boolean;
  placeholder?: string;
  defaultValue?: unknown;

  // Per-type config (only the matching block is meaningful)
  text?: { minLength?, maxLength?, pattern? };
  number?: { min?, max?, step?, precision? };
  enum?: { options: FieldEnumOption[]; multiple? };
  media?: { layout?, kind?, maxItems? };
  relation?: { targetTypeSlug, multiple? };
  date?: { min?, max? };

  // Recursive shapes
  fields?: FieldDefinition[];       // for type='object'
  items?: FieldDefinition;          // for type='array'

  config?: Record<string, unknown>; // free-form per-displayType extras
}

The Zod schema lives at FieldDefinitionSchema in the same module and validates the runtime shape.

FieldRenderMode

Layout density — the same component honors all modes. Modes pick layout, not components.

ModeUsed in
fullentity edit forms, modal sheets
compacttable cells, bulk editors
cardfield-card blocks (host renders external label)
panelKPI strips, summary blocks
inlinechat field-refs, comment mentions
edit-inlinebento default detail views (hover-to-edit)

card mode is the convention for "host renders the label externally" — the substrate's text/number/enum/date/url/email/phone inputs all skip their own label container in card mode.

FieldRenderContext

Surface metadata threaded through to dispatched components. Specials may react to it (e.g. rank displayType could compact when surface=card).

interface FieldRenderContext {
  surface?: "form" | "bento" | "table" | "card" | "kanban" | "canvas" | "chat";
  viewId?: string;
  sessionId?: string;
  entityId?: string;
  isDraft?: boolean;                    // writes go to a session draft
  onPatch?: (patch) => void;            // optional patch sink (collab)
}

How It Works

Two-level dispatch

Both <FieldInput /> and <FieldDisplay /> resolve their component the same way:

  1. DisplayType slotslot("field-input", field.displayType) / slot("field-display", field.displayType). If a slot is registered, it wins.
  2. Per-FieldType default — the registered default for field.type.

Inputs use a module-local inputsMap (in registry-bootstrap.ts) for the per-type defaults; displays use the shared slot registry under slot("field-display", type). The dispatcher itself doesn't know about any specific displayType — the slot registry IS the seam.

Built-in specials

Bootstrap registers four displayType slot overrides at module load:

displayTypeComponentWhat it renders
likertLikertInputRadio-button row (default 1..5, falls back to slider when range > 11)
ratingRatingInputSlider with min/max/value labels
rankRankInputDrag-rank list (uses RelationFieldInput from entities)
tagsTagsInputComma-separated string-array input

Adding a new special is the same registration:

import { registerSlot, slotKey } from "@/lib/ui-registry";
import { MyHeatmapInput } from "./my-heatmap-input";

registerSlot(slotKey("field-input", "heatmap"), {
  component: MyHeatmapInput,
});

Then any FieldDefinition with displayType: "heatmap" will render through MyHeatmapInput regardless of FieldType.

API Reference

<FieldInput />

<FieldInput
  field={fieldDefinition}
  value={currentValue}
  onChange={(next) => /* … */}
  mode="full"                         // optional, default "full"
  disabled={false}                    // optional
  error="Validation message"          // optional
  context={{ surface: "form" }}       // optional
/>

<FieldDisplay />

<FieldDisplay
  field={fieldDefinition}
  value={currentValue}
  mode="compact"                      // optional, default "full"
  context={{ surface: "table" }}      // optional
  className="text-xs text-primary"    // optional — leaf override (Phase 6)
/>

The className override applies to the leaf rendered element (the link, the formatted span). Lets one-shot consumers (tool outputs, table cells) restyle without re-implementing per-displayType branching.

Adapters

Three adapters live in features/schemas/adapters/. Each consumes a different native vocabulary and produces FieldDefinition[] or a single FieldDefinition:

  • fromEntityTypeFields(jsonSchema, configs)entity_types.json_schema + entity_types.config.fieldsFieldDefinition[]. Skips archived fields. Auto-sets displayType: "tags" for plain string arrays. Populates placeholder from schema.examples.
  • fieldDefinitionFromProperty(key, prop, config?, required?) — single-property variant for ad-hoc paths (entity bento inline edit, the entity form's per-field renderer).
  • fromCriteriaDimensions(dimensions)criteria_sets.dimensionsFieldDefinition[]. Maps DimensionType to (FieldType, displayType): rating(number, "rating"), relation-rank(relation, "rank") with multiple: true.
  • fromFormSpec(spec) / fromFormSpecField(field) — FormSpec → FieldDefinition[] (storage-side adapter; recursive for object/array). FormSpec runtime renderer also has inline render-side adapters (text/number/date/select/relation/entity) since rendering wants real FieldType not the storage fallback.
  • fromFormFlowField(field)FormFlowFieldFieldDefinition | null. Returns null for kinds that stay bespoke (file-upload, multi-select badge UX).

For Agents

Agents that author entity types, criteria sets, or tools should produce shapes that flow through the substrate cleanly:

  • Entity types — set x-display-type on json_schema properties (or displayType on FieldConfig) to pick a special. Use tags for string arrays, currency / percentage / metric / bytes / duration for number variants, long-text for multiline strings, media with x-media-layout for image/video fields.
  • Criteria dimensionstype: "rating" → slider; type: "relation-rank" → drag-rank list.
  • Custom tools — declare a FormSpec; the substrate covers text/number/date/select/relation/entity. Use displayHint: "slider" for sliders. For specialized inputs (heatmap picker, calendar grid, etc.) register a slot("field-input", "<your-key>") and set displayType on the FormSpec field through config.

Design Decisions

  • Two vocabularies of displayType coexist by design. FieldDisplayType is the 25-value enum that classifyValue() and tool-output formatters branch on (closed for exhaustiveness). FieldDefinition.displayType is a free string so third-party slots can register new values (open for extension). They're consumed by different pipelines.
  • The substrate doesn't depend on entities/responses/interactivity. Those modules consume the substrate, not the other way around. The single permitted upward import is specials/rank-input.tsx reaching into entities/components/entity-picker for the dnd-kit RelationFieldInput (the dnd primitives DAG lives there). That's a documented intentional coupling.
  • field-input SlotKind has new semantics. ADR-0006 retired the per-FieldType field-input:{type} scheme. The reintroduced field-input SlotKind is keyed on displayType (not FieldType). Per-FieldType defaults go through getFieldInput() locally in registry-bootstrap.ts.
  • FormSpec runtime delegates only where the substrate has equivalent capability. text, number (non-slider), select (static), date, relation, entity delegate. Slider stays bespoke (value-in-header + suffix labels), entity-source / computed-source selects stay bespoke (live data wiring), boolean stays bespoke (inline checkbox-then-label chrome), object/array stay bespoke (recursive RHF), entity-or-text / file / connection / custom stay bespoke (no storage analog).
  • Some surfaces remain intentionally specialized. Entity-card FieldValue / HeroValue (Check/X icons for booleans, currency formatting, tag pills with overflow), media-tile chrome — these visual contracts don't fit the substrate's defaults and are not migrating. Documented in ADR-0015.
  • Entity System — entity types and json_schema feeding the substrate.
  • Interactivity — FormSpec / ViewSpec / CallSpec / LinkSpec, the four-shape model that hosts the substrate.
  • Block System — field-card, form-flow, and other blocks that consume the substrate.
  • Response System — criteria sets and dimension inputs.

On this page