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. The full spec is docs/plan/65-schema-form-spec.md in the versable-builder repo, which the published package does not carry. You do not need it to use this component: the vocabulary it defines is listed under Contract below, and everything the component refuses is under Banned combinations.

The canon behind it. method/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), method/design-language/03-status-language.md (error, warning and info ink for the three feedback lines), method/design-language/09-page-composition.md (section spacing), method/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.

  • sectionLayout?: "cards" | "tabs" (default cards). cards is one card per section. tabs is one card with a tab per section: the form owns that card, so never wrap it in another. Every tab carries an icon (the section's x-icon, else a generic one). All panels stay mounted and only the active one shows, so a hidden section keeps its values and its validation. A tab whose fields fail shows an error count. submit() opens the tab holding the first error before it focuses the field. The review mode's Edit link opens the matching tab when the host flips back to edit on the same mounted form. The advanced section is an ordinary tab here, with its changed count when nothing fails. Loose fields sit above the tab strip, and a form with fewer than two visible sections renders as cards. Tabs are one level on purpose: a section that needs sub-levels becomes two sections.

  • compact?: boolean (default false). The default section card matches every other card; pass compact for a dense config screen.

  • A section card's header is the plain card title (the section label) with the section's icon when it declares one, the description as the subtitle, the same anatomy as a sibling card. A section is { id, label, description, "x-icon" }; x-icon is a kit icon key and is read on section objects only.

  • A rows table inside a section card or the tabs card runs to the card's edge like every other table, with its label and row count in the padding. Inside a group fieldset, a row's expanded detail, or a flat grid with no card, it keeps its inset.

  • 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. On wide screens it is sticky and bounded to the viewport height with its own scroll, so a tall panel never pushes past the fold.

  • A table's add-row seeds free-text string cells (input and textarea widgets) with "", so the new row's inputs are controlled and an untouched cell reads as empty. Enum, number and boolean cells stay unset unless the schema declares a default.

  • An x-labels or x-option-meta key that is not in the enum is ignored, with a console warning in development; it no longer refuses the whole schema. Other malformed entries still refuse.

  • x-widget: "mode-chooser" lays two or four options in pairs (four is 2x2) and any other count in up to three columns.

  • 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: three methods, submit(), focusFirstError() and getValidity().

    submit() marks the form submitted, so every field shows its error as if each had been blurred, focuses the first one, and returns the value to post. It returns null instead while any error or pending check stands.

    Three rules decide what that returned value contains. Fields hidden by x-when are pruned. Fields above the viewer's tier keep their defaults, because the module still needs them. Keys the schema does not declare pass through untouched, so a host that carries its own bookkeeping inside the value keeps it, and a host that must post only declared keys strips the rest itself.

  • 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).

Run the lint from a shell#

lint-schema <file>... [--json] runs the same walk from a subprocess, for a module that publishes its schema as a file rather than importing the kit. Exit 0 is clean and silent; exit 1 prints one line per refusal, <file>: <path>: <keyword>: <reason>; exit 2 means a file could not be read or parsed. --json prints the full LintSchemaResult array instead. The bin loads src/schema-form/lint-schema.ts directly, so a schema the shell refuses is the schema the component would have refused too.

Laws it enforces#

  • Fit, then truncate, never clip: the aside stays inside the viewport and scrolls on its own.
  • Rows meet the card edge: a rows table in a section card or the tabs card bleeds like every other table.
  • A tab has an icon: every section tab carries one, and a section with no x-icon gets the generic one.
  • Show only what has something to say: a stray label key is dropped quietly instead of replacing the form with a refusal.

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, recorded in the versable-builder repo at docs/plan/67-schemaform-r10-answers.md).
  • 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, x-section and a section's x-icon; pixels, columns and colours belong to the kit.
  • Never a SchemaForm wrapped in a Card of the app's own; with sections the form already draws the cards (or, with sectionLayout="tabs", the one card).
  • Never one SchemaForm per section under the kit Tabs to fake a tabbed form; that splits the value, the validation and the review mode. Use sectionLayout="tabs".
  • Never a second tab level inside a section; split the section instead.
  • 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); method/app-patterns/16-recipe-module-settings-form.md (what a request for a module's settings page brings along).

Modal, and read this before you put a SchemaForm in one. modal.md says a Select or Dropdown inside a Modal must pass noPortal, or its menu renders outside the dialog and is clipped. A SchemaForm inside a Modal now takes the same prop: <SchemaForm noPortal> reaches every Select the form renders, so a choice field's listbox opens inside the dialog instead of behind its top layer.

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