Documentation source
Documentation source
Documentation source
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, entity detail panel (`PanelFieldValue`) |
| `compact` | table cells (`DataTableCell`), bulk editors |
| `card` | entity cards (`FieldValue`/`HeroValue`), field-card blocks (host renders external label) |
| `panel` | KPI strips, summary blocks; also `ArrayDisplay`/`ObjectDisplay`'s nested-item layout (labeled sub-item rows) — NOT hero typography, see Design Decisions |
| `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. `card` mode is also where `NumberDisplay` switches to compact notation (`formatCompactValue`) above the ≥100k threshold — e.g. `$1.2M` instead of `$1,234,567` — and applies `text-destructive` tone to negative currency. `full`/`compact` modes never compact a number, matching table/detail's finance-grade "always show the exact figure" convention.
### `hideLabel`
`mode` picks density; `hideLabel` picks label chrome — the two are orthogonal. A surface can want full density (no array/object summarization, no truncation) while still owning its own label rendering externally (e.g. the entity detail panel's row already renders the field label, so `PanelFieldValue` passes `<FieldDisplay mode="full" hideLabel>` to get full-density value formatting without a duplicate label). Every display component in the substrate (including `NumberDisplay`) treats `hideLabel` the same as `mode === "compact" | "card" | "inline"` for label-suppression purposes — only the density behavior differs.
### `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.
Two consequences worth stating explicitly:
- **Input/display asymmetry.** You can override a *displayType* input AND a *base-type* display via `registerSlot`, but per-FieldType input **defaults** are not registry-overridable — they live in the local `inputsMap`. To change how a base type inputs everywhere, set a `displayType` on the field and register that slot.
- **A `displayType` slot always beats the per-type default** — and every FieldType name doubles as a registered `field-display` slot name. So never stamp a *fallback-classified* displayType (e.g. the output of `classifyValue` defaulting to `"text"`) onto a `FieldDefinition`: it will pin the field to `TextDisplay` and shadow the richer per-type default (this exact bug regressed enum badges on entity cards — always thread `enumValues`/schema hints into `classifyValue`, and only set `displayType` when it carries real information).
### 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"
hideLabel // optional — suppress label chrome independent of mode
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.
### Rendering surfaces
Four surfaces resolve field widgets through `dispatchFieldDisplay` today — a displayType registered once via `registerSlot("field-display", ...)` renders identically on all of them:
| Surface | Component | Mode |
| ------- | --------- | ---- |
| Entity detail panel | `PanelFieldValue` (`features/entities/components/entity-detail-panel.tsx`) | `full` + `hideLabel` |
| Table cells | `DataTableCell` (`features/entities/components/data-table/data-table-cell.tsx`) | `compact` |
| Entity cards | `FieldValue` / `HeroValue` (`features/entities/components/entity-card/field-value.tsx`) | `card` |
| Blocks | `field-display` block module (`features/blocks/modules/field-display/render.client.tsx`) | per block config |
Each builds its `FieldDefinition` from raw `json_schema` properties via `fieldDefinitionFromProperty`. See Design Decisions below for what stays local on purpose.
### 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).
- **Entity-card `FieldValue`/`HeroValue`, the entity detail panel, and table cells now delegate to `<FieldDisplay>`** (2026-07-07 field-display-unification) instead of hand-rolling their own displayType branching — see Rendering surfaces above. Only a small, deliberately-local set of surface chrome remains unmigrated: the entity card's Check/X boolean glyph vocabulary, `HeroValue`'s large-type-scale className, and the name-classified media branch (bare-string media fields the schema adapter can't reconstruct). Tag-pill overflow now runs through the shared `ArrayDisplay`/`TagsDisplay` components, not a private card-only fork.
- **Arrays never render `[object Object]`.** `ArrayDisplay` (and `TagsDisplay`) label object items via `chipLabel()` (`features/schemas/lib/chip-label.ts`) — a labelish-key lookup (`label`/`name`/`title`/`value`/`id`, falling back to the first string value, then compact JSON) shared by every surface. Density also varies by mode: dense modes (`compact`/`card`/`inline`) cap primitive chips at 3 with a "+N" overflow and summarize multi-object arrays as "N items"; `full` mode caps chips at 12 and structured lists at 5 with "+N more".
- **Epoch-adjacent timestamps never render as a confident date.** `formatAbsoluteDate`/`formatAbsoluteDateShort`/`formatAbsoluteDateTime`/`formatRelativeDate` (`features/tools/lib/format.ts`) and `formatRelativeTime` (`lib/utils.ts`) all guard on `isEpochPlaceholder()` (`lib/utils.ts` — true when `|ms| < 48h` from the Unix epoch, including null/empty/unparseable input) and return `"—"` instead of `"Jan 1, 1970"`. Applies uniformly across table cells, the detail panel, and cards since they now share the same formatters.
## 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.