A pearl form card with input bars, a toggle, radio spheres, a ticked checkbox and a button

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

  1. formsSchemaForm, texuli controls
  2. forms/modelschema, logic, rules, drafts
  3. forms/hostagent forms in the player
  4. core/expressionbounded conditions and rules
  5. core/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.

20 field types
TypePainted byValueSample label
textInputstringProject title
textareaTextareastringSummary
numberInput (number)number or nullSeats
emailInput (email)stringContact email
urlInput (url)string (https, or http where protocols allow it)Website
dateDatePickerYYYY-MM-DD or nullStart date
timeTimeFieldHH:MM or nullStand-up time
selectSelectstringWorkspace
multiselectCheckbox groupstring listShared with
checkboxCheckboxbooleanNotify members
radioRadioGroupstringPlan
switchSwitchbooleanPublic project
sliderSlidernumberPriority
fileFileUploadfile metadata list (the host uploads)Brief
tagsTagsInputstring listLabels
passwordInput (password) with a reveal togglestring (never in drafts, masked in summaries)Deploy password
otpInput (one-time code)string of digitsVerification code
colorColorPicker#hex or nullAccent color
datetimeDatePicker + TimeFieldYYYY-MM-DDTHH:MM or nullKickoff
customPainted by the page (a field snippet)any JSON value the host painter setsOwner

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

22 named formats; a schema can only name one
FormatWhat it acceptsAcceptedRefused
emailOne @, no spaces, a dotted domain
urlhttps only, through the untrusted URL policy
phone7 to 15 digits; spaces, dots, hyphens, parentheses and one leading +
postal-codeAny country: 2 to 10 letters, digits, spaces or hyphens
postal-code-usUnited States: ZIP or ZIP+4
postal-code-caCanada
postal-code-ukUnited Kingdom
postal-code-deGermany: five digits
postal-code-frFrance: five digits
postal-code-esSpain: five digits
postal-code-mxMexico: five digits
postal-code-brBrazil: CEP, the hyphen optional
postal-code-coColombia: six digits
postal-code-inIndia: six-digit PIN
postal-code-jpJapan: three digits, then four, the hyphen optional
postal-code-auAustralia: four digits
digitsDigits only
alphanumericLetters and digits
slugLowercase words joined by hyphens
iso-dateA real calendar date, YYYY-MM-DD
time24-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.

Nothing sent yet.

Create a workspace

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

Identity

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

Capacity

Sold in fives, from 5 to 100.

Topics

Pick one to three.

Loading…
Loading…

Up to four labels, each 2 to 12 characters.

A PNG or SVG up to 200 KB.

Trial
Loading…
Loading…
12 closed rules, and where the form above shows each
RuleIn this form
requiredWorkspace handle, Owner email, Seats and both trial dates (they start filled in)
minLengthWorkspace handle (3), each label (2)
maxLengthWorkspace handle (20), each label (12)
minSeats (5), Trial starts (2026-01-01)
maxSeats (100)
stepSeats (in fives)
formatWorkspace handle (slug), Owner email, Accent color (hex-color), Billing ZIP code
minItemsTopics (1)
maxItemsTopics (3), Labels (4), Logo (1)
optionA value outside the choices: refused when a draft or a host restores it (try the tampered draft below)
fileTypeLogo (PNG or SVG)
fileSizeLogo (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

Role
Loading…

Steps

  1. About you runs
  2. Your team runs
  3. Security review shows when people > 50 skipped
  4. Confirm runs
Conditional fields, live
FieldConditionsShownRequiredEditable
Your role
show: role == "other"
require: role == "other"
nonono
Sign-on domain
require: sso
enable: sso
nonono

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.

How did the launch go?
Loading…
Loading…

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.

  1. 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
WhereKeysEffect
Any fieldTab, Shift+TabNext or previous control, in document order
Text fieldEnterNext step, or submit on the last one
Radio groupArrow keysMove and select a choice
SelectSpace, Enter, arrows, type to findOpen, move, pick
SliderArrows, Page Up, Page Down, Home, EndChange the value
TagsEnter; Backspace in the empty inputAdd the typed tag; move to the tags already added
Date pickerUp and Down in a segment; the calendar buttonChange day, month or year; open the calendar dialog
Error summaryEnter on a linkFocus 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