Agent docs

SchemaForm

A form generated from a module's JSON Schema, in the kit's one field anatomy; refuses anything outside its vocabulary by name.

A module publishes JSON Schema for its job parameters; SchemaForm turns that schema into the form a customer fills, with the kit's own fields, one anatomy for every field, sections as cards, dependent fields, audience tiers, plain-sentence validation and, when the schema steps outside the declared subset, a refusal in place of the form that names the path and the keyword. lintSchema is the same walker on its own, for the contract side's manifest lint. Spec: docs/plan/65-schema-form-spec.md; the vocabulary is its section 6, the anatomy its section 7.

The canon behind it. docs/design-language/13-forms-and-fields.md §A1 to §A11 (13-forms-and-fields.md:15, one field anatomy everywhere; 13-forms-and-fields.md:18, widths are the rhythm), docs/design-language/03-status-language.md (error, warning and info ink for the three feedback lines), docs/design-language/09-page-composition.md (section spacing), docs/design-language/11-motion.md (the segment entrance dependent fields use).

When to reach for it#

Any settings form whose fields a module declares: the console's four V1 modules (content generation, image generation, attribute normalization, part type matching), and every module after them. Also a hand-composed field that wants the same anatomy: the label, required mark, help, hint, warning and error lines are the kit's rule for forms, generated or not.

Do not reach for it as a general JSON Schema renderer; the subset is the product and anyOf, allOf, if, remote $ref, open maps and tuple arrays refuse. Do not reach for it as the wizard shell, the file step, the live preview or the per-item review editor; those are slots beside it (spec section 2).

Contract#

What ships: every widget of the vocabulary renders (strings, numbers, booleans, single and multiple choice, colour, file, prompt, tables and lists with their row lifecycle, mode choosers over a discriminated oneOf), with sections, x-when, x-tier, x-width, x-warn, x-suggestions-from, seeded rows, sticky computed defaults, validation and messages, the three modes, presets, a loading skeleton, and the keyboard contract.

  • schema (required): the module's params or item schema. Linted at mount with lintSchema; a refusal renders as one Alert tone="error" naming the first three refusals and the count, and nothing else.
  • value, onChange (required): the form's data, controlled by the host. On the first mount SchemaForm fills the schema's defaults into any absent key and calls onChange once; a value that already carries the keys is left as it is.
  • mode?: "edit" | "review" | "snapshot" (default edit). Review reads every field as label plus value in prose, grouped by section, values that differ from the baseline (the active preset, else the defaults) marked, an Edit link per section that calls onEditSection(sectionId | null); snapshot is the same reading with no links, for a frozen value.
  • tier?: "customer" | "admin" | "team" (default customer): the viewer. Fields with an x-tier above it are neither rendered nor validated; team sees everything, admin sees admin and customer fields.
  • labelTone?: "default" | "soft" (default default): how field labels read. soft is the quieter variance (/65 ink, normal weight, text-xs) for a dense form or one that sits under a card title that already carries the weight. The required mark is the kit's RequiredMark in both tones.
  • settings?: the $settings.<key> map x-when and the {$settings.<key>} placeholders in x-options-from read.
  • context?: data the schema names by reference: x-options-from: "item.columns" reads context["item.columns"] (a list of values or a value-to-label record); x-hint-from: "item.sample(3)" reads context["item.sample(3)"] (a string for one hint line, a record for a per-option hint, or a function of { row, value } returning either).
  • resolveOptions?(path, { row, settings }): answers x-options-from and x-suggestions-from for paths not found in context, with {row.<field>} and {$settings.<key>} already substituted. Returns a list of strings or { value, label, description, disabled, icon, swatch, trailing } (the same decoration x-option-meta gives a static enum: a kit icon before the label, a colour dot on the right that also tints the row's hover, trailing text at the right edge). Labels read as sentences (first letter upper case; a raw value's underscores as spaces) unless the value is an identifier, which shows as typed. While it runs the field shows a loading line; on failure it shows a fallback sentence with Retry; a required field whose list is loading makes the form pending.
  • validate?(value): the app's cross-field and server checks, run on every change; returns issues { path, message, tier } that render under their field like schema issues, or above the form when path is empty.
  • widgets?: named components for x-widget: custom (x-custom names one); each receives { id, plan, value, onChange, onBlur, invalid, disabled, readOnly, describedBy } and renders inside the standard anatomy.
  • actions?: side actions keyed by plan path (fields[].prompt): { icon, label, help, onClick({ path, value }) }, rendered icon-only with the rich tooltip at the end of the control row.
  • aside?: rendered beside the form at 1280 and below it at 375.
  • status?: { tone, text }, one line the app owns (a preview outcome), rendered above the fields.
  • onValidityChange?({ isValid, pending, errors, warnings, isDirty }); isDirty compares against the active preset (defaults filled), else the defaults, ignoring the order of a choice list.
  • presets?, activePreset?: named values the host's own picker chooses between; ref.resetToPreset() puts the active one back (or the defaults) and clears the submitted state.
  • loading?: a shape-matched skeleton, labels as themselves and controls as bars, tables and lists as a titled pair of bars, review values as bars.
  • widgets.prompt replaces the built-in prompt slot (a textarea with the item's variables as chips inserted at the caret); the console's editor plugs in there.
  • ref: submit() marks the form submitted (every field shows its error, as if each had been blurred), focuses the first error, and returns the value to post with x-when-hidden fields pruned (fields above the viewer's tier keep their defaults, since the module needs them; keys the schema does not declare pass through untouched, so a host that carries its own bookkeeping in the value keeps it, and a host that must post only declared keys strips the rest), or null while errors or a pending check stand; focusFirstError(); getValidity().
  • disabled?, className?.

Validation timing: a field's error shows after its first blur or after submit(); warnings (x-warn, suggestions outside the list, the app's warning-tier issues) show at once and never block; a warned control carries the warning edge (the same tone as its line) until an error takes over, and every coloured edge takes one step towards the ink on hover, the same step on the field frame, Select and every grouped control. Messages are plain sentences per keyword ("Enter a whole number between 1 and 20", "Choose at least one"); the schema's description is never reused as an error.

lintSchema(schema, { customWidgets? }) returns { ok: true, plan } or { ok: false, refusals } (an x-widget: custom refuses unless its name is in customWidgets, on the bare call too, so the contract's manifest lint and the console agree; a leaf's description renders as a tooltip on an info mark after the label); refusal paths are JSON Pointers into the schema, plan paths are value paths (fields[].prompt).

Sanctioned combinations#

  • The console wizard step: SchemaForm with aside={<PreviewTable/>}, settings={{ module_version }}, resolveOptions bound to the API, the wizard's own primary button driven by onValidityChange and ref.submit().
  • The job page: the same component in snapshot mode over the frozen envelope.
  • A hand-composed field beside generated ones: the same label, required mark, help and message anatomy, never a second one.

Banned combinations#

  • Never a required dot or a bare asterisk without its tooltip; the required mark is the kit's RequiredMark (a red asterisk with a "Required" tooltip), the same one Input draws (owner ruling W01, doc 67).
  • Never a native <select> for a mode with behavioural weight; a discriminated oneOf is a mode chooser or a segmented control.
  • Never a full-width numeric field; numbers default to a third of the grid.
  • Never a spinner as the form's loading state; the loading form is a shape-matched skeleton.
  • Never a silent fallback when a schema is outside the subset; the refusal renders in place of the form.
  • Never the schema's description as an error message.
  • Never layout keys in a schema beyond x-width and x-section; pixels, columns and colours belong to the kit.
  • Never a second field anatomy in an app beside the kit's.

Before you adopt this#

  • Is the schema inside the vocabulary? Run lintSchema over it first; fix every refusal in the schema, not around it.
  • Who is the viewer? Pass tier; a customer must never see an admin field.
  • Where do the options come from? Static enums in the schema, context data under the exact reference string, or resolveOptions for a path.
  • Who owns the action bar? The host does; wire its primary button to onValidityChange and ref.submit().
  • Both themes at 1280 and 375, before it is called done.

Travels with#

Input, Select, Chip, Card, Kicker, Alert, Button, Tooltip (the primitives it composes); the confirm-and-report pattern (review mode's shape); docs/app-patterns/16-recipe-module-settings-form.md (what a request for a module's settings page brings along).

Snippet#

import { SchemaForm, type SchemaFormHandle } from "@versable-git/ui";
import { useRef, useState } from "react";
const handle = useRef<SchemaFormHandle>(null);
const [params, setParams] = useState<Record<string, unknown>>({});
const [canSubmit, setCanSubmit] = useState(false);
<SchemaForm
ref={handle}
schema={manifest.capabilities["part.match"].params}
value={params}
onChange={setParams}
tier="admin"
context={{ "item.columns": columns, "item.sample(3)": samples }}
resolveOptions={(path, { settings }) => api.options(path, settings)}
onValidityChange={(v) => setCanSubmit(v.isValid && !v.pending)}
/>
<Button color="primary" disabled={!canSubmit} onClick={() => { const out = handle.current?.submit(); if (out) post(out); }}>Start job</Button>
@versable-git/ui · reference, canon, and method, read in place