Skip to content

Accessibility first

Include accessibility in the component’s behavior design.

Expose labels, status, and command availability through the source and derived states.

Render those values with native controls.

Test behavior headlessly, then verify accessibility in the browser.

An accessible component usually needs the same facts regardless of channel:

  • a stable label,
  • helper or error text,
  • clear command descriptions,
  • dynamic availability,
  • a status summary,
  • and sometimes focus or announcement intent.

Those facts belong in the runtime contract before they are styled in the DOM.

thermostat.ts
import { igniteCore } from "ignite-element/xstate";
import { assign, createActor, setup } from "xstate";
type ThermostatContext = {
target: number;
isSaving: boolean;
errorText: string | undefined;
};
type ThermostatEvent =
| { type: "SET_TARGET_DRAFT"; target: number }
| { type: "SAVE_TARGET"; target: number }
| { type: "SAVE_SUCCESS" }
| { type: "SAVE_FAILURE"; message: string };
const thermostatMachine = setup({
types: {
context: {} as ThermostatContext,
events: {} as ThermostatEvent,
},
}).createMachine({
context: {
target: 70,
isSaving: false,
errorText: undefined,
},
initial: "ready",
states: {
ready: {
on: {
SET_TARGET_DRAFT: {
actions: assign({
target: ({ event }) => event.target,
errorText: () => undefined,
}),
},
SAVE_TARGET: {
target: "saving",
actions: assign({
target: ({ event }) => event.target,
isSaving: () => true,
errorText: () => undefined,
}),
},
},
},
saving: {
on: {
SAVE_SUCCESS: {
target: "ready",
actions: assign({
isSaving: () => false,
errorText: () => undefined,
}),
},
SAVE_FAILURE: {
target: "ready",
actions: assign({
isSaving: () => false,
errorText: ({ event }) => event.message,
}),
},
},
},
},
});
export const source = createActor(thermostatMachine).start();
export const thermostat = igniteCore({
source,
states: (snapshot) => ({
target: snapshot.context.target,
targetLabel: "Target temperature",
targetHint: "Choose a value between 58 and 82 degrees.",
statusLabel: snapshot.context.isSaving ? "Saving target" : "Ready",
errorText: snapshot.context.errorText,
canEditTarget: snapshot.can({
type: "SET_TARGET_DRAFT",
target: snapshot.context.target,
}),
canSave: snapshot.can({
type: "SAVE_TARGET",
target: snapshot.context.target,
}),
}),
commands: ({ source: actor }) => ({
setTargetDraft: (target: number) =>
actor.send({ type: "SET_TARGET_DRAFT", target }),
saveTarget: (target: number) => actor.send({ type: "SAVE_TARGET", target }),
}),
});

Tool consumers preserve their explicit constraints separately from ordinary commands:

thermostat-tools.ts
export const thermostatToolSchema = {
commands: {
setTargetDraft: {
description: "Synchronize the actor-owned target draft.",
input: { type: "number", minimum: 58, maximum: 82 },
gated: true,
},
saveTarget: {
description: "Save the target temperature.",
input: { type: "number", minimum: 58, maximum: 82 },
gated: true,
},
},
};

Pass this schema and a current availability predicate reading canEditTarget or canSave to igniteTools; unknown names are unavailable.

The core catalogue has null input schemas and is not a substitute for these application definitions.

The application persistence service handles the SAVE_TARGET request and sends SAVE_SUCCESS or SAVE_FAILURE back to the actor.

While the machine is in saving, neither draft edits nor another save transition are available, so canEditTarget and canSave disable their respective controls from actor facts.

The existing actor is shared between the rendered controls and explicit headless access.

At the end of its application-owned lifetime, call thermostat.dispose() in try and source.stop() in finally. Individual view unmounts do not stop this source.

That core supports three consumers:

  1. A browser UI can render labels, hints, and disabled states.
  2. A headless test can assert get('states'), including canSave, and the minimal get('schema') catalogue.
  3. An agent can inspect the same facts without scraping the DOM.

Once the behavior contract is stable, render it with native controls before reaching for ARIA.

Save this view beside thermostat.ts above.

thermostat-view.tsx
/** @jsxImportSource ignite-element/jsx */
import { thermostat } from './thermostat';
thermostat("thermostat-panel", (ctx) => (
<form aria-describedby="thermostat-hint thermostat-status">
<label for="thermostat-target">{ctx.targetLabel}</label>
<input
id="thermostat-target"
name="target"
type="range"
min={58}
max={82}
value={ctx.target}
onInput={(event: Event) => {
if (event.currentTarget instanceof HTMLInputElement) {
ctx.setTargetDraft(Number(event.currentTarget.value));
}
}}
disabled={!ctx.canEditTarget}
aria-invalid={ctx.errorText ? "true" : undefined}
aria-describedby={
ctx.errorText
? "thermostat-hint thermostat-error thermostat-status"
: "thermostat-hint thermostat-status"
}
/>
<p id="thermostat-hint">{ctx.targetHint}</p>
<p id="thermostat-status" aria-live="polite">
{ctx.statusLabel}
</p>
{ctx.errorText && (
<p id="thermostat-error" role="alert">
{ctx.errorText}
</p>
)}
<button
type="button"
disabled={!ctx.canSave}
onClick={(event: MouseEvent) => {
if (!(event.currentTarget instanceof HTMLButtonElement)) return;
const draft = event.currentTarget.form?.elements.namedItem("target");
if (draft instanceof HTMLInputElement) {
ctx.saveTarget(Number(draft.value));
}
}}
>
Save target
</button>
</form>
));

Rendered validation must cover SAVE_FAILURE while focus remains on the triggering control and verify that the dedicated assertive error announcement is exposed to assistive technology.

The range value is actor-owned rather than a one-time DOM default.

Active user edits call setTargetDraft, which sends SET_TARGET_DRAFT and re-renders the control from the resulting target.

External integrations use the same command path, so their updates converge through the same actor state:

External update (fragment)
// After importing thermostat from ./thermostat:
await thermostat.execute({ command: "setTargetDraft", input: 72 });

The save handler still reads the synchronized native control.

It does not own a second local draft that can drift from an external actor update.

The runtime contract already knew:

  • what the control is called,
  • when saving is allowed,
  • what status text should be announced,
  • and whether an error exists.

The DOM projection expresses those facts with native semantics instead of trying to reconstruct them later.

Ignite’s current runtime APIs are already enough to verify most of the contract before any DOM assertion:

thermostat-headless.ts
import { thermostat } from './thermostat';
import { thermostatToolSchema } from './thermostat-tools';
const schema = thermostat.get("schema");
const states = thermostat.get("states");
const canSave = states.canSave;
if (canSave) {
try {
const result = await thermostat.execute({
command: "saveTarget",
input: 70,
});
console.log(result.snapshot, result.events);
} catch (error) {
// Availability can change; source guards enforce behavior.
// Fulfillment alone does not confirm that persistence succeeded.
console.error("Saving the target was rejected or failed.", error);
}
}
console.log(thermostatToolSchema.commands.saveTarget.description);
console.log(schema.commands); // names and unknown input schemas, not descriptions
console.log(states.statusLabel);

This is the right place to verify:

  • separately supplied tool descriptions and input schemas,
  • dynamic availability,
  • projected status summaries,
  • and post-command events.

It is not the right place to claim that the browser computed the right accessible name or focus order.

Use validated semantic documents for shared non-DOM output

Section titled “Use validated semantic documents for shared non-DOM output”

When multiple channels need more than the derived states read model, Ignite’s accepted v3 direction is to use actor-owned validated semantic documents rather than a public projection registry.

That means:

  • the source writes durable ProjectionDocument state through commands,
  • the runtime validates safe semantic nodes,
  • command-backed actions resolve against existing commands,
  • DOM, text, terminal, and speech committers consume the same document.

The document remains data, not executable UI.

Ignite rejects raw JSX, JavaScript, imports, event handlers, DOM references, and arbitrary executable strings in this path.

Speech and announcements need stricter lifecycle rules than ordinary DOM or text updates:

  • DOM and text projections can be change-driven.
  • Speech should be request-driven.
  • Spoken text or structured speech first becomes durable actor state.
  • Each utterance has stable identity.
  • Acknowledged utterances must not replay when state is re-read or a session is rebound.

This keeps announcement behavior explicit and testable.

Voice and text share behavior, not browser machinery

Section titled “Voice and text share behavior, not browser machinery”

The voice and text workbench example demonstrates the split in a complete agent-authored interface:

  1. Typed input and a final browser speech transcript both become the same submitPrompt command with an explicit modality.
  2. The actor owns the conversation state and command availability.
  3. get('schema') and igniteTools expose only semantic artifact commands to the model turn.
  4. The actor validates the proposed semantic document before Ignite renders native JSX or commits terminal and speech output.
  5. Each speech request has stable identity and is acknowledged after the speech projection commits it.

Microphone capture remains a capability-gated browser adapter.

Permission denial, cancellation, unsupported recognition, and transcription errors are typed facts; they do not mutate the DOM or bypass the actor.

The headless suite can therefore prove that speech and text converge on the behavior contract, but it cannot claim that a particular browser exposed the right permission prompt, accessible name, or assistive-technology announcement.

The production parity harness was manually checked across all five states at 1920×1080, 1440×900, 1280×800, 768×900, and 390×844: 25 combinations in total.

Every combination had zero horizontal overflow, no browser warnings or errors, the expected state proof visible, and visible controls measuring at least 44px.

Actor, voice, and active-panel state matched each fixture; microphone denial preserved the typed draft.

These receipts prove responsive interaction geometry and runtime behavior.

Browser accessibility-tree and assistive-technology checks remain separate rendered-DOM evidence.

A mandatory top-level accessibility callback

Section titled “A mandatory top-level accessibility callback”

Rejected because it duplicates states, command metadata, and semantic document state with a second authoring surface.

Rejected because it bypasses the component contract and makes deterministic verification impossible.

Rejected because browser accessibility semantics, focus order, and assistive technology behavior must be verified in rendered DOM.

Use this split when you review an accessibility-first component.

  • get('schema') exposes command names with unknown input schemas; the explicit tool schema supplies descriptions and validation.
  • Derived availability matches the source’s current rules; it is a hint, not authorization.
  • get('states') exposes labels, status summaries, errors, and disabled reasons.
  • Validated semantic documents reject unsafe content and keep stable ids.
  • Command-backed actions resolve against existing commands and schema.
  • Speech requests dedupe and acknowledge correctly.
  • execute() produces the expected events and resulting snapshot.
  • accessible names and descriptions,
  • label and helper-text wiring,
  • keyboard flows and focus order,
  • live-region timing,
  • visual hiding and visibility behavior,
  • browser and assistive technology integration.