@generative-a11y/dom 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bhavesh Chowdhury
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # `@generative-a11y/dom`
2
+
3
+ DOM announcement delivery for `@generative-a11y/core`. The package mounts or
4
+ adopts live regions without changing the host application's visible interface.
5
+ It reports DOM delivery actions; it does not claim that assistive technology
6
+ produced speech.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pnpm add @generative-a11y/core @generative-a11y/dom
12
+ ```
13
+
14
+ ## Connect a runtime
15
+
16
+ `connectRuntimeToDOM(runtime, options?)` creates a `DOMAnnouncer`, subscribes it
17
+ to a `GenerativeA11yRuntime`, and returns a `DOMRuntimeBinding`. Disposing the
18
+ binding unsubscribes and disposes the announcer, but never disposes the borrowed
19
+ runtime.
20
+
21
+ ```ts
22
+ import { createGenerativeA11y } from "@generative-a11y/core";
23
+ import { connectRuntimeToDOM } from "@generative-a11y/dom";
24
+
25
+ const runtime = createGenerativeA11y({});
26
+ const binding = connectRuntimeToDOM(runtime);
27
+
28
+ // Later:
29
+ binding.dispose();
30
+ runtime.dispose();
31
+ ```
32
+
33
+ `DOMRuntimeBinding` exposes the connected `announcer` and an idempotent
34
+ `dispose()` method. Once disposed, the binding cannot mutate its regions even if
35
+ the runtime was already delivering an event. If subscription fails, any regions
36
+ created for the attempted binding are removed before the error is rethrown.
37
+
38
+ ## Create an announcer directly
39
+
40
+ `createDOMAnnouncer(options?)` returns a `DOMAnnouncer`. When a document is
41
+ available, it synchronously mounts one polite and one assertive region before
42
+ returning. Each announcer owns an isolated pair. Without an injected or global
43
+ document it remains inert, so the module and constructor are safe in server
44
+ environments.
45
+
46
+ ```ts
47
+ import { createDOMAnnouncer } from "@generative-a11y/dom";
48
+
49
+ const announcer = createDOMAnnouncer({ mode: "auto" });
50
+ const result = announcer.announce(intent);
51
+ announcer.dispose();
52
+ ```
53
+
54
+ `DOMAnnouncer` provides:
55
+
56
+ - `announce(intent)`, which returns a `DOMDeliveryResult`.
57
+ - `getRegions()`, which returns the `DOMLiveRegions` pair or `undefined` when
58
+ the DOM is unavailable.
59
+ - An idempotent `dispose()`. It removes regions created by the announcer and
60
+ leaves supplied regions mounted. Later announcements report `disposed`.
61
+
62
+ ## Options and modes
63
+
64
+ `DOMAnnouncerOptions` accepts:
65
+
66
+ - `document`: an injected `Document`. If omitted, creation uses a supplied
67
+ region's owner document and then the current global document, when available.
68
+ - `mode`: a `DOMAnnouncementMode` of `"auto"`, `"aria-notify"`, or
69
+ `"live-region"`. Both progressive-enhancement modes try a callable
70
+ `ariaNotify` on the selected region and fall back when it is absent.
71
+ `"live-region"` always uses text mutation.
72
+ - `regions`: a pre-mounted `DOMLiveRegions` pair with `polite` and `assertive`
73
+ elements. Supply dedicated, connected, empty elements in the same document;
74
+ neither element may contain the other, and an explicitly supplied `document`
75
+ must be their owner document. The driver normalizes direct hiding attributes
76
+ and inline `display`, `visibility`, and `content-visibility`, then applies the
77
+ required live-region attributes and visually hidden inline styles. The caller
78
+ must ensure ancestors and external CSS keep both regions in the accessibility
79
+ tree. Supplied elements remain owned by the caller.
80
+ - `onDiagnostic`: a callback invoked with the `DOMDeliveryResult` for each
81
+ attempted announcement. Callback errors are isolated from delivery.
82
+
83
+ If `ariaNotify` throws, that notifier is disabled for the announcer. The same
84
+ intent is delivered once through live-region mutation, and later intents remain
85
+ on that fallback path.
86
+
87
+ ## Delivery results
88
+
89
+ `DOMDeliveryResult` is serializable and contains:
90
+
91
+ - `status`: `"notified"`, `"mutated"`, `"unavailable"`, or `"disposed"`.
92
+ - `method`: `"aria-notify"`, `"live-region"`, or `"none"`.
93
+ - `channel`: the selected core announcement channel.
94
+ - `error`, when notifier invocation failed, with serializable `name` and
95
+ `message` strings.
96
+
97
+ `DOMLiveRegions` contains the stable outer `polite` and `assertive` elements.
98
+ Live-region delivery sets or clears `lang` from the intent locale and replaces
99
+ the region's text content on every delivery, including repeated identical text.
100
+ Announcement strings are inserted as literal text, never HTML.
101
+
102
+ ## Observe attention signals
103
+
104
+ `createAttentionStore(options?)` returns an `AttentionStore`, an external-store
105
+ compatible source of conservative browser signals. It observes raw visibility,
106
+ window focus, DOM focus area, and whether the currently registered newest
107
+ response intersects the viewport. These signals do not reveal a screen-reader
108
+ virtual cursor or user intent.
109
+
110
+ ```ts
111
+ import { createAttentionStore } from "@generative-a11y/dom";
112
+
113
+ const attention = createAttentionStore();
114
+ const unregisterComposer = attention.registerComposer(composerElement);
115
+ const unsubscribe = attention.subscribe(() => {
116
+ const snapshot = attention.getSnapshot();
117
+ console.log(snapshot.mode);
118
+ });
119
+
120
+ // Later:
121
+ unsubscribe();
122
+ unregisterComposer();
123
+ attention.dispose();
124
+ ```
125
+
126
+ `ExternalStore<T>` exposes `subscribe(listener)`, `getSnapshot()`, and
127
+ `getServerSnapshot()`. `AttentionStore` implements
128
+ `ExternalStore<AttentionSnapshot>` and adds:
129
+
130
+ - `registerComposer(element)` and `registerConversation(element)`. Any
131
+ registered element containing `document.activeElement` matches. Composer wins
132
+ when registered areas overlap. Repeated registrations of the same element are
133
+ reference-counted. Each returned unregister function is idempotent.
134
+ - `registerNewestResponse(element)`. Only one newest response is current; a
135
+ later registration replaces the prior target. Unregistering an older target
136
+ cannot remove its replacement. Each registration receives a fresh observer
137
+ epoch, including when the same element is registered again, so queued records
138
+ from an older registration are ignored. When one callback contains multiple
139
+ records for the current target, the final record is the current transition.
140
+ - `dispose()`, which is idempotent and removes listeners, registrations,
141
+ subscribers, and the intersection observer. Subscribing or registering after
142
+ disposal throws. Stores created without a document are permanently inert; all
143
+ their methods remain safe no-ops.
144
+
145
+ Subscribers present when a snapshot changes are invoked at most once for that
146
+ transition. Adding, removing, or re-subscribing listeners during notification
147
+ affects later transitions, not the stable listener snapshot already in flight.
148
+ Listener errors remain isolated.
149
+
150
+ `AttentionSnapshot` is frozen and cached by value, so `getSnapshot()` returns
151
+ the same reference until a raw or derived value changes. Its fields are:
152
+
153
+ - `visibility`: `"visible"`, `"hidden"`, or `"unknown"`.
154
+ - `windowFocus`: `"focused"`, `"blurred"`, or `"unknown"`.
155
+ - `focusArea`: `"composer"`, `"conversation"`, `"elsewhere"`, `"none"`, or
156
+ `"unknown"`.
157
+ - `newestResponse`: `"visible"`, `"outside"`, `"unobserved"`, or `"unknown"`.
158
+ - `mode`: `"background"` when hidden; `"away"` when visible and blurred;
159
+ `"reading-history"` when visible, focused, and the newest response is outside;
160
+ `"foreground"` when visible, focused, and the newest response is visible;
161
+ otherwise `"unknown"`.
162
+
163
+ Creation uses an injected document or the current browser document. Without
164
+ either, client and server snapshots are the same constant all-`"unknown"` value.
165
+ With a document but no registered newest response, `newestResponse` is
166
+ `"unobserved"`. Registering a newest response without IntersectionObserver
167
+ support produces `"unknown"`, never optimistic `"visible"`.
168
+
169
+ `AttentionStoreOptions` accepts an injected `document`, an optional
170
+ `createIntersectionObserver` (`AttentionIntersectionObserverFactory`), and an
171
+ optional `intersectionObserverInit`. The factory returns the minimal
172
+ `AttentionIntersectionObserver` interface (`observe`, `unobserve`, and
173
+ `disconnect`), allowing deterministic tests without browser globals. The store
174
+ does not use timers and never changes focus or scroll position. Observer
175
+ creation, observation, and cleanup failures are treated as unavailable
176
+ intersection evidence and deliberately suppressed; DOM listener and subscriber
177
+ cleanup still completes.
178
+
179
+ ## Use conservative focus helpers
180
+
181
+ Focus helpers are never invoked automatically. They are explicit host actions
182
+ for workflows, such as restoring focus after an application-owned interaction.
183
+ Ordinary streaming, status changes, and announcements do not move focus.
184
+
185
+ ```ts
186
+ import { captureFocus, restoreFocus } from "@generative-a11y/dom";
187
+
188
+ const capture = captureFocus();
189
+ // The host opens and later closes its interaction.
190
+ const result = restoreFocus(capture, { onlyIfFocusWithin: interactionElement });
191
+ ```
192
+
193
+ `captureFocus(document?)` returns a frozen `FocusCapture` containing the exact
194
+ originating `Document` and deepest active `Element` references. It follows
195
+ `activeElement` through nested open shadow roots and uses the current browser
196
+ document when none is injected. Missing documents, throwing `activeElement`
197
+ accessors, and focus on `body` or `documentElement` produce a capture with no
198
+ restorable target. Closed shadow roots deliberately expose only their host, so
199
+ their internal focused element is unavailable to these helpers.
200
+
201
+ `focusElement(target, options?)` requests focus through the target's public
202
+ `focus()` method. `FocusElementOptions.preventScroll` defaults to `true` and can
203
+ be set to `false`. Success is verified with `ownerDocument.activeElement`.
204
+
205
+ `restoreFocus(capture, options?)` checks that the captured target still belongs
206
+ to its originating document, then delegates to `focusElement`. A capture can be
207
+ used repeatedly. `RestoreFocusOptions` adds `onlyIfFocusWithin`: restoration
208
+ proceeds only while the current active element is that guard or its descendant.
209
+ If focus has moved elsewhere or is unavailable, restoration is skipped so a
210
+ later user focus move is preserved. Guard containment follows the composed tree
211
+ through assigned slots and across open shadow-root hosts.
212
+
213
+ Both operations return a finite `FocusResult`: either
214
+ `{ status: "focused", target }` or `{ status: "skipped", reason, target }`.
215
+ `FocusSkippedReason` is one of `"unavailable"`, `"cross-document"`,
216
+ `"disconnected"`, `"disabled"`, `"hidden"`, `"aria-hidden"`, `"inert"`,
217
+ `"missing-focus"`, `"guard-mismatch"`, `"focus-error"`, or
218
+ `"focus-not-applied"`.
219
+
220
+ Eligibility is intentionally conservative. The target must be an element from
221
+ its owner document, remain connected, expose a callable public focus method, not
222
+ be effectively disabled, and have no self or composed ancestor with `hidden`,
223
+ `aria-hidden="true"`, or `inert`. Effective disabled checks use the browser's
224
+ `:disabled` matching, including disabled-fieldset and first-legend behavior,
225
+ with guarded direct-state fallback. Composed ancestry follows assigned slots
226
+ before light-DOM parents, then crosses open shadow roots through their hosts.
227
+ These checks do not claim perfect browser focusability: layout, computed CSS,
228
+ tab order, and device behavior require real browser and assistive-technology
229
+ testing.
230
+
231
+ Eligibility is checked both before and immediately after the focus call. If the
232
+ target becomes ineligible while remaining deeply active, the helper restores the
233
+ previous target only when that previous target is still conservatively eligible.
234
+ A synchronous third-party redirect is never overwritten: if focus ends on
235
+ another element, the helper reports `"focus-not-applied"` or `"focus-error"` and
236
+ preserves that destination. All rollback and verification remain best-effort at
237
+ hostile DOM boundaries without leaking errors.
238
+
239
+ `FocusElementOptions`, `RestoreFocusOptions`, `FocusCapture`, `FocusResult`, and
240
+ `FocusSkippedReason` are exported for typed integrations. The module is safe to
241
+ import during SSR, uses no timers, performs no scrolling itself, creates no
242
+ focus trap, queries no host DOM, and has no automatic lifecycle behavior.
243
+
244
+ ## Store announcement preferences
245
+
246
+ `createPreferenceStore(options?)` creates a small external store for validated,
247
+ versioned announcement preferences. The default is the frozen v1 value
248
+ `{ version: 1, preset: "balanced", streaming: "preset", tools: "preset" }`. The
249
+ other granular presets are `"minimal"` and `"verbose"`; streaming can be
250
+ `"preset"`, `"off"`, `"completion"`, `"paragraph"`, or `"sentence"`, and tool
251
+ verbosity can be `"preset"`, `"off"`, `"failures"`, `"status"`, or `"progress"`.
252
+ The separate `"completion-only"` schema has no granular fields. Every accepted
253
+ `PreferenceSchemaV1` snapshot is frozen, and validation rejects missing, extra,
254
+ invalid, or unsupported-version fields. Runtime input fields must be own,
255
+ enumerable data properties; accessor-backed and symbol-keyed input is rejected
256
+ without invoking its getters.
257
+
258
+ ```ts
259
+ import {
260
+ createPreferenceStore,
261
+ preferencesToCoreConfiguration,
262
+ } from "@generative-a11y/dom";
263
+
264
+ const preferences = createPreferenceStore({
265
+ persistence: { key: "my-app:a11y-preferences" },
266
+ });
267
+ const unsubscribe = preferences.subscribe(() => {
268
+ console.log(preferences.getSnapshot());
269
+ });
270
+
271
+ preferences.setPreferences({
272
+ version: 1,
273
+ preset: "balanced",
274
+ streaming: "sentence",
275
+ tools: "status",
276
+ });
277
+ ```
278
+
279
+ `PreferenceStore` implements `ExternalStore<PreferenceSchemaV1>` and adds
280
+ `setPreferences(value)` and idempotent `dispose()`. Its server snapshot is the
281
+ configured default, while its client snapshot may contain a loaded preference.
282
+ This deliberate split supports SSR and hydration without accessing browser
283
+ globals during module evaluation. After disposal, snapshots remain readable; new
284
+ subscriptions and writes throw.
285
+
286
+ `normalizePreferences(value)` validates, canonicalizes, and freezes a v1
287
+ snapshot. `samePreferences(left, right)` compares canonical preference values.
288
+ These helpers are exported for thin integrations such as the React adapter to
289
+ reuse the DOM package's schema invariants.
290
+
291
+ Persistence is opt-in through `PreferencePersistence`. It accepts a `key`, an
292
+ optional `PreferenceStorage` (`getItem`/`setItem`), and an optional
293
+ `PreferenceStorageEventSource`. When persistence is requested without injected
294
+ storage, a safely available browser `localStorage` and native `storage` events
295
+ are used. Server and restricted-browser environments remain in-memory. Injected
296
+ event sources take precedence; custom storage without an event source has no
297
+ cross-tab synchronization. Native storage events synchronize other documents,
298
+ but browser storage events do not form a same-tab bus. Invalid, corrupt, and
299
+ forward-version values are preserved in storage and reported through the
300
+ isolated, serializable `PreferenceDiagnostic` callback.
301
+
302
+ `PreferenceStoreOptions` contains `defaultValue`, `persistence`, and
303
+ `onDiagnostic`. A `PreferenceStorageEvent` carries `key`, `newValue`, and an
304
+ optional `storageArea`; a `PreferenceStorageEventSource` supplies a subscribing
305
+ callback and cleanup function. `PreferenceDiagnostic` contains a
306
+ `PreferenceDiagnosticSource`, a `PreferenceDiagnosticCode`, and an optional
307
+ serialized `{ name, message }` error. Sources distinguish storage reads, writes,
308
+ external events, event subscription, and event unsubscription. Codes distinguish
309
+ operation failures, invalid JSON, invalid preferences, and unsupported versions.
310
+
311
+ `preferencesToCoreConfiguration(value)` validates a preference and translates it
312
+ to `{ preset, policy? }`. Use that result only when the host explicitly
313
+ constructs or replaces a core runtime. A preference store never mutates,
314
+ recreates, or disposes an active runtime. `"preset"` fields inherit the selected
315
+ core preset; granular values override only text strategy or tool-event flags,
316
+ leaving timing and progress thresholds inherited. The `"completion-only"`
317
+ preference returns that preset without granular policy overrides.