Input forms

Core Input turns authored controls into response forms. A response belongs to one Block; its saved draft and submitted response remain separate.

Before saving personal work

Saving a response, progress, or an attachment requires an admitted Actor and a writable Home. Use the local account setup for development, then configure credentials and persistent writable storage for shared hosting. A browser publication provides a local Actor and writable Home on one device without sign-in; its saved files do not synchronize to a server. An ordinary static export does not provide a writable Source host. Disposable drafts and device settings are separate from committed work.

The Home directory's canonical index Source owns its privacy claim with access: { type: home, owner: /canonical/actor.id }. The Actor Source itself does not carry a home field. State writers resolve the current claim through the installed access policy, so a missing or Home-free Actor cannot create personal State accidentally.

The example below uses Auth's local Ada setup and the initialized project from that walkthrough. Core Input is already installed in the standard Plugins.

Author a response

Create paths/reflection.page.md:

# A small improvement

---

<!-- id: reflection -->

## What would you change?

<x-input-textarea name="idea" label="Your idea" required />

<x-input-submit label="Save reflection" update-label="Update reflection" />

Saved idea: {{ $input.responses(source=this.source, response="reflection").values.idea ?? "No saved idea" }}

Run bun run dev, sign in as Ada, and open /reflection.page. Enter “Add a worked example” and choose Save reflection. The saved idea now shows that text. Change the editor to “Add a diagram” without saving: the saved idea remains “Add a worked example”. Reload; the submitted value remains saved, and draft recovery can restore the newer editor text when browser storage is available. Choose Update reflection to commit the second version, then reload to verify it.

A required empty field cannot submit. If saving is unavailable after filling it, check the session and writable Home before retrying. Draft recovery is best-effort; only a successful submission commits the response Source.

A single response can omit its Block id, in which case its response id is source. Multiple response Blocks must each author a stable id. Keep field names and Block ids stable so saved responses continue to identify their fields.

Choose controls

Input response Blocks wrap their content in a [data-pmx-input-form] element. Use a descendant selector such as [data-pmx-block] .pmx-heading when styling headings across ordinary and Input Blocks; the heading is not always a direct child of the Block.

A custom component with input: true edits its one named field. An editor that needs several fields declares their prop keys, for example input: ["name", "mark"]. Each prop must name an existing field in the same response Block. Neither form grants access to undeclared neighboring fields. See Custom Input clients for field handles and subscriptions.

ComponentRequired attributesOptional attributes
x-input-textname, labelplaceholder, required, max-length, max-words
x-input-textareaname, labelplaceholder, required, max-length, max-words
x-input-radioname, value, labelrequired
x-input-checkboxname, value, labelrequired, min, max, exclusive
x-input-submitlabelupdate-label, blocks
x-input-valuename—

Choices with the same name form a field. Use radio controls for one choice and checkboxes for multiple choices. Wrap related choices in a native fieldset with a legend:

<fieldset>
<legend>Where would you begin?</legend>
<x-input-radio name="approach" value="clarity" label="Make it clearer" required />
<x-input-radio name="approach" value="simplicity" label="Make it simpler" required />
</fieldset>

<x-input-submit label="Save choice" />

Use at most one local submit control in each response Block. required is a presence attribute. For checkbox groups, required means at least one selected value; it never makes every checkbox mandatory. min="2" requires at least two selections, max="3" permits at most three, and exclusive marks a choice that must be selected alone. Declare group constraints on one option or repeat identical values; conflicting maxima and a minimum greater than the maximum are errors. Browser saves and direct Action calls enforce the same rules; invalid selections remain editable drafts.

<x-input-checkbox name="topics" value="learning" label="Learning" required max="2" />
<x-input-checkbox name="topics" value="community" label="Community" />
<x-input-checkbox name="topics" value="none" label="None of these" exclusive />

Run bun run pmx lint to check the response model.

To save several Blocks together, place a submit control in its own Block:

<x-input-submit blocks="*" label="Submit survey" />

blocks="*" saves all participating input Blocks owned by this Source. Included Sources and inactive Blocks are excluded. Use blocks="pace topics reflection" to select specific Block ids. Without blocks, the control saves only its containing Block. Inputs do not need their own submit control when a grouped control saves them.

Grouped submission uses the browser runtime to collect responses and saves them in one transaction. A grouped button uses update-label after any selected response has been saved; author both labels when its domain wording should stay fixed. Routine save status stays in the button (Saving… / Saved); adjacent feedback is reserved for errors and recovery. Saved responses outside the selected set remain unchanged. Lint reports missing input targets, including a submit without blocks in an empty Block.

Text fields can set either or both limits:

<x-input-textarea name="reflection" label="Reflection" max-words="100" max-length="1200" />

Limits are nonnegative integers. max-length counts Unicode code points, including whitespace; max-words counts nonempty whitespace-separated tokens. A hyphenated word counts as one token. The browser and the server enforce the same limits, including direct Action submissions. Over-limit answers stay intact as drafts; validation identifies the field to correct. No counter is added automatically. Existing saved text is preserved if a limit becomes stricter, but the response needs correction before it can count as complete or be saved again.

Conditional questions

Block conditions can read Input values with $response-id.field. Checkbox arrays support contains and containsAny:

<!--
id: follow-up
when: { $experiences.answer: { containsAny: [learning, community] } }
-->

<x-input-textarea name="idea" label="What would you improve?" />

Editing a routing answer previews the dependent Blocks in the current Source through the admitted View. Included Sources keep their own response boundary; their edits do not replace the containing document. It does not save answers or complete responses. Unaffected controls retain focus and drafts; hiding a question preserves its answers for when its branch returns. Browser draft recovery also restores branching when storage is available. Preview requires the running application host; offline edits cannot refresh conditions. Guided navigation waits for the latest successful preview, including when another routing answer supersedes an in-flight request. A failure or timeout keeps the current question open; Next retries the same draft.

On save, the server evaluates participation using the submitted candidate answers over saved responses. Explicitly submitting an inactive response is an error. Grouped buttons collect participating response Blocks; hidden required fields do not block them. A section save validates its selected responses; final survey completeness and immutable reporting snapshots remain application policy.

Describe questions for reporting

Author reusable question and field labels in the response Block's metadata:

<!--
id: experiences
input-response:
  label: "Which activities have you tried?"
  labels: { answer: "Activities" }
-->

<fieldset>
<legend>{{ input-response.label }}</legend>
<x-input-checkbox name="answer" value="learning" label="Learning" />
<x-input-checkbox name="answer" value="community" label="Community life" />
</fieldset>

A Plugin reads the current View's admitted description:

const responses = await view.query("input.describe", {
  input: { source: "/survey.page" },
})

The result is an ordered array of { id, label?, fields } (InputDescription from @pathmx/core/plugins). Each field describes name, optional label, type, required, applicable min, max, maxLength and maxWords, and ordered options with { id, label, exclusive? }. Labels are plain text. Choice labels are decoded from authored control attributes; question labels come from metadata, never inferred from surrounding HTML. Unknown field names in labels are authoring errors. Validation messages use these field labels. For example, labels: { email: "Email address" } makes a missing required value report “Email address” rather than the storage key. Input retains the response and field location so guided forms and grouped saves can focus the failing editor.

Descriptions include all authored responses, even currently inactive branches, and contain no participant answers or compiler identities. An unreadable Source returns an empty array; reading its description grants no access to saved state. An instance can be addressed as /survey.page?iid=cohort-a. Survey sections, grid row grouping, and historical instrument versions remain consumer metadata.

Display and complete responses

Render a saved value elsewhere in the same Source:

Your idea: {{input.idea}}

Field names belong to their response: two response Blocks may both use answer without sharing saved values. <x-input-value> and {{ input.name }} inside a response read that response. Outside a response, a field name must identify only one response; use an explicit input.responses query when the name is ambiguous.

<x-input-value name="idea" /> provides the same projection. Typing updates the draft; submitting confirms a snapshot. Later draft edits do not silently replace that submitted snapshot.

For a custom saved-answer presentation, the typed input.responses query returns one submitted response through the current View:

{{ $input.responses(source=this.source, response="reflection").values.idea ?? "No saved idea" }}

Use the response Block's stable id, or source for a Source-level response. response may be omitted only when the Source has one response. The row contains values for that response's fields and submittedAt; drafts are excluded. No submission, an unreadable State, or a saved response that needs newly required fields produces no row. Malformed stored values and ambiguous State records produce an error. An explicit Input State binding uses the same reader. See View bindings for named aliases and rich fallbacks.

To make submission a derived Completion requirement, add this to the question Block's topmatter and keep its stable id:

id: reflection
input-response:
  completion: true

Compose input.completion with Completion's derived providers as shown in Personal state. Completion follows the submitted response, not merely the presence of draft text.

Storage and custom behavior

Input saves responses as Sources in the current Actor's Home. A browser publication provides a local Actor without sign-in and runs the same submission Actions offline. Responses can be exported as saved files; they are not automatically sent to a teacher or remote server. Draft recovery is disposable and separate from submitted responses. Plugins used in a browser publication must support that host's capabilities.

Each saved response owns its values and submission timestamp under its stable id:

type: state
plugin: input
for: /practice.page
responses:
  reflection:
    values:
      answer: Try a worked example.
    submittedAt: 2026-09-20T14:00:00.000Z
  revision:
    values:
      answer: Add a second example.
    submittedAt: 2026-09-20T14:05:00.000Z

Saving reflection replaces that response's saved values without changing revision. The for reference may include ?iid=... for a named Source instance. A stored response without a submission timestamp can supply editable values but is not submitted output.

A Source can explicitly bind Input to an existing State Source using input-response.state in Source frontmatter. The canonical State id must refer to matching Input state for that origin, and the current viewer still needs access. Ordinary personal responses need no explicit binding.

Extend a response editor

Start with Assessment's complete configured Input example for an authoring adapter and submission contribution. The browser methods below are the editor contract for such an adapter, not a standalone form setup.

For custom response components, bind from @pathmx/core/input/browser connects a component to the single compiled answer in its Block. Its render(value, { editable }) callback receives restored state; set(value) validates and updates the answer, and dispose() removes listeners. When the editor contains an incomplete or invalid draft, call setIssue("Explain what needs to change."). Submission stays unavailable even if the compiled answer still contains an earlier valid value. A successful set(value) clears that binding's issue; clearIssue() clears it explicitly. Dispose the binding when its component is removed. An authoring adapter supplies that answer's field model. Assessment composes such authoring with Input. Submission contributions add domain work to the normal validated Action transaction.

Read a response in the browser

Use readInputResponse from @pathmx/core/input/browser when a review or summary needs the current meaning of an Input response. Pass its mounted editor, form, or question Block; Input supplies the authored label and current draft status.

import { readInputResponse } from "@pathmx/core/input/browser"

const question = document.getElementById("reflection")!
const response = readInputResponse(question)
if (response && !response.answered) {
  console.log(response.label, response.required ? "Required" : "Optional")
}
if (response?.issue) {
  console.log(response.issue.field, response.issue.message)
}

The immutable snapshot contains label?, answered, required, and an optional issue: { field?: string, message: string }. Labels come from input-response.label; they are not inferred from HTML. answered means at least one field has a nonempty answer, not that every field is valid. required means a field requires an answer or a positive selection minimum. issue carries the current validation or editor issue. Native and custom editors use the same response state.

Read again when updating your summary; this function does not subscribe to changes, submit answers or expose answer values. Before Input mounts, after its response is removed, or for an unrelated element, it returns undefined. A region associated with multiple responses throws; read each question separately. A connected Block on another Player slide remains readable, while a removed conditional Block does not.

Submit while reading

Original Save controls and Player response actions submit through the same response owner. Repeated views of the same response in one application share the pending guard, so simultaneous activation starts one submission. Separately named responses keep their own drafts and status.

For a response with several text fields, input-response.label also names its single Player entry action. Entry focuses the first incomplete or invalid text editor, or the first editable field when all are valid. Without a response label, the action uses the target field's authored label. Filling a field updates the entry target while preserving the editors and draft; saving remains explicit.

Saving confirms the values at submission time. Edits made while saving remain an unsaved draft when that submission finishes. Player shows Saving… during the operation and briefly shows Saved after success. A response with no new valid changes does not offer another ordinary Save action.

If the save commits but the page cannot refresh, the response reports that it was saved and asks for a reload. An uncertain or interrupted submission also requires recovery before another attempt; its draft remains available.

Save responses together

Programmatic callers submit a map of response ids to field values. The same shape handles one response or several:

await view.actions.run("input.submit", {
  responses: {
    starting: { choice: "explore" },
    solo: { choice: "plan" },
  },
  submission: "attempt-1",
}, { target: "/approach.page" })

Each response keeps its own fields and saved state. All submitted responses are validated before any are saved; an invalid response rejects the entire operation. Responses omitted from the map retain their saved values. Empty maps and unknown response ids are rejected. Domain submission contributions receive the validated responses map and participate in the same transaction.

Native Input forms produce the same structured request for their response. Grouping several responses in one operation does not combine their evaluation or outputs.