# Forms as data, painted accessibly

> Texuli schema-driven forms (@texuli/forms, in development): every field type, announced validation, multi-step flows, conditions, drafts and agent-made forms.

Language: en
Canonical: https://texuli.com/forms-page/
Markdown: https://texuli.com/forms-page.md (this page). Every texuli.com page has a Markdown twin at its URL plus `.md` (the home page is https://texuli.com/index.md), and any page URL answers `Accept: text/markdown` with its twin instead of HTML.
Last Updated: 2026-11-13

---

![A pearl form card with input bars, a toggle, radio spheres, a ticked checkbox and a button](https://texuli.com/_astro/page-forms.Bpb2nPzF.webp)

@texuli/forms · in development · on npm from 0.8.0

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](https://texuli.com/guides/forms/).

- `forms` SchemaForm, texuli controls
- `forms/model` schema, logic, rules, drafts
- `forms/host` agent forms in the player
- `core/expression` bounded conditions and rules
- `core/data-source` host 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.

### Every field type

20 field types, each painted by a texuli control and named by its label.

 Text and numbers Project title

A single line of text.

 Summary

Several lines, at most 280 characters.

 Seats

 Contact email

 Website

https and http here (protocols); the URL policy checks both.

 Labels Loading…

Type a label and press Enter. At most five.

 Deploy password

At least 12 characters. Never saved in drafts; masked in summaries.

 Verification code

Six digits; one input with autocomplete="one-time-code".

 Choices and toggles Workspace

 Shared with Loading…
 Loading…

Plan Loading…
 Loading…

Priority Loading…

 Accent color Loading…

 Owner

A host-painted field: validated, summarized and submitted with the rest.

 Dates, times and files Start date Loading…

 Stand-up time Loading…

 Brief Loading…

A PDF or an image up to 5 MB. Nothing is uploaded: the host would.

 Kickoff Loading…

### 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
}
```

20 field types

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

Format Value

Matches `email`

22 named formats; a schema can only name one

| Format | What it accepts | Accepted | Refused |
| --- | --- | --- | --- |
| `email` | One @, no spaces, a dotted domain | `ada@example.com` | `ada@` |
| `url` | https only, through the untrusted URL policy | `https://example.com/docs` | `javascript:alert(1)` |
| `phone` | 7 to 15 digits; spaces, dots, hyphens, parentheses and one leading + | `+1 555 010 0199` | `call me` |
| `postal-code` | Any country: 2 to 10 letters, digits, spaces or hyphens | `SW1A 1AA` | `!!!` |
| `postal-code-us` | United States: ZIP or ZIP+4 | `94107-1234` | `9410` |
| `postal-code-ca` | Canada | `K1A 0B1` | `12345` |
| `postal-code-uk` | United Kingdom | `EC1A 1BB` | `1234` |
| `postal-code-de` | Germany: five digits | `10115` | `1011` |
| `postal-code-fr` | France: five digits | `75008` | `7500A` |
| `postal-code-es` | Spain: five digits | `28013` | `2801` |
| `postal-code-mx` | Mexico: five digits | `06600` | `066` |
| `postal-code-br` | Brazil: CEP, the hyphen optional | `01310-100` | `0131` |
| `postal-code-co` | Colombia: six digits | `110111` | `11011` |
| `postal-code-in` | India: six-digit PIN | `110001` | `11000` |
| `postal-code-jp` | Japan: three digits, then four, the hyphen optional | `100-0001` | `1000-01` |
| `postal-code-au` | Australia: four digits | `2000` | `200` |
| `digits` | Digits only | `004211` | `42a` |
| `alphanumeric` | Letters and digits | `Harbor42` | `Harbor 42` |
| `slug` | Lowercase words joined by hyphens | `harbor-notes` | `Harbor Notes` |
| `iso-date` | A real calendar date, YYYY-MM-DD | `2026-11-03` | `2026-13-03` |
| `time` | 24-hour HH:MM | `09:30` | `25:00` |
| `hex-color` | #rgb or #rrggbb | `#0ea5e9` | `#0ea5e` |

## 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.

 The server refuses the next submit

Nothing sent yet.

### Create a workspace

Submit it empty first: an error summary takes focus and links to each answer.

 Identity Workspace handle

Lowercase words joined by hyphens, 3 to 20 characters. Try harbor.

 Owner email

 Accent color

 Billing ZIP code

 Capacity Seats

Sold in fives, from 5 to 100.

 Topics

Pick one to three.

 Loading…
 Labels Loading…

Up to four labels, each 2 to 12 characters.

 Logo Loading…

A PNG or SVG up to 200 KB.

 Trial Trial starts Loading…

 Trial ends Loading…

12 closed rules, and where the form above shows each

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

### Request a pilot workspace

Fields, a section and a whole step appear from your answers.

#### About you

 Full name

 Work email

 Role Loading…

### Steps

- **About you** runs
- **Your team** runs
- **Security review** shows when `people > 50` skipped
- **Confirm** runs

Conditional fields, live

| Field | Conditions | Shown | Required | Editable |
| --- | --- | --- | --- | --- |
| Your role | show: `role == "other"` require: `role == "other"` | no | no | no |
| Sign-on domain | require: `sso` enable: `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.

### Launch feedback

Type anything: the draft saves itself. Then remount the form.

 Headline

 How did the launch go? Loading…
 Score Loading…

 Details

### 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.

Keys in a schema form

| 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`

## For agents

Schema-driven forms with validation and steps, live in the browser. The live parts run in the browser and are not in this Markdown; the operations below do the same work through the read-only MCP server at https://texuli.com/mcp (no sign-in; see https://texuli.com/auth.md).

MCP operations ([reference](https://github.com/DailybotHQ/texuli/blob/main/packages/tools/docs/ops.md#operations)):

- `catalog.search`: Search the component catalog.
- `component.get`: Read a component manifest.

Docs:

- [@texuli/forms README](https://github.com/DailybotHQ/texuli/blob/main/packages/forms/README.md)
