Core Input turns authored controls into response forms. A response belongs to one Block; its saved draft and submitted response remain separate.
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.
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.
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.
| Component | Required attributes | Optional attributes |
|---|---|---|
x-input-text | name, label | placeholder, required, max-length, max-words |
x-input-textarea | name, label | placeholder, required, max-length, max-words |
x-input-radio | name, value, label | required |
x-input-checkbox | name, value, label | required, min, max, exclusive |
x-input-submit | label | update-label, blocks |
x-input-value | name | — |
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.
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.
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.
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.
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.
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.
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.
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.
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.