Build for agents
Ignite Element is behavior-first. The same igniteCore(...) registration that creates a custom element also exposes a headless runtime, so agents and tests can inspect the contract, execute commands, observe events, and verify projected view state before any DOM selector exists.
The practical model is:
- Define component behavior and boundaries.
- Test workflows through the headless runtime.
- Add the DOM layer as a projection over already-proven behavior.
Behavior-first components
Section titled “Behavior-first components”Start by describing the state source, projected view, commands, events, and effects. igniteCore(...) infers snapshot, prevSnapshot, emit, select, actor, and host from the source plus your events builder, so standard authoring needs no internal helper-type imports.
import { igniteCore } from 'ignite-element/xstate';
export const counter = igniteCore({ source: counterMachine,
view: ({ context, snapshot }) => ({ count: context.count, limit: context.limit, isLimited: snapshot.matches('limited'), }),
commands: ({ actor, command }) => ({ increment: () => actor.send({ type: 'INCREMENT' }),
setLimit: command( (limit: number) => actor.send({ type: 'SET_LIMIT', limit }), { description: 'Set maximum count', input: command.number({ minimum: 3, maximum: 12 }), }, ), }),
events: (event) => ({ 'limit-reached': event<{ count: number; limit: number }>(), }),
effects: ({ snapshot, prevSnapshot, emit }) => { if (!prevSnapshot.matches('limited') && snapshot.matches('limited')) { emit('limit-reached', { count: snapshot.context.count, limit: snapshot.context.limit, }); } },});That registration gives agents a contract to inspect and a command surface to drive — all without the DOM:
counter.getSchema(); // commands + metadata + events + state shapecounter.getView(); // current projected view
await counter.execute('setLimit', 6);
while (!counter.getView().isLimited) { await counter.execute('increment');}
expect(counter.getView()).toMatchObject({ count: 6, limit: 6, isLimited: true });See Headless runtime for every method (execute, getSnapshot, getView, getSchema, on, watchSnapshot, watchView, record) and its return shape.
Add the DOM layer
Section titled “Add the DOM layer”Once the behavior is stable, the custom element stays thin — it consumes projected view values and command functions. The default DOM layer uses the public ignite-element/jsx runtime and ordinary local style tags; no project config is required.
const panelStyles = ` section { display: grid; gap: 0.75rem; padding: 1rem; border: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-radius: 1rem; }`;
counter('counter-panel', ({ count, limit, isLimited, increment, setLimit }) => ( <section> <style>{panelStyles}</style> <strong> {count} / {limit} </strong>
<button type="button" onClick={() => increment()}> Increment </button>
<input type="range" min={3} max={12} value={limit} onInput={(event) => setLimit(Number(event.currentTarget.value))} />
{isLimited && <p>Limit reached</p>} </section>));This keeps DOM tests focused on rendering and accessibility, while behavior tests stay selector-free. To assert the rendered controls by role and accessible name against the same proven runtime, mount a test-only DOM bridge with igniteTest.accessibilityBridge(...) / igniteTest.expectControls(...) — see the Testing DSL.
Agent-readable contracts
Section titled “Agent-readable contracts”Command metadata is optional. Plain functions remain valid commands; command(fn, metadata) enriches the runtime schema when an agent or inspector needs more context.
commands: ({ actor, command }) => ({ reset: () => actor.send({ type: 'RESET' }),
renamePreset: command( (label: string) => actor.send({ type: 'RENAME_PRESET', label }), { description: 'Rename the saved preset label.', input: command.string({ minLength: 1, maxLength: 32 }), }, ),});Built-in helpers cover number, string, boolean, enum, object, and array while staying JSON-serializable, and getSchema() returns executable commands plus their metadata in one map. The full helper catalog and the serialized schema shape live in Command metadata.
Story recording
Section titled “Story recording”Stories make behavior workflows first-class: a recorder captures command, event, state, and projected-view changes as one trace, while DOM lifecycle evidence stays on a separate channel of the same story object. Serialize a trace for snapshot tests with igniteTest.serializeTrace(...) and assert ordered checkpoints with igniteTest.expectTrace(...).
const story = counter.record('reaches limit');
await story.execute('setLimit', 6);await story.until((view) => view.isLimited, async () => { await story.execute('increment');});
igniteTest.expectTrace(igniteTest.serializeTrace(story.trace()), [ { kind: 'command', command: 'setLimit', payload: 6 }, { kind: 'event', event: 'limit-reached', payload: { count: 6, limit: 6 } }, { kind: 'view', phase: 'after', view: { isLimited: true } },]);
story.stop();story.trace() holds only behavior evidence; story.lifecycle() holds DOM evidence (registration, connection, render, disconnect, cleanup) when a custom element or accessibility bridge participates. The exact recorder, trace, and serialization signatures are in the Testing DSL.
The important boundary: logic stays inspectable without the DOM, and the DOM remains a projection over the tested contract.