Documentation source
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](/docs/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.
```ts
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`).
```ts
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 slot** — `slot("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:
| `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:
```ts
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 />`
```tsx
<FieldInput
field={fieldDefinition}
value={currentValue}
onChange={(next) => /* … */}
mode="full" // optional, default "full"
disabled={false} // optional
error="Validation message" // optional
context={{ surface: "form" }} // optional
/>
```
### `<FieldDisplay />`
```tsx
<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-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.dimensions` → `FieldDefinition[]`. 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)`** — `FormFlowField` → `FieldDefinition | 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 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 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.
## Related Modules
- [Entity System](/docs/features/entity-system) — entity types and json_schema feeding the substrate.
- [Interactivity](/docs/features/interactivity) — FormSpec / ViewSpec / CallSpec / LinkSpec, the four-shape model that hosts the substrate.
- [Block System](/docs/features/block-system) — field-card, form-flow, and other blocks that consume the substrate.
- [Response System](/docs/features/response-system) — criteria sets and dimension inputs.