The built-in field set and display component registry use the frozen
protocol §8 type keys. Everything lives in packages/ui:
the field components in src/components/fields/, the display projections in
src/components/display/, and the registrations in src/registry/index.ts.
Server half: fields (§7.3/§11.1). State layer: the form engine (§13.3).
A field component owns no state
Every field receives { node, control } and nothing else. control.value is
the flat form state for its key and control.setValue is the only way to
change it — there is no local copy, no defaultValue, no “commit on blur”
buffer. A component that kept its own would disagree with the engine the
moment a sets derivation, a reset(), or a server round-trip rewrote the
key underneath it.
export function MyField({ node, control }: FieldProps) {
const chrome = useFieldChrome(node, control)
return (
<FieldShell chrome={chrome} type="vendor/my-field" fieldKey={node.key}>
<input id={chrome.controlId} value={stringValue(control.value)}
onChange={(e) => control.setValue(e.target.value)} />
</FieldShell>
)
}useFieldChrome reconciles the node’s props with the controller’s reactive
verdicts (disabled, readonly, error) and mints the ids; FieldShell
renders the label, the prefix/suffix affixes, the helper and the error. Using
them is what makes a custom field indistinguishable from a built-in one.
The set
| Type key | Control | Form state | Notable props |
|---|---|---|---|
text-input |
<input type=text> + <datalist> |
string |
maxLength, minLength, mask → pattern, datalist |
textarea |
<textarea> |
string |
rows, autosize |
number |
<input type=number> |
number | null |
min, max, step, decimal |
slider |
one or two <input type=range> |
number or [from, to] |
min, max, step, decimal, range |
select |
<select> or an ARIA combobox |
scalar or array | options, multiple, native, searchable |
radio |
<fieldset> of radios |
scalar | options, inline |
checkbox |
<input type=checkbox> |
boolean |
inline |
toggle |
role="switch" button |
boolean |
onLabel, offLabel, onColor |
checkbox-list |
<fieldset> of checkboxes |
array | options, columns, minSelected, maxSelected |
date |
<input type=date> |
YYYY-MM-DD |
after, before |
datetime |
<input type=datetime-local> |
UTC ISO instant | after, before, displayTimezone |
time |
<input type=time> |
HH:mm:ss |
after, before |
color |
picker or text + swatches | string |
notation, alpha, swatches |
hidden |
<input type=hidden> |
any scalar | — |
code |
textarea + lazy highlighter | string |
language, rows, lineNumbers, wrap |
json |
textarea with parse feedback | any JSON value | rows, prettify |
key-value |
add/remove/edit rows | object | keyLabel, valueLabel, addable, deletable, editableKeys, minPairs, maxPairs |
markdown |
textarea + lazy preview | string |
preview (side-by-side/tab/none), rows |
rich-text |
lazy contenteditable WYSIWYG | sanitized HTML | toolbar (preset name), toolbarItems (expanded list), rows |
repeater |
add/remove/reorder item sub-forms | array of item objects | min, max, reorderable, collapsible, itemLabel, layout |
file |
drag-and-drop upload | key string or key array |
acceptedTypes, maxSizeMb, multiple |
image |
the same, with previews and a lazy crop editor | key string or key array |
+ imageEditor, dimensions, previewWidths |
belongs-to |
search box + <select>, optional create modal |
target primary key | resource, searchable, preload, creatable |
belongs-to-many |
checkbox list + pivot sub-forms | key array, or { id, …pivot }[] |
resource, searchable, preload, pivotFields |
has-many and morph-to are Phase-3 rows with no server half either. They
render as <UnknownComponent> — which is exactly what protocol §8 mandates
for a missing client half, and why the gap stays visible rather than silent.
Heavy editors are lazy chunks
§18 budgets the base panel at 225 KB gzip with the rich-text, code and image
editors as lazy chunks. Four modules live under
src/components/fields/editors/ and are reachable only through
lazyEditor(() => import('./editors/x.js')):
| Chunk | Loaded by | When |
|---|---|---|
code_editor |
code |
on mount, in the browser |
markdown_preview |
markdown |
unless preview: 'none' |
rich_text_editor |
rich-text |
on mount, in the browser |
image_editor |
image |
only when props.imageEditor, only when the user clicks Edit |
lazyEditor wraps React.lazy in <ClientOnly> + <Suspense>, so the
server pass and the hydration pass both render the FALLBACK. The fallback is
never a spinner: each shell renders a working <textarea> underneath, so a
panel whose chunk never arrives (offline, blocked CDN, slow first paint)
still edits and submits the field.
Two tests hold the line: tests/fields/lazy_chunks.test.ts walks the source
graph and fails on a static import of anything under editors/, and D2-7’s
pnpm size fails CI on the same rule against a real bundle.
rich-text: the client is not the security boundary
Neither the shell nor the editor chunk sanitizes anything, deliberately.
rich-text sanitizes on dehydrate, server-side, against the configured
allowlist (§7.3), so the column holds safe markup for every consumer — this
panel, an API, a public page, an export. A second allowlist on the client
would drift from the server’s, and the looser of the two would silently
decide what gets stored. What the toolbar restricts is what the editor makes
easy, which is an authoring affordance.
markdown’s preview is the opposite case and takes the opposite approach:
its source is what the user is typing this instant, so it has passed through
no sanitizer. The preview therefore builds React elements and never
touches dangerouslySetInnerHTML — a <script> in the draft is text,
structurally, with no injection point to get wrong.
Uploads carry a key, never bytes
file/image state is the KEY STRING the field.upload endpoint minted (a
key array under multiple()). Protocol §4 is explicit that the server never
accepts inline file data, and FileField.dehydrateValue refuses any key it
did not mint or the record does not already hold.
The widget POSTs one file per request to control.urls.upload through the
UploadTransport (default: XMLHttpRequest, because fetch cannot report
request-body progress), reads { key, url } back, and commits the key. The
url becomes the row’s preview; a key already in state on an edit page shows
its name instead, because form state carries the key and resolving it to a
(possibly signed) URL is a server-side act.
Both transports are replaceable, which is how a host adds auth headers or a gateway prefix:
export function PanelRoot({ children }: { children: ReactNode }) {
return (
<AdoniaProvider transports={{ fetch: myFetch, upload: myUploader }}>
{children}
</AdoniaProvider>
)
}repeater: state boundary, nested error paths
The node carries both key and children, and the children key an ITEM
(protocol §8). Three consequences the client half implements:
collectFieldNodesandcollectStateKeysstop at the first keyed node, so the form’s flat state holdsitemsand nothing from inside it;- each item renders through
SchemaRendereragainst the item as its state map, so a child keeps its own key (title, neveritems[2].title) and every field type — including another repeater — works inside a row; - errors arrive as runtime paths (
items.2.title) with no rewriting.FieldControlleris ABI-frozen and carries only the node’s own message, soSchemaRendereroverlays the scoped subset asprops.itemErrors({ '2.title': '…' }), exactly as it already overlays refetchedoptionsand reactiverequired.belongs-to-many’s pivot sub-forms use the same route.
Row-scoped reactivity is deliberately not evaluated on the client: a ./x
rule resolves against the row but an absolute path resolves against the root
form state, which a field component behind the frozen controller does not
have. §11.2 mirroring makes the server’s per-row prune authoritative anyway,
and failing OPEN is the safe direction — a field shown that the server drops
costs a keystroke; one wrongly hidden costs the user their data.
The date family does no arithmetic it does not have to
date and time are CIVIL values (docs/fields.md): the wire string and the
control value are literally the same characters, because running a zone
conversion over a zone-less day is the classic off-by-one-day bug. Only
datetime converts, since it is an absolute instant on the wire and
wall-clock in the control. It renders in displayTimezone, falling back to
the runtime’s own zone — declare the prop when the panel is server-rendered
across zones, or the SSR pass and its hydration will disagree.
Zone math uses Intl alone (src/components/fields/temporal.ts); Luxon is a
server-side optional peer and never reaches the browser bundle.
Selection values keep their wire type
props.options values ride the wire as they were declared, and the §11.1
base is vine.enum(values) over those same values. A select over numeric
ids therefore commits 1, not "1" — the DOM string is a rendering detail
carried separately as the option’s token.
Dependent options: the component never fetches
A select whose node carries reactive.refetch renders a loading state and
waits. The engine owns the capability URL, the withState slice, the
debounce and the abort; SchemaRenderer overlays the response onto
props.options.
The two states are distinguished by the prop, not by a flag:
reactive.refetchdeclared and nooptionsprop → the answer has not arrived → “Loading options…”,aria-busy;optionspresent but empty → the server answered, and the answer is nothing → “No options available”.
A dependent select whose parent narrows the list to zero has to say so rather than spin forever, which is why the distinction is worth a rule.
Display projections
In detail mode each field is replaced by its displayType counterpart
(§7.4), carrying chrome props only — label, helper, columnSpan, icon,
description, prefix, suffix and format. Every one of them is a
labelled <dl> pair rendered by EntryShell.
| Type key | Renders |
|---|---|
text-entry |
the scalar, with format (date/datetime/time) applied and the raw value kept on <time datetime>; copyable adds a clipboard button |
badge-entry |
a coloured pill per value, keyed through props.colors / props.icons |
image-entry |
the server-resolved (and, for a private disk, signed) URL |
code-entry |
a read-only <pre><code>, structured values pretty-printed |
key-value-entry |
a two-column <table> with keyLabel/valueLabel headers |
relation-entry |
the related label(s), linked when the value carries a server-built url |
repeater-entry |
one block per item, each rendered through the children the server already projected |
html |
trusted markup, injected verbatim — see below |
html trusts its input, deliberately
Sanitization is the server’s job and happens on the way IN. rich-text
sanitizes on dehydrate against the configured allowlist (§7.3, B2-5), so the
database holds safe markup and every consumer is safe — this panel, an API, a
public page, an export — not just the one that remembered to clean up.
Sanitizing on read instead would leave a client-side bypass (or an SSR pass
with no DOM) serving the raw value, and a second allowlist would drift from
the server’s, with the looser of the two deciding what renders.
The consequence is a rule: an html entry pointed at an attribute that never
passed through a sanitizing dehydrate is an XSS hole, and closing it is the
author’s job. s.html('body') over a rich-text field is safe by
construction; s.html() over a column filled by an importer is not.
Accessibility is part of the contract
Every component in the tranche is asserted against axe (WCAG 2.0/2.1 A + AA)
in packages/ui/tests/fields/. The rules the shell encodes once:
- a real
<label htmlFor>for single controls, a<legend>for groups (radio,checkbox-list, a range slider), the control inside its label for a checkbox; aria-describedbychaining affixes → constraint hints → helper → error, in reading order, andaria-invalidmirroringcontrol.error;- the error message carrying
role="alert", so a validation round-trip is announced; - keyboard operation on every custom control: the select’s combobox opens on
ArrowDown/Enter, moves with the arrows and Home/End, selects with
Enter/Space and closes on Escape, keeping focus on the trigger and pointing
aria-activedescendantat the active option.
Tests
packages/ui/tests/fields/, one file per component plus:
support.tsx— the shared harness.renderFieldhands the component a spy-backed, INERTFieldController(value in,setValueout, nothing read back), which is the contract for a stateless control;renderStatefulFieldfeeds every commit back as the next value, for the collection fields whose reconciliation is itself the behaviour under test.renderFromSnapshotrenders from the committed server snapshot for the type (packages/core/tests/fixtures/snapshots/) rather than from a hand-written node — the shared fixture is what lets the two halves of a field type be built in parallel without drifting.lazy_chunks.test.ts— walks the source graph and fails on a staticimportof anything undercomponents/fields/editors/.upload.test.tsx/relation.test.tsx— thefile/imageandbelongs-to*pairs have no committed server snapshot yet (B2-4 and B2-7 shipped the field classes but not the descriptor fixtures), so their nodes are written from the field classes’ prop bags.registry_completenesspicks the pair up automatically the day those snapshots land.registry_completeness.test.tsx— walks every committedfield-*snapshot and asserts eachtyperesolves to a registered component. A server field type cannot ship without its client half.ssr.test.tsx— runs in the node environment (nowindow, nodocument) and renders the whole tranche to static markup, which is the §13.4 SSR guarantee stated as a test.