Agent docs

13 · Forms and fields

One field anatomy, the widths that give a form its rhythm, sections as cards, the mode chooser,…

One field anatomy, the widths that give a form its rhythm, sections as cards, the mode chooser, tables and lists inside a form, the three modes, validation timing and message voice, loading and presets, keyboard and a11y, and the do-nots. Sources: the SchemaForm spec (docs/plan/65-schema-form-spec.md, section 7 is the draft of this doc), the three form inventories that fed it (docs/plan/65-schema-form-evidence/), and the kit's SchemaForm (packages/ui/docs/schema-form.md), which renders every rule here from a schema. The rendered companions are the component page /components/schema-form and the four module pattern pages under /patterns (section C).

This doc binds hand-composed fields in an app as much as generated ones. A form that an app builds by hand follows the same anatomy, or it is the second anatomy §A11 forbids.

Audience: an agent (or person) building or reviewing any form, settings page, wizard step, or field, generated or hand-built.

§A Principles and mental models#

1. One field anatomy, everywhere. Label above the control, always visible, text-sm font-medium (or the soft tone an app picks with labelTone: /65 ink, normal weight, text-xs, for a dense form or one under a title that already carries the weight); the required mark is the kit's RequiredMark, a red asterisk with a "Required" tooltip, centred on the label row beside the info mark; optional fields say nothing. Under the control: one help sentence in quiet ink, then a hint line (data, info ink), then warnings (warning ink), then one error (error ink), stacked in that order, each on its own line, only one error per field at a time. Side actions (regenerate, verify, reset, copy) sit at the end of the control row as icon-only buttons with the rich tooltip (doc 2 §A10) and render nowhere else. The kit renders this in packages/ui/src/schema-form/field.tsx:124-126 (the label and its mark) and the lines below it; a hand-built field copies the shape, not the file.

2. Widths are the rhythm. Control width follows the type: numeric fields and short enums are a third, a segmented control of three or four choices is a half, toggles are content-width, text and selects are full unless the schema says otherwise. A form where every control is full width is what "bland" looks like. The kit derives these defaults from the schema (packages/ui/src/schema-form/lint-schema.ts:516-521); x-width overrides one field at a time, not a whole form.

3. Sections are cards, and the advanced one starts closed. A section is a Card with a Kicker label and an optional one-line description, in declared order, spaced by doc 9's page composition, no bespoke gaps. advanced is collapsed by default and its header carries a count of values that differ from the baseline, the active preset else the defaults ("Advanced · 2 changed"), the same measure review mode marks with, so a closed section never hides a change silently.

4. A mode with behavioural weight is a row of cards, never a native select. When one choice changes which fields exist (generate versus edit, list versus text), the chooser is a row of Cards, one per branch, title and one line each, the chosen one carrying the primary ring; the branch's fields render below with the segment entrance from doc 11. A three-way choice with no field consequences may be a segmented control. Neither is ever a native <select>.

5. A table inside a form never grows sideways. Repeating rows render in the kit Table at compact density: the row-label field first, short fields as columns (a number's unit in its column header), longer and dependent fields in an inline expanded row, add at the bottom, duplicate and remove in a trailing actions cell, a per-row error count as a chip. Under the sm breakpoint the same rows are stacked cards. Rows too rich for a table (rules with typed arguments) are stacked cards at every width.

6. Three modes, one reading. edit is the form. review is every field as label plus value in prose, grouped by section, values that differ from the baseline marked, an Edit link per section that returns to the field; it is the confirm-and-report pattern (/patterns/confirm-and-report) and what a wizard's last step shows. snapshot is review with no links, over a frozen value; the job page's settings view. Read-only fields render their value as text in the control's place with the label unchanged, so a snapshot reads like the form it came from.

7. Validation speaks in plain sentences, at the right moment. A field validates on blur once touched, live once it has shown an error, and every field on submit, when the first error takes focus. Messages are sentences a person would say ("Enter a whole number between 1 and 20", "Choose at least one column"), never the schema's description reused as an error, never a keyword name. Warnings (x-warn, a value outside a suggestion list, the app's warning-tier issues) never block; a warned control carries the warning edge in the same tone as its line until an error takes over. Validation states live on the border, never on an outline: the kit's field frame keeps a constant 1px edge that changes colour (packages/ui/theme/fields.css:69-89), the focus ring is the only outline, and every coloured edge takes one step towards the ink on hover with the field transition (the same .field-edge-* rule for the field frame, Select and every grouped control; doc 01 §A8). Server and cross-field checks render in place like schema issues, and while one is in flight the form is not valid and the host's button says why.

8. The action bar is the host's, and its button is honest. The form exposes isValid, isDirty, pending and a submit() that returns the value to post (fields hidden by x-when pruned) or null. The host's primary button reads those, so a button that cannot act explains itself (recipe AP-10 row 26). The form never renders its own Save.

9. Loading is the form's own shape. Labels render as themselves and controls as bars, tables as a titled pair of bars, review values as bars. Never a spinner over nothing (doc 4 §A). A required-but-empty form is not an error state; it is the form.

10. Presets belong to the app, dirty tracking to the form. The picker (a template row, a Select, the console's listbox) is the app's control, because its semantics differ per app. The form takes the named values, reports isDirty against the active preset (else the defaults) ignoring the order of a choice list, and offers resetToPreset(). A computed default writes only while the field is untouched or still equal to its previous computed value.

12. Options read as sentences and carry their own marks. An option's label starts upper case ("Neutral", "Part type"), never the raw value, unless the value is an identifier (a SKU, a code, a path), which shows as typed. Options carry a leading icon wherever the set benefits from visual discrimination (chips especially), a colour swatch on the right when the choice is a tone or a colour (the row's hover previews it), and trailing text at the right edge for a count or a unit; a checklist may stack the description under the label with the checkbox centred on both. All of it comes from x-option-meta on the schema or from the option object the app resolves (packages/ui/docs/schema-form.md, x-option-meta), never from a hand-styled row. An unselected segment of a segmented control shows the light primary edge (the tint ladder, doc 01), no focus outline, and takes the warning or error tone with its field.

11. Keyboard and assistive order follow the visual order. Tab moves in reading order; segmented and radio groups are arrow-key groups; Enter in a table's last cell adds a row and Escape closes an expanded row. Every field has one id, htmlFor, aria-invalid, aria-required, aria-describedby wired to its help and message lines, group roles on choice groups, and aria-live="polite" on async option and validation states. There is never a second field anatomy in an app beside the kit's; if a screen needs a shape this doc does not have, the shape is added here first.

13. State is read from the platform; an attribute is minted only when CSS cannot see it. A stateful look hangs off the selector the platform already provides (:disabled, :read-only, :checked, [open], :focus-visible) wherever one exists: the theme styles the selector once and every caller gets the state for free, with nothing to keep in sync. An attribute is minted only for a state no selector can express, and then it follows the shipped data-tone shape: one axis, a closed value list, styled from theme/*.css (packages/ui/theme/fields.css:132-152, set at packages/ui/src/schema-form/widgets.tsx:426). A hand-set mirror of a state the platform can already see is weaker than the platform's own account of it, which is why a general data-state channel is rejected by name: beside the selectors and data-tone it would be a third way of saying the same thing.

§B Presets#

  • Anatomy: SchemaForm for a schema-driven form; the same anatomy composed by hand from Input (its checkbox and toggle modes included), Select and the kit's label and message idioms for a one-off field. Both are shown on /components/schema-form (the anatomy card).
  • Widths: x-width third | half | full; the defaults in §A2. Never full-width numerics.
  • Sections: x-section naming a declared section; advanced collapsed with the changed count.
  • Choosers: x-widget: mode-chooser for a branching choice (cards); segmented for three or four flat options; radio cards for a taxonomy-style choice with descriptions; x-option-meta for icons, swatches, trailing text and descriptions on any of them; x-option-layout: stacked for a checklist whose descriptions sit under their labels.
  • Rows: x-widget: table for short rows, list for rich rows; x-reorder is move up and down; x-seed-from fills rows from the file and hides add and remove.
  • Modes: edit | review | snapshot; a wizard's last step is review with onEditSection.
  • Feedback: x-warn and suggestion lists warn; validate for server checks; messages from the plain-sentence table in packages/ui/src/schema-form/validate.ts.
  • States: loading for the shape-matched skeleton; disabled for a frozen edit; presets through presets and activePreset.

§C Worked use cases through the apps#

The four V1 console modules are the held-to forms; each is a committed schema under docs/plan/65-schema-form-fixtures/ and a pattern page that renders it with the app's slots stubbed.

  • Content generation (/patterns/module-content-generation, docs/plan/65-schema-form-fixtures/enhancement.params.schema.json): the fields table with the prompt and rules in the expanded row (§A5), the template picker above the form backed by presets (§A10), the live preview beside the form as the host's aside, the review mode as the wizard's last step (§A6).
  • Image generation (/patterns/module-image-generation, image-generation.params.schema.json): generate versus edit as mode cards with the operation nested (§A4), colour swatches and the compact dropzone inside the anatomy (§A1), the "produced nothing" outcome surfaced through the form's status line as a warning, not an error (§A7).
  • Attribute normalization (/patterns/module-attribute-normalization, attribute-normalization.params.schema.json): rows seeded from the file's distinct part types with a fetched multiselect per row and a coverage hint per row (§A5, §A1's hint line), the taxonomy as radio cards.
  • Part type matching (/patterns/module-part-type-matching, part-type-matching.params.schema.json): the smallest module, four fields and no sections; the checklist over the file's columns with per-option hints from a sample, a two-option segmented at a half, an admin-only integer at a third with its unit beside it (§A1, §A2).

The kit's own proof: packages/ui/test/schema-form.test.tsx, schema-form-rows.test.tsx and schema-form-day3.test.tsx mount each of these from its fixture, and every widget is screenshotted in both themes at 1280 and 375 under .claude/output/20260818-schemaform-build/ before it is called done.

Do-nots#

Never a native <select> for a mode with behavioural weight; never a required dot or a bare asterisk without its tooltip (the mark is RequiredMark); never an outline for a validation state (the edge carries the tone); never a lower-case option label unless the value is an identifier; never a full-width numeric field; never a spinner as the loading state; never an error message that is the schema's description; never a silent fallback when a schema is outside the kit's subset (the form refuses by name, packages/ui/docs/schema-form.md); never layout keys in a schema; never a second field anatomy in an app beside the kit's.

@versable-git/ui · reference, canon, and method, read in place