# access-input

<!-- vai-hero:start -->
<p align="center">
  <picture>
    <source media="(prefers-reduced-motion: reduce)" srcset="docs/assets/hero-poster.png">
    <img src="docs/assets/hero-loop.webp" width="800" alt="A six-tile grid driven by gaze. A 600 ms dwell ring fills to select Read, then More, then Yes; a brief glance at Help ends before the dwell completes and cancels. An event log records focus, select, and cancel as they happen.">
  </picture>
</p>
<p align="center"><sub>A 4.8-second loop. It plays once and rests, and shows a still frame if you prefer reduced motion. <a href="https://le-vai.github.io/LE-VAI/loops/#access-input">Watch it on repeat</a>.</sub></p>
<!-- vai-hero:end -->

**The input-abstraction layer for assistive access.** Switch, gaze, EMG, head-pointer, keyboard — and one day EEG — all reduce to the same three events. This package is the seam between whatever signal a person can produce and whatever interface they need to drive.

Zero dependencies. MIT. Runs in the browser, testable in Node.

**[Dwell & scan →](https://le-vai.github.io/access-input/demo/)** · **[Read-along integration →](https://le-vai.github.io/access-input/demo/read-along.html)** · **[Consent-gated full stack →](https://le-vai.github.io/access-input/demo/stack.html)** · **[Biosignal pipeline →](https://le-vai.github.io/access-input/demo/analog.html)**

*(The second demo is the whole point of this package: a person using a switch, gaze tracker, or EMG channel drives a real [`<read-along>`](https://github.com/LE-VAI/read-along) element. Rest on a word and the reading starts there. Neither library knows the other's internals — they compose through the published APIs.)*

https://raw.githubusercontent.com/LE-VAI/access-input/main/docs/assets/demo-readalong.mp4

*(33s — the amber dwell fill is the access layer; the blue karaoke highlight is the reading layer. Both running at once.)*

https://raw.githubusercontent.com/LE-VAI/access-input/main/docs/assets/demo.mp4

*(53s — dwell activation on a reading surface, then single-switch scanning and keyboard access on the same interface. [Download the MP4](docs/assets/demo.mp4) if it doesn't play inline.)*

```bash
npm install access-input
```

```js
import { DwellEngine, SignalBridge, SwitchSource } from 'access-input';

const dwell = new DwellEngine({ dwellMs: 600 });
const source = new SwitchSource({ keys: [' '], autoScan: true });
new SignalBridge({ source, dwell, mode: 'direct', onActivate: (id) => choose(id) });
```

## Why this exists

Every access method that replaces a mouse click reduces to three events:

| Event | Meaning | Produced by |
|---|---|---|
| **FOCUS** | "the pointer is on target X" | gaze position, head-pointer, mouse, a scan highlight |
| **SELECT** | "the user chose X" | a switch press, a sip-puff, a dwell completing, a blink |
| **CANCEL** | "the user backed out" | an escape gesture, an undo |

An app built against those three events works for **every** access method without knowing which one is in use. That is the whole idea.

It matters because the assistive-input ecosystem is a graveyard of single-purpose apps: a scanning keyboard that only accepts switches, a gaze app that only accepts one tracker. Each re-implements the same plumbing, and none can accept a new input without a rewrite. This layer is the missing seam.

## The dwell problem, and what this does about it

For someone using a switch, a gaze tracker, or an EMG channel, "click" does not exist. The universal substitute is **dwell**: rest on a target and it activates. Almost every implementation ships a *fixed* duration, and a fixed duration is always wrong for someone — too long and every selection costs seconds of held effort until fatigue wins; too short and tremor or gaze jitter fires activations the user did not intend, which is worse, because it destroys trust in the interface.

`DwellEngine` starts from a calibrated value and adapts from two honest behavioural signals that **the host reports**, because only the host knows what "undo" means in its own UI:

- **Abandoned attempts** — the user began dwelling and left before completion. Repeated abandonment means the duration is too long.
- **Undone activations** — the host reports the user immediately reversed an activation. That means it was too short.

The corrections are deliberately asymmetric: lengthening is applied harder (a wrong activation is more damaging than a slow one), and shortening needs more evidence (abandonment can just mean the user changed their mind). The engine never infers intent from raw signal noise — it counts outcomes the host labels.

**Adaptation reads a moving window, not a session total.** Ratios come from the last 20 labelled outcomes, so the burst needed to trigger a correction is bounded and does not depend on how long the session has already run. An earlier version divided by cumulative activations, which made the engine adapt eagerly in the first minute and then progressively stop — someone whose tremor developed twenty minutes in got the least help. The engine also remembers which way it last moved: reversing a correction takes a *smaller* step than the move it reverses, so the undo/abandon pair converges instead of see-sawing.

Three details that make it usable rather than merely correct:

- **Grace window.** A brief slip off-target (gaze jitter, a tremor, one dropped EMG frame) does not restart the dwell — progress resumes. Restarting on every slip makes an interface punishing.
- **Sweep rejection.** A signal merely passing across a target is normal for gaze and is not counted as a failed attempt, so it cannot skew the adaptation.
- **Clock-gap guard.** If the host stops ticking (a hidden tab, machine sleep, a stalled device stream), the gap is NOT counted as dwell progress — the attempt is abandoned. Without this, resting on a word, switching tabs for ten seconds, and returning fires an activation the user never made.

## Remembering a calibration (opt-in)

An adapted duration is the engine's best estimate for one person on one device. Dropping it on every reload means they have to earn it again through a string of undos. Pass a storage key and it survives:

```js
const dwell = new DwellEngine({ dwellMs: 600, persist: 'my-app:dwell' });

// Later, from a settings screen:
dwell.resetCalibration();   // forget it, and go back to 600ms
```

- **Off unless you pass a key.** Without `persist`, storage is never read or written. The engine doesn't even look it up.
- **On the device only**, in `localStorage`. For tests or non-browser hosts, pass `storage` (anything with `getItem`/`setItem`/`removeItem`), or `null` for none. Nothing is transmitted.
- **Only the duration is stored**: `{ v: 1, dwellMs }`. The adaptive window, the session counts, and which targets fired describe one sitting, not the person.
- **Never trusted.** On load the record is validated (schema version, a finite positive number) and clamped to the engine's `minDwellMs`/`maxDwellMs`. A corrupt or tampered value can't produce a 20ms dwell, which would be a stream of activations the user never made. Anything invalid is ignored.
- **Storage can fail and the session still works.** Blocked site data, a sandboxed iframe, a full quota: every access is guarded, so the duration just isn't remembered.
- **Saved when the duration changes**: when adaptation moves it, or `setDwell()` records a user's choice. A bare `dwell.dwellMs = x` is not saved. Loading never writes.

If your UI lets someone choose a duration outside the default adaptive bounds (300–1500ms), pass `minDwellMs`/`maxDwellMs` that cover your control's range. Otherwise a restored choice is clamped back into the defaults. `resetCalibration()` also restores those bounds after `setDwell()` re-centred them, and it leaves repeat gating alone, as `setDwell()` does.

## Making content addressable

An input layer is only useful if there is a target to land on. Screen readers have the accessibility tree; a dwell engine has nothing unless the words are individually addressable. `tagWords` turns a block of prose into addressable words **without changing how it looks or reads**:

```js
import { tagWords } from 'access-input/words.js';

tagWords(document.getElementById('article'));  // every word becomes a dwell target
```

It wraps each word in an inline `<span>` carrying `data-dwell-target`, and never alters, collapses, or re-orders the text. That property is load-bearing: a component that tokenizes the same content (`<read-along>` does) computes its offsets from `textContent`, so if wrapping changed a single character every highlight would land on the wrong word. The wrapping is purely additive — same characters, same order, more nodes.

`splitWords(text)` gives you the word records with source offsets if you want to build targets yourself, and `untagWords(el)` restores the original DOM.

## Sources

| Source | Capabilities | Notes |
|---|---|---|
| `PointerSource` | continuous | Mouse/touch. The access method everyone already has, so it is also the fallback. |
| `KeyboardSource` | direct | Arrows/Tab to move, Enter to select. |
| `SwitchSource` | direct | One binary switch on any key, optional auto-scan, linear or row-column scanning. |
| `ExternalSource` | continuous + direct | **The escape hatch.** Any device that can reach the page drives the host by calling `focus()`, `select()`, `cancel()`. |

Switch scanning follows the AAC platform consensus: the scan **pauses after a selection** (so the next press is deliberate), presses are debounced against bounce and accidental double-press, the first item gets an orientation delay, and `scanPattern: 'row-column'` is available for grids — grouping targets into rows by *layout*, with no markup required to declare them.

`SignalBridge` wires a source to a `DwellEngine` and your handler. The one rule that matters: a **continuous** source dwells (position → dwell → activate); a **direct** source does not (its select already happened). Dwelling on a switch press would be nonsense. A host that knows its actual device can override with `mode: 'dwell' | 'direct'`, because the host knows the hardware and the class only knows the category.

## Speak on focus

Someone who can't see the highlight well needs to hear what has focus before choosing it: a person with low vision, a gaze user whose tracker hides the cursor, a switch user scanning a grid. `DwellEngine` reports it through `onFocus(id, label)`. It does not speak. This package has zero dependencies, and the host already owns a voice, so route the label to Web Speech, a neural TTS, or recorded prompts:

```js
const dwell = new DwellEngine({
  dwellMs: 600,
  onFocus: (id, label) => {
    speechSynthesis.cancel();                                  // replace, don't queue
    speechSynthesis.speak(new SpeechSynthesisUtterance(label));
  },
  onBlur: () => speechSynthesis.cancel(),                     // focus left: stop talking
});
new SignalBridge({ source, dwell, onActivate: (id) => choose(id) });
```

When it fires is the whole design. It follows the same gates that protect activation:

- **Dwell: when lock-on completes**, not when the signal arrives. A glance that never locks on says nothing. Speaking every word a gaze sweep crosses is exactly the noise lock-on exists to remove, and a voice is far more intrusive than a ring that doesn't paint.
- **Once per acquisition.** A grace-window slip that comes back doesn't re-announce. Neither does the activation, holding a spent target, or a repeat target re-firing. Only leaving ends the focus.
- **Scanning: on every highlight step.** `SignalBridge` routes a direct source's focus (a switch scan, arrow keys, an external device naming a target) into `dwell.focus()`, which announces it at once. A scan step is deliberate, so there's no glance to filter. This is auditory scanning, the standard AAC feature.
- **Consent-gated.** Behind a consent gate nothing is announced while the grant is withheld, including a withdrawal that lands mid-lock-on.

**Cancelling and replacing speech.** Every `onFocus` is ended by exactly one `onBlur` with the same `seq`, and it always arrives before the next `onFocus`. So speak on `onFocus`, cancel on `onBlur`, and speech never describes a target the user has left. `onBlur`'s `reason` says why: `'left'`, `'replaced'`, `'paused'`, `'clock-gap'`, or the reason given to `cancel()`. For an asynchronous voice, compare the `seq`:

```js
let live = 0;
const dwell = new DwellEngine({
  onFocus: async (id, label, { seq }) => {
    live = seq;
    const audio = await synthesize(label);   // a neural voice takes a moment
    if (seq === live) audio.play();          // focus moved on meanwhile? drop it
  },
  onBlur: (id, { seq }) => { if (seq === live) live = 0; },
});
```

**The label** comes from `labelOf(id)` if you pass one. Otherwise it's the accessible name of the `[data-dwell-target]` element (`aria-label`, then the `aria-labelledby` text, then its own text), or `String(id)` if there's no element. It's never empty. `targetLabel(id, root)` and `accessibleName(el)` are exported for building your own resolver. Pass a `root` when a page has more than one tagged surface, since `tagWords` numbers each one from `w0`; the read-along adapter scopes to its own element automatically.

`SignalBridge`'s own `onFocus(id)` is a different signal: the raw position on every change, glances included, for painting a highlight. Use the engine's `onFocus` for speech.

## Why `ExternalSource` is the whole BCI story

The thesis behind this package is that **the BCI software layer is accessibility software**, and that the useful thing to build is the timing/sync/input substrate rather than electrodes. `ExternalSource` is that claim made concrete: an EEG pipeline that can decide "focus" and "select" plugs in here **unchanged**, with no EEG-specific code in this package at all. The same is true of a BLE switch, a serial sip-puff sensor, or an eye-gaze bridge.

That design is not an accident of laziness — it is what the 2026 landscape forces:

- **BrainFlow has no browser binding.** Its JS package is Node-only FFI (`koffi`) and was ~9 months behind core as of Sep 2026. It is the best *native* EEG library and a dead end in a browser.
- **Web Bluetooth is permanently Chromium-only** (Firefox WONTFIX, Safari no-plan). Web Serial is better — Chrome/Edge/Opera and Firefox 151+ (May 2026) — but still not Safari.
- **The input-side ecosystem is mostly abandoned.** WebGazer.js ended official maintenance Feb 2026 with no successor found; the small JS switch-scan engines last saw commits in 2016–17. The live web options (Asterics AAC, Cboard) are AGPL/GPL, which cannot be embedded in a permissive substrate.

So the durable contribution is the layer that outlives any particular device.

## read-along adapter

`ReadAlongInputHost` drives a [`<read-along>`](https://github.com/LE-VAI/read-along) element with any source. It tags each word as a dwell target and routes activations to word-level seek, so "rest on a word" reads from there — the gesture a pointer user gets from clicking, expressed in whatever signal the person actually has.

```js
import { ReadAlongInputHost } from 'access-input/read-along.js';
import { SwitchSource } from 'access-input/sources.js';

const host = new ReadAlongInputHost(document.querySelector('read-along'), {
  source: new SwitchSource({ keys: [' '], autoScan: true }),
});
await host.start();
```

`onFocus`, `onBlur`, `labelOf`, `persist` and `storage` pass straight through to the adapter's engine. The default label is the focused word, looked up inside that element only, so a switch user scanning the text hears each word before choosing where reading starts.

## Demo

```bash
python -m http.server 8795
# open http://127.0.0.1:8795/demo/
```

The demo switches live between pointer-dwell, single-switch auto-scan, and keyboard, over both a reading surface and a plain four-cell grid — to show the input layer does not care what the content is. Watch the amber ring fill as you rest on a word: that fill is the dwell, and the word activates when it completes.

**Speak on focus** is off until you turn it on. It reads each acquired target aloud through read-along's Web Speech engine: after lock-on in pointer mode, on every highlight step in scan mode. **Remember dwell** keeps the calibrated duration on this device, and **Reset calibration** forgets it.

## TypeScript

Ships hand-written types — no build step, no generated `dist/`. A custom element's public surface is a deliberate API, and types generated from source freeze implementation details (private fields, internal helper shapes) into a published contract that then cannot change without a breaking version.

```ts
import { DwellEngine, type CalibrationResult } from 'access-input';

const dwell = new DwellEngine({ dwellMs: 600, leaveToRearm: true });
dwell.setRepeatTargets({ 'volume-up': 400 });   // per-target rate is typed

const r: CalibrationResult = detector.calibrate(baseline, sigma, peak);
if (!r.ok) show(r.detail);   // `reason`/`detail` exist only on the failure branch
```

Verified by consuming them in a strict-mode project: correct usage compiles, and deliberate misuse (wrong option type, typo'd option name, missing argument, unknown property, malformed capability object) is caught.

## The full stack, running

[demo/stack.html](demo/stack.html) wires all three libraries together with nothing but their published CDNs — no build step, no shared internals:

```
neural-consent   decides whether the signal may be read
      ↓
access-input     turns the signal into a selection (dwell / scan / keys / external device)
      ↓
read-along       reads from the chosen word, and reports the position back
```

The gate is enforced by the input layer, so it bites in the right place: while consent is withheld the surface is visibly inert and dwelling on a word does nothing; grant it and the same gesture reads from that word; withdraw it mid-dwell and the activation in flight is cancelled.

Verified in a real browser: blocked (`0` activations), granted (`1` activation, the reader seeked to the chosen token), and mid-dwell withdrawal (`0` activations where `1` was expected).

## Analog biosignal input (EMG, sip-and-puff)

An analog sensor produces a *continuous* signal, not presses. `AnalogSwitchSource` derives them — rectify, envelope, adaptive threshold, activation state machine — and then behaves exactly like `SwitchSource`, so `SignalBridge` and everything downstream need no changes.

```js
import { AnalogSwitchSource, GamepadTransport } from 'access-input/analog-source.js';

const source = new AnalogSwitchSource({
  transport: new GamepadTransport({ mode: 'analog', axisIndex: 0 }),
});
await source.start();

// Calibrate, then adopt — or refuse a signal that cannot support thresholds.
const rest = await capture(300);
const effort = [await captureBurst(), await captureBurst(), await captureBurst()];
const result = source.calibrate(rest, effort);
if (!result.ok) showMessage(result.detail);   // "signal-too-weak" explains why
```

### The transport order is research-driven, and not what you would guess

**Gamepad first.** Origin Instruments' Breeze sip-and-puff switch has a documented "Joystick Plus" mode that publishes **raw analog pressure as a standard HID joystick axis** — sip negative, puff positive, ±4 kPa at the extremes. So the browser reads real sip-and-puff pressure from a shipping commercial device with no driver, no protocol and no chooser UI. The Xbox Adaptive Controller and Hori Flex arrive through the same path.

**Keyboard is already covered.** Most commercial USB switch interfaces (AbleNet Hitch 2, Blue2 FT, Origin Swifty in keyboard mode) present as HID keyboards emitting Enter or Space — so `KeyboardSource` handles them with no new code. Documented rather than rebuilt.

**Web Serial is the maker path.** No assistive switch vendor publishes a serial protocol (that set is empty), and no consumer EMG device in 2026 is browser-reachable with documented protocol. So `SerialTransport` defines a minimal one instead of pretending to adopt one: newline-delimited JSON, `{"t":ms,"v":value}`, with an optional `{"dev":…,"fs":hz}` hello.

### What the detector refuses to do

**It never lowers thresholds into the noise floor.** A user whose strongest effort cannot clear the noise floor is told so, with a reason — because the alternative produces activations they did not make, which the 2025 EMG-switch usability trial found to be the dominant complaint. The failure path is a first-class result, not an error to work around.

### Evidenced constants vs judgement calls

The code distinguishes them, and so does this README:

| Constant | Value | Basis |
|---|---|---|
| Onset criterion | amplitude above baseline | Collins 2020 (n=60) — an **electrophysiology** reference, cited only for the 2σ onset method it used, not for anything about assistive technology |
| Baseline multiplier | 3σ | Collins used 2σ; raised deliberately, because a false activation costs more than a miss here |
| Envelope cutoff | 5 Hz | conventional linear-envelope band is 5–10 Hz |
| Raw EMG band-pass | 20–450 Hz | SENIAM; Noraxon puts the high cut at 400–500 Hz |
| Spike conditioning | TKEO optional | Solnik 2010: onset error 13 ms vs 98 ms |
| Adaptive threshold | dual-threshold with slow creep | OpenBCI's model; the implementable state of the art |
| Per-trial false-positive measurement | accuracy / precision / recall / FPR = FP/(FP+TN) | SITbench 1.0 (Esiyok & Albayrak 2019) — note the correction notice, PMC6900938 |
| **Min activation 150 ms** | **judgement** | No AT-specific published value exists. The 50 ms figure that looks like a candidate is a clinical *burst-duration* floor, and healthy controls routinely breach it — so 150 ms is deliberately well above it. |
| **Release 100 ms, refractory 300 ms** | **judgement** | Same; expose and tune per user. |

All of them live in an exported `ANALOG_DEFAULTS` object so a clinician can tune without forking.

**A naming trap worth recording**, because it will bite anyone citing this field: *SITbench* is the benchmark that defines the accuracy/precision/recall/FPR metrics. *SAM* is the **Switch Access Measure** (Nguyen et al. 2023) — a 16-item video-rated functional assessment for children, unrelated to those metrics. They are not two names for one thing. The nearest thing to a published *error-rate standard* is Koester's scanning rule of thumb (revise when scan errors exceed 25% of correct selections) — a ratio, not a rate over time.

### What this is not

Not a medical device. Not for diagnosis, therapy, or any application where a missed or spurious activation could cause harm. It reads **muscle electrical activity or air pressure** — an indirect, noisy proxy for intent — and it is not a brain interface, so do not call it one. Both false activations and missed activations will occur; that is the nature of the signal, not a defect to be hidden. A switch assessment led by an occupational therapist or AAC clinician is the correct process, and this library is not a substitute for it.

**And no published number can tell you how often it will misfire for a given person.** There is no standardised per-hour false-activation benchmark for switch access anywhere in the literature — not for single switches, sip-and-puff, EMG, head pointers, or eye gaze — and no cross-method comparable rate. Individual studies report per-trial error rates and throughput; nobody has published a reusable per-hour figure. That is why the defaults above are labelled engineering values rather than clinical ones: the honest position is that the number does not exist yet.

So `docs/MEASUREMENT-PROTOCOL.md` describes how to *produce* it for one person, on one device, on one day — a protocol you or a clinician can run, with the metrics and the reporting format specified, and no recruitment required to start.

## Consent gating (optional)

Reading a signal IS the processing act, so that is where consent has to bite. `SignalBridge` accepts an optional gate — duck-typed, because this package has zero dependencies, so it is an interface rather than an import:

```js
import { ConsentManager, PURPOSES } from 'neural-consent';

const consent = new ConsentManager({ storage: localStorage });
new SignalBridge({
  source, dwell,
  consent,                                   // anything with isGranted(id)
  consentPurpose: PURPOSES.ACQUIRE_SIGNAL.id,
  onActivate: (id) => choose(id),
});
```

Four properties make this a gate rather than a warning:

- **Per-event, not per-session.** Consent can be withdrawn while the tool runs, so the check runs on every focus, select, and activation. A withdrawal cancels any dwell already in progress.
- **Fails closed.** A gate that throws, or that lacks `isGranted`, means *no consent*. A broken gate must never be a permissive one.
- **Cancel is never gated.** A user backing out must always work — including when consent itself is what they are backing out of.
- **The clock must be shared.** If a source reports its own timestamps, inject the same clock the heartbeats use, or the engine's stall guard will read the mismatch as a gap. `rebaseline(t)` exists for switching clocks mid-session.

## The read-along integration

The adapter drives a real `<read-along>` element with any source — the composition the whole package exists to make possible:

```js
import { ReadAlongInputHost } from 'access-input/read-along.js';
import { SwitchSource, tagWords } from 'access-input';

// Tag the words first...
tagWords(document.querySelector('read-along'));
// ...then let read-along rebuild its highlight ranges against the new nodes.
// (It caches ranges from the text nodes present at prepare time; wrapping
// replaces those nodes, so a re-prepare keeps the karaoke highlight aligned.)
el._prepared = false;
el._prepare();

const host = new ReadAlongInputHost(el, {
  source: new SwitchSource({ keys: [' '], autoScan: true }),
});
await host.start();
```

An activation becomes a word-level seek; read-along's `activeToken` reports the reading position back, so a gaze or EEG layer can use it as feedback. Two independently published libraries, no shared internals.

## Tests

```bash
npm test
```

194 tests, zero dependencies, `node:test`. The dwell engine takes an **injected clock** everywhere, so every timing assertion is about logic rather than wall-clock behaviour. Persistence tests inject their own storage, so the suite never touches a real one.

## API

```js
new DwellEngine({
  dwellMs,          // 600  — evidence-based default (Burnham 2025 meta-analysis)
  lockOnMs,         // 150  — entry gate; a glance shorter than this never dwells
  lockoutMs,        // 200  — min gap between two fires on the same target
  repeatIntervalMs, // 1000 — auto-repeat period for `repeat` targets
  minDwellMs, maxDwellMs,   // adaptive bounds (300 / 1500)
  graceMs,          // 140  — slip forgiveness
  adaptive,         // true
  leaveToRearm,     // true — a fired target must be LEFT before it can fire again
  onProgress, onActivate, onCancel, onAdapt, onPhase,
  onFocus,          // (id, label, { tMs, seq, via }) — a target was ACQUIRED; speak here
  onBlur,           // (id, { tMs, seq, reason }) — that focus ended; cancel speech here
  labelOf,          // (id) => label — default: the target's accessible name, else String(id)
  persist,          // storage key — opt-in calibration persistence (off without it)
  storage,          // localStorage by default; inject for tests, null for none
})
  .enter(targetId, tMs)   // signal arrived (or returned) on a target
  .hold(tMs)              // heartbeat while on target
  .leave(tMs)             // signal left; grace window begins
  .tick(tMs)              // host heartbeat to expire the grace window
  .cancel(reason)         // explicit cancel
  .focus(targetId, tMs)   // a direct source moved focus (scan step) — announced at once
  .setDwell(ms)           // authoritative user choice (WCAG 2.2.1)
  .resetCalibration()     // clear the stored calibration; back to the configured duration
  .pause() / .resume()    // global kill switch (all platforms ship one)
  .setRepeatTargets(ids)  // opt targets into timed auto-repeat (additive)
  .clearRepeatTargets(ids)      // remove specific registrations
  .repeatIntervalFor(id)        // the interval a target will use
  // Per-target rates, because controls differ:
  //   setRepeatTargets({ 'volume-up': 400 })  // 400ms, others keep the default
  // Registration is ADDITIVE, so targets can be added as a UI builds.
  .reportUndo()           // host: the user undid the last activation
  .isSpent(id)            // has this target fired and not yet been re-armed?
  .phase                  // 'idle' | 'lockon' | 'dwell' | 'spent'
  .focused                // the target last announced via onFocus, or null
  .stats                  // { dwellMs, lockOnMs, activations, undos, abandons,
                          //   totalActivations, totalUndos, totalAbandons,
                          //   adaptations, lastDirection, windowSize, spent }
```

**Use `setDwell()` for user-facing controls, not a bare assignment.** An explicit choice re-centres the adaptive bounds around the chosen value (half to double) and clears the adaptation window, so the user's number is treated as a decision rather than a starting guess. WCAG 2.2.1 requires a timing value be adjustable over at least ten times the default — and that the adjustment actually hold.

What `setDwell()` deliberately does **not** touch is repeat gating: `_spent` and the lockout history survive a settings change. An earlier version called `reset()` here, so a user who opened the settings panel mid-dwell and nudged the slider re-armed a target that had already fired while the signal never left it — and `isSpent()` then disagreed with `phase` about the same fact, which is the kind of contradiction a host cannot defend against.

`stats` reports the windowed counts the engine actually acts on, with `total*` for the session. Reporting only totals would describe a different quantity than the one driving behaviour.

Time is always supplied by the caller (`performance.now()` in a browser, an injected clock in tests) — the engine never reads a clock itself, so its behaviour is fully deterministic.

### words

```js
splitWords(text)        // [{ text, start, end, index }]
tagWords(el, opts)      // wrap words as dwell targets; returns the word list
untagWords(el)          // restore the original DOM
accessibleName(el)      // aria-label → aria-labelledby text → own text ('' if none)
targetLabel(id, root)   // accessibleName of the target with that id, else String(id)
```

## License

MIT.
