@texuli/forms · in development · on npm from 0.8.0
Forms as data, painted accessibly
A closed form schema becomes an accessible form: typed fields in sections or steps, conditions over earlier answers, named formats, host validators and cross-field rules, with announced errors, drafts and autosave. Agents send the same schema as data. Everything below is live. @texuli/forms is in development and on npm from 0.8.0, a pre-1.0 release. For hand-written forms with Field and Form, read the forms guide.
formsSchemaForm, texuli controlsforms/modelschema, logic, rules, draftsforms/hostagent forms in the playercore/expressionbounded conditions and rulescore/data-sourcehost data behind fields
Every field type
One schema, twenty field types, each painted by the texuli control for it and named by its label. Values are typed: numbers stay numbers, dates are YYYY-MM-DD, multiple choices are lists. Change anything and watch the values; submit to see what a host receives.
Typed values
Numbers are numbers, dates are YYYY-MM-DD, choices are lists. Change any field.
{
"projectTitle": "Harbor Notes",
"workspace": "lumen",
"shared": [
"harbor"
],
"plan": "team",
"priority": 6,
"labels": [
"launch"
],
"public": true
} | Type | Painted by | Value | Sample label |
|---|---|---|---|
text | Input | string | Project title |
textarea | Textarea | string | Summary |
number | Input (number) | number or null | Seats |
email | Input (email) | string | Contact email |
url | Input (url) | string (https, or http where protocols allow it) | Website |
date | DatePicker | YYYY-MM-DD or null | Start date |
time | TimeField | HH:MM or null | Stand-up time |
select | Select | string | Workspace |
multiselect | Checkbox group | string list | Shared with |
checkbox | Checkbox | boolean | Notify members |
radio | RadioGroup | string | Plan |
switch | Switch | boolean | Public project |
slider | Slider | number | Priority |
file | FileUpload | file metadata list (the host uploads) | Brief |
tags | TagsInput | string list | Labels |
password | Input (password) with a reveal toggle | string (never in drafts, masked in summaries) | Deploy password |
otp | Input (one-time code) | string of digits | Verification code |
color | ColorPicker | #hex or null | Accent color |
datetime | DatePicker + TimeField | YYYY-MM-DDTHH:MM or null | Kickoff |
custom | Painted by the page (a field snippet) | any JSON value the host painter sets | Owner |
Named formats, never patterns
A schema names a format; it never carries a regular expression. Each format is owned code that runs in linear time on input capped at 1,000 characters, so an agent cannot ship a pattern that backtracks. Try any value, or a sample from the table.
Matches email
| Format | What it accepts | Accepted | Refused |
|---|---|---|---|
email | One @, no spaces, a dotted domain | ||
url | https only, through the untrusted URL policy | ||
phone | 7 to 15 digits; spaces, dots, hyphens, parentheses and one leading + | ||
postal-code | Any country: 2 to 10 letters, digits, spaces or hyphens | ||
postal-code-us | United States: ZIP or ZIP+4 | ||
postal-code-ca | Canada | ||
postal-code-uk | United Kingdom | ||
postal-code-de | Germany: five digits | ||
postal-code-fr | France: five digits | ||
postal-code-es | Spain: five digits | ||
postal-code-mx | Mexico: five digits | ||
postal-code-br | Brazil: CEP, the hyphen optional | ||
postal-code-co | Colombia: six digits | ||
postal-code-in | India: six-digit PIN | ||
postal-code-jp | Japan: three digits, then four, the hyphen optional | ||
postal-code-au | Australia: four digits | ||
digits | Digits only | ||
alphanumeric | Letters and digits | ||
slug | Lowercase words joined by hyphens | ||
iso-date | A real calendar date, YYYY-MM-DD | ||
time | 24-hour HH:MM | ||
hex-color | #rgb or #rrggbb |
Validation with announced errors
Every closed rule, a host validator that checks the handle asynchronously, and a cross-field rule. Submit the form empty: an error summary takes focus, says how many answers need attention and links to each one, and every error is linked to its control. Errors clear as you fix them. Turn on the server refusal to see server errors land on their fields.
| Rule | In this form |
|---|---|
required | Workspace handle, Owner email, Seats and both trial dates (they start filled in) |
minLength | Workspace handle (3), each label (2) |
maxLength | Workspace handle (20), each label (12) |
min | Seats (5), Trial starts (2026-01-01) |
max | Seats (100) |
step | Seats (in fives) |
format | Workspace handle (slug), Owner email, Accent color (hex-color), Billing ZIP code |
minItems | Topics (1) |
maxItems | Topics (3), Labels (4), Logo (1) |
option | A value outside the choices: refused when a draft or a host restores it (try the tampered draft below) |
fileType | Logo (PNG or SVG) |
fileSize | Logo (200 KB) |
Plus a host validator by name (the handle check, asynchronous and abortable) and cross-field rules as expressions (the trial must end after it starts). A field’s messages override any rule’s text.
Multi-step flows and conditional logic
Steps with a progress Stepper; Next validates the current step, and each new step heading takes focus and is announced. Conditions are expressions over earlier answers: a field shows when you pick Something else, a section appears for Enterprise, a field becomes editable when single sign-on is on, and a whole step runs only for teams of more than 50.
Steps
- About you runs
- Your team runs
- Security review shows when
people > 50skipped - Confirm runs
| Field | Conditions | Shown | Required | Editable |
|---|---|---|---|---|
| Your role | show: role == "other"require: role == "other" | no | no | no |
| Sign-on domain | require: ssoenable: sso | no | no | no |
Cross-field rules
plan != "free" or people <= 5, or: “The free plan covers up to five people.”
Drafts and autosave
Every change schedules a save to the host storage, here an in-page adapter you can read. Remount the form and the draft comes back, announced once. A restored draft is untrusted input: each value is coerced to its field, unknown fields are dropped, and a choice outside the options is refused on submit.
No draft yet.
What the storage holds
Key forms-page-feedback; saves so far: 0.
Files are never stored.
Empty
Fields backed by host data
The team choices are rows of a host data source, and the handle is checked against another. The page declares both with @texuli/core/data-source and answers from memory; the loader validates each query, shares identical reads, caches pages and retries a rate-limited read. The log shows exactly what each adapter received.
Loading the teams from the host source…
Request log
What each adapter received: a validated query, never a URL or code. Newest first.
- No calls yet.
The form names nothing of the source: the host reads it and hands the form its choices and a validator. @texuli/core/data-source ships in 0.8.0 too.
Forms as agent data
An agent sends a whole form as one schema-form node of a blueprint (spec 0.8.0). The player validates it at the untrusted tier and paints it through the form host of @texuli/forms; submitting proposes the node’s action to the host with the answers. A schema that smuggles a pattern is refused before anything paints.
The blueprint the agent wrote
Spec 0.8.0 adds the schema-form catalog id; it ships in the 0.8.0 release of @texuli/spec. Conditions travel as expression trees.
{
"format": "texuli-blueprint",
"version": 1,
"spec": "0.8.0",
"id": "onboarding-form",
"catalog": "0000000000000000000000000000000000000000000000000000000000000000",
"status": "draft",
"createdAt": "2026-10-29T09:00:00Z",
"updatedAt": "2026-10-29T09:00:00Z",
"generator": {
"kind": "agent",
"name": "Harbor assistant",
"model": "example-model"
},
"tier": "untrusted",
"profile": "web",
"tree": {
"id": "layout-page",
"props": {
"title": "Onboarding"
},
"children": [
{
"id": "schema-form",
"key": "onboarding-form",
"props": {
"action": "book-onboarding",
"schema": {
"title": "Book an onboarding session",
"description": "Harbor assistant drafted this form from the chat. Answers go to the host.",
"submitLabel": "Book session",
"steps": [
{
"id": "contact",
"title": "Contact",
"fields": [
{
"name": "name",
"type": "text",
"label": "Your name",
"required": true
},
{
"name": "email",
"type": "email",
"label": "Work email",
"required": true
},
{
"name": "phone",
"type": "text",
"label": "Phone",
"format": "phone"
}
]
},
{
"id": "session",
"title": "Session",
"fields": [
{
"name": "format",
"type": "radio",
"label": "Format",
"required": true,
"options": [
{
"value": "video",
"label": "Video call"
},
{
"value": "onsite",
"label": "On site"
}
]
},
{
"name": "address",
"type": "text",
"label": "Office address",
"when": {
"show": {
"op": "eq",
"left": {
"op": "field",
"name": "format"
},
"right": {
"op": "str",
"value": "onsite"
}
},
"require": {
"op": "eq",
"left": {
"op": "field",
"name": "format"
},
"right": {
"op": "str",
"value": "onsite"
}
}
}
},
{
"name": "day",
"type": "date",
"label": "Preferred day",
"required": true,
"min": "2026-11-02"
},
{
"name": "from",
"type": "time",
"label": "From",
"required": true
},
{
"name": "to",
"type": "time",
"label": "To",
"required": true
}
]
}
],
"rules": [
{
"id": "window",
"field": "to",
"message": "The session must end after it starts.",
"test": {
"op": "gt",
"left": {
"op": "field",
"name": "to"
},
"right": {
"op": "field",
"name": "from"
}
}
}
]
}
}
}
]
}
}The hostile field
{
"name": "name",
"type": "text",
"label": "Your name",
"pattern": "^(a+)+$"
}Painted by the player
Answer and book: the player proposes the action to the host.
Loading the player…
Keyboard and screen readers
Every form on this page works without a pointer, in the native tab order, with every control labelled.
| Where | Keys | Effect |
|---|---|---|
| Any field | Tab, Shift+Tab | Next or previous control, in document order |
| Text field | Enter | Next step, or submit on the last one |
| Radio group | Arrow keys | Move and select a choice |
| Select | Space, Enter, arrows, type to find | Open, move, pick |
| Slider | Arrows, Page Up, Page Down, Home, End | Change the value |
| Tags | Enter; Backspace in the empty input | Add the typed tag; move to the tags already added |
| Date picker | Up and Down in a segment; the calendar button | Change day, month or year; open the calendar dialog |
| Error summary | Enter on a link | Focus that answer |
What a screen reader hears
- A named form landmark, its description linked.
- Each control’s label, description and error reach the focusable element (the Field rule).
- The error summary takes focus and counts the answers that need attention.
- Each new step heading takes focus; “Step 2 of 4: Your team” is announced.
- “Checking…” is linked to a field while its host validator runs.
- A restored draft is announced once; autosaves stay quiet.
- Step transitions fade through texuli/motion and are instant under reduced motion.
What keeps it safe
Forms are data. These hold by construction in the validator and the view.
Closed schemas
Unknown members, a pattern included, are refused. Members that do not fit the field type, duplicate names and defaults outside the options are refused too, with a JSON path.
Bounded logic
Conditions and rules are expressions of one closed language, evaluated within fixed step limits. A condition reads only earlier fields, so visibility resolves in one pass with no cycles.
Names, not code
Formats and host validators are names. A validator the host did not register fails closed, and the schema can never bring its own.
Text is text
Labels, descriptions and messages render as text, never markup. Help links pass the URL policy at the schema’s tier.
Hidden means not sent
A hidden or disabled field is neither validated nor submitted, and reads as empty to the conditions after it.
Untrusted drafts
Restored drafts are coerced field by field like any outside input; storage failures set a status and never throw.
In code
import { SchemaForm } from '@texuli/forms';
import { validateFormSchema } from '@texuli/forms/model';
const result = validateFormSchema(schema); // untrusted tier by default
{#if result.ok}
<SchemaForm
form={result.form}
validators={{ 'handle-free': checkHandle }}
drafts={{ storage: createWebStorageDrafts(localStorage), key: 'signup' }}
onSubmit={async ({ values, files }) => save(values, files)}
/>
{/if}import { createFormPlayerHost } from '@texuli/forms/host';
import { Player } from '@texuli/player';
const formHost = createFormPlayerHost({ validators: { 'handle-free': checkHandle } });
<Player {blueprint} tier="untrusted" {formHost} onAction={(intent) => 'confirmed'} />Package docs
The site does not render the package docs yet. Read them in the repository:
- @texuli/forms:
packages/forms/README.md - SchemaForm:
packages/forms/docs/schema-form.md - Model:
packages/forms/docs/model.md - Host:
packages/forms/docs/host.md - The expression language:
packages/core/docs/core-expression.md - Data sources:
packages/core/docs/core-data-source.md - ADR-015: one bounded expression language:
docs/DECISIONS.md


