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.
| Mode | Used in |
|---|---|
full | entity edit forms, modal sheets |
compact | table cells, bulk editors |
card | field-card blocks (host renders external label) |
panel | KPI strips, summary blocks |
inline | chat field-refs, comment mentions |
edit-inline | bento 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:
- DisplayType slot —
slot("field-input", field.displayType)/slot("field-display", field.displayType). If a slot is registered, it wins. - 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:
displayType | Component | What it renders |
|---|---|---|
likert | LikertInput | Radio-button row (default 1..5, falls back to slider when range > 11) |
rating | RatingInput | Slider with min/max/value labels |
rank | RankInput | Drag-rank list (uses RelationFieldInput from entities) |
tags | TagsInput | Comma-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.fields→FieldDefinition[]. Skips archived fields. Auto-setsdisplayType: "tags"for plain string arrays. Populatesplaceholderfromschema.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.dimensions→FieldDefinition[]. MapsDimensionTypeto (FieldType, displayType):rating→(number, "rating"),relation-rank→(relation, "rank")withmultiple: 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)—FormFlowField→FieldDefinition | null. Returnsnullfor 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-typeon json_schema properties (ordisplayTypeonFieldConfig) to pick a special. Usetagsfor string arrays,currency/percentage/metric/bytes/durationfor number variants,long-textfor multiline strings,mediawithx-media-layoutfor image/video fields. - Criteria dimensions —
type: "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 aslot("field-input", "<your-key>")and setdisplayTypeon the FormSpec field throughconfig.
Design Decisions
- Two vocabularies of
displayTypecoexist by design.FieldDisplayTypeis the 25-value enum thatclassifyValue()and tool-output formatters branch on (closed for exhaustiveness).FieldDefinition.displayTypeis 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 isspecials/rank-input.tsxreaching intoentities/components/entity-pickerfor the dnd-kitRelationFieldInput(the dnd primitives DAG lives there). That's a documented intentional coupling. field-inputSlotKind has new semantics. ADR-0006 retired the per-FieldTypefield-input:{type}scheme. The reintroducedfield-inputSlotKind is keyed ondisplayType(not FieldType). Per-FieldType defaults go throughgetFieldInput()locally inregistry-bootstrap.ts.- FormSpec runtime delegates only where the substrate has equivalent capability.
text,number(non-slider),select(static),date,relation,entitydelegate. Slider stays bespoke (value-in-header + suffix labels),entity-source/computed-sourceselects stay bespoke (live data wiring),booleanstays bespoke (inline checkbox-then-label chrome),object/arraystay bespoke (recursive RHF),entity-or-text/file/connection/customstay 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.
Related Modules
- 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.