<!-- GENERATED by scripts/build-llms.mjs from llms/utility.md — do not edit this file. -->

# `lr-live-region`

- **Import** `import '@aceshooting/lyra-ui/components/lr-live-region.js';` (stable tag alias; registers the tag)
- **Class** `LyraLiveRegion`, also available unregistered from `@aceshooting/lyra-ui/components/utility/live-region/live-region.class.js`
- **Family** `components/utility/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 1 part, 0 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-live-region` (+ the `Announcer` helper)

A throttled screen-reader announcement helper, split into a DOM-free coalescing engine (the
`Announcer` class), the shared light-DOM region a flush writes into (`acquireAnnouncementSink()`),
and a real custom element that composes both. The two helpers are public — part of the curated,
semver-covered `utilities/` surface documented in `llms/shared.md`, not internals:

The Basic example’s controls announce through its sibling live region; both first and repeated
messages appear in the visible log and the light-DOM announcement sink.

```ts
import {
  Announcer,
  acquireAnnouncementSink,
} from "@aceshooting/lyra-ui/utilities/announcer.js";
```

The `.js` is required (`./utilities/*` maps straight onto `./dist/utilities/*`). Both symbols are
also re-exported from the extensionless `@aceshooting/lyra-ui/utilities` barrel and from the package
root, but the single-helper subpath above is the form to copy — it reaches nothing else.

### `Announcer` — `@aceshooting/lyra-ui/utilities/announcer.js`

Not a custom element — the `Announcer` class itself is pure timing/coalescing logic with no DOM
dependency, composed by `<lr-live-region>` (below) and intended for reuse by any other component
that needs throttled announcements (a stream-status indicator, a tool-call chip's status
transitions, a chat message's streaming state). The same module also exports the DOM half a flush
writes into — `acquireAnnouncementSink()`, documented after `Announcer` below.

Streaming UIs (token-by-token chat responses, progress ticks, etc.) naturally produce far more
candidate announcements than a screen-reader user can usefully absorb — reading every incremental
chunk aloud is spam, not information. `Announcer` collapses a burst of `announce()` calls arriving
within `throttleMs` of the _first_ call in that burst down to a single trailing-edge flush of the
latest text: superseded intermediate text is dropped outright, never queued or concatenated.

- `new Announcer(options: AnnouncerOptions)` where
  `AnnouncerOptions = { throttleMs?: number /* = 500 */; onFlush: (text: string) => void;
timerHost?: AnnouncerTimerHost }`. `AnnouncerTimerHost` is the minimal numeric-handle
  `setTimeout`/`clearTimeout` surface implemented by a browser `Window`; omit it to use ambient
  timers.
- `announce(text: string, options?: AnnounceOptions)` where `AnnounceOptions = { force?: boolean }` —
  queues `text`, overwriting whatever an earlier call in the same burst queued. Only the _first_
  call of a burst schedules the flush timer, so the deadline stays anchored to that first call
  rather than being pushed back by every subsequent call. `{ force: true }` bypasses any
  in-progress window and flushes immediately, so a terminal message (e.g. "response complete") is
  never swallowed mid-burst.
- `cancel()` — drops any pending (not yet flushed) text without invoking `onFlush`.
- `setTimerHost(timerHost: AnnouncerTimerHost)` — rebinds scheduling and cancellation, for example
  after a component is adopted into another document. A pending burst is canceled on the previous
  host and rescheduled on the new one without losing its latest text.
- `pendingText: string | undefined` — the latest text awaiting flush, if a burst is in progress.
- `isPending: boolean` — whether a flush is currently scheduled.
- `throttleMs` — a plain public field, safe to change between bursts; a flush already scheduled
  keeps the deadline it was scheduled with.

### `acquireAnnouncementSink()` — `@aceshooting/lyra-ui/utilities/announcer.js`

The shared live region announcements actually land in. A live region rendered **inside a shadow
root is not reliably announced** — JAWS with Firefox ignores one entirely — so every announcement
this library makes goes into a visually hidden element in the _host document's_ light DOM instead.

- `acquireAnnouncementSink(politeness: AnnouncementPoliteness, options?: AnnouncementSinkOptions)`
  where `AnnouncementPoliteness = 'polite' | 'assertive'` and
  `AnnouncementSinkOptions = { document?: Document /* = the ambient document */; source?: Element;
messageTtlMs?: number /* = 5000 */ }`. Library components pass their host as `source`, which
  prevents a document-level region from speaking while that source or a composed ancestor is
  `hidden`, `inert`, `aria-hidden`, CSS-hidden, or in a closed `<details>` content branch;
  standalone consumers can do the same. A box-generating source also stays silent while skipped by
  `content-visibility:auto`. Browsers report every `display:contents` source as false from
  `checkVisibility()` whether its semantics are exposed or not, so the helper uses the explicit
  authored/CSS/closed-details gates for that boxless case and cannot distinguish an auto-skipped
  subtree; bind `source` to a semantic box when that exact distinction matters. A source adopted
  away from the acquired `document` also fails closed until its owner reacquires a sink.
  Returns an `AnnouncementSink` handle: `element`, `politeness`, a writable `messageTtlMs`,
  `announce(text: string): void` and `release(): void`.
- One region per `(document, politeness)` pair, shared by every consumer and **ref-counted**: it is
  mounted on the first `acquire()` and removed from the DOM when the last handle `release()`s.
  Mounting happens at acquire time, ahead of any text, because assistive tech has to have been
  observing a region _before_ content arrives for the change to be announced at all.
- `announce()` **appends a child node** (`aria-relevant="additions"`, `aria-atomic="false"`) rather
  than rewriting one text node. That is what makes an identical repeat announce a second time — no
  clear-then-restore-across-a-frame dance is needed — and each appended node is swept after
  `messageTtlMs`, so returning focus to the page never finds stale text to re-read. Empty text is
  ignored. Sweep and release cancellation use the selected document's `defaultView` timer realm,
  so an iframe-owned sink does not leave parent-window timers retaining its messages. `announce()`
  after `release()` is a no-op; `release()` is idempotent and removes that
  handle's own not-yet-swept nodes.
- The region carries `data-lr-live-region="<politeness>"` (exported as
  `ANNOUNCEMENT_SINK_ATTRIBUTE`) so a consumer's DOM diffing, snapshot testing or `MutationObserver`
  can recognize and ignore library-owned nodes appearing at the end of `<body>`.
- Under SSR (no `document`) the call returns an inert handle instead of throwing, so callers need
  no environment check.

### `lr-live-region`

A visually-hidden ARIA live region that throttles and coalesces announcements instead of relaying
every call verbatim, by composing an internal `Announcer` with the shared light-DOM sink above. A
consumer typically mounts one `<lr-live-region>` per page/surface (much like `<lr-toast>` is one
region per placement) and keeps a reference to call `announce()` from application code or a parent
component. The announced copy does **not** live in this element's shadow root: it is appended to
the shared, ref-counted region in the host document, while the shadow `part="region"` element stays
behind as an `aria-hidden` mirror of the latest text.

**Properties:**

- `mode: 'polite' | 'assertive' = 'polite'` (reflected) — selects which shared region announcements
  land in: `'polite'` uses the `role="status"` + `aria-live="polite"` one (waits for the user to be
  idle), `'assertive'` the `role="alert"` + `aria-live="assertive"` one (interrupts). Unsupported
  attributes and untyped property writes normalize to reflected `polite`
- `throttleMs: number = 500` (attribute `throttle-ms`) — the coalescing window; see `Announcer`
  above

**Methods:** `announce(text: string, options?: AnnounceOptions): void` — queues `text` for
announcement through the internal `Announcer`; `{ force: true }` bypasses the current throttle
window and flushes immediately.

**Events:** none.

**Slots:** none.

**CSS parts:** `region` — the visually-hidden, `aria-hidden` mirror of the latest announced text.
It carries no `role`/`aria-live` of its own: a second live region holding the same text would make
browsers that _do_ announce shadow live regions read every message twice.

**Themeable custom properties:** none component-specific — the shadow mirror is hidden via the
shared `.sr-only` helper class (`internal/a11y.ts`) and the light-DOM region via the same
visually-hidden algorithm inline (no stylesheet of this package reaches the consumer's light DOM),
neither of them tokenized CSS.

**Optional peer deps:** none.

```html
<!-- once, near the root of a page/surface -->
<lr-live-region id="live" mode="polite"></lr-live-region>
<script type="module">
  const live = document.getElementById("live");
  // streaming tokens: fine to call on every chunk, only the trailing state lands
  live.announce(`${partialText} …`);
  // stream finished: always announced, even mid-throttle-window
  live.announce("Response complete", { force: true });
</script>
```

A parent Lit component would instead hold the reference via `@query('lr-live-region')`.

**Known gotchas:**

- The element mounts (and shares) a region in `document.body` for as long as it is connected — an
  expected side effect, not a leak: it is ref-counted and removed once the last `<lr-live-region>`
  of that politeness disconnects. Announcements are appended nodes, so re-announcing identical text
  is read again with no special-casing, and each node is swept a few seconds later.
- The announcement never waits on a render: it is appended as soon as the flush happens, including
  when a consumer creates, appends and `announce()`s synchronously. Only the shadow mirror waits —
  a write landing before `firstUpdated()` is buffered and applied on the next `firstUpdated()`.
- Changing `mode` re-targets the shared region synchronously at announce time, so setting `mode`
  and force-announcing in the same turn lands with the new urgency instead of racing Lit's
  re-render. Adopting the element into an iframe likewise re-targets to that document's own region.
- `disconnectedCallback()` cancels any pending (unflushed) announcement and any before-first-render
  buffered write, and releases the shared region — an element removed before a deferred write lands
  silently drops it, including across a later reconnect.
- Changing `throttle-ms` updates the live `Announcer`'s window immediately, but a flush already
  scheduled under the old window keeps the deadline it was scheduled with.

---
