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

# `lr-stream-status`

- **Import** `import '@aceshooting/lyra-ui/components/lr-stream-status.js';` (stable tag alias; registers the tag)
- **Class** `LyraStreamStatus`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/stream-status/stream-status.class.js`
- **Family** `components/conversation/` — 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** 5 parts, 5 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-stream-status`

A compact status indicator for a single streaming connection (SSE, WebSocket, long-poll, …), with
built-in heartbeat-aware stall detection. First-party invention (no Web Awesome equivalent). The
host drives `connectionState` for `idle`/`connecting`/`streaming`, and calls the imperative
`recordActivity()` method on every _semantic_ frame received while streaming — a real content
chunk, never a transport-level keep-alive ping. This component has no payload-inspection logic of
its own: "ignore heartbeats" is entirely call-site discipline, which is exactly why a connection
that's only sending keep-alives (no real content) for longer than `stall-threshold-ms` correctly
reads as stalled.

**Properties:**

- `connectionState: StreamConnectionState = 'idle'` (attribute `connection-state`, reflected) —
  host-owned transport state (`'idle' | 'connecting' | 'streaming'`). Invalid attribute or property
  writes normalize to `idle`.
- `phase: LyraStreamPhase` (readonly) — effective state: `connectionState`, or component-owned
  `'stalled'` while an active stream has exceeded its inactivity threshold
- `stallThresholdMs: number = 10000` (attribute `stall-threshold-ms`) — how long `phase` may stay
  `'streaming'` with no `recordActivity()` call before the component auto-transitions to
  `'stalled'`. A non-finite or `<= 0` value disables the stall timer entirely (arming becomes a
  no-op, so the phase will never auto-stall). Changing this value while already `'streaming'`
  re-arms the timer immediately against the new value, rather than waiting for the next
  `recordActivity()` call or phase change.

**Methods:**

- `recordActivity(): void` — call on every semantic (non-heartbeat) frame received while
  streaming.
  - While `phase === 'streaming'`: (re)arms the stall timer, pushing the stall deadline
    `stallThresholdMs` further out.
  - While `phase === 'stalled'`: recovers — the effective phase becomes `'streaming'` again (firing
    `lr-recover` and arming the timer fresh, via the same transition handling a direct host
    transport transition would also go through).
  - While `phase` is `'idle'` or `'connecting'`: a no-op. Safe to call defensively before formally
    flipping to `'streaming'`; it never throws or starts a timer early.
- `markStalled(): void` — installs the component-owned stalled override for an active streaming
  connection; no-op in other transport states or when already stalled

**Events:** `lr-stall` (`detail: null`) — fires whenever the effective phase transitions into
`'stalled'`, whether timer-driven or via `markStalled()`. `lr-recover` (`detail: null`) — fires
whenever the effective phase transitions out of `'stalled'`, whether via `recordActivity()` or a
host transport transition. Neither fires for a same-value
reassignment, and neither fires for whatever phase the element happens to _mount_ with — only a
later change counts as a transition.

**Slots:** default (custom copy shown only while the readonly `phase` is `'stalled'`, e.g. "Taking longer than
usual…" — falls back to a built-in default message when nothing is slotted), `actions` (a
stop/retry button row; always present in the template regardless of `phase` — its wrapper's
visibility is driven purely by whether anything is slotted into it, not by `phase`)

**CSS parts:** `base`, `indicator`, `phase` (persistent localized effective-state text), `message`,
`actions`

**Themeable custom properties:** shared tokens only — `--lr-color-text-quiet` (idle dot color),
`--lr-color-brand` (connecting/streaming dot color), `--lr-color-warning` (stalled dot color,
message text color, stalled border), `--lr-color-warning-quiet` (stalled background tint),
`--lr-space-s` / `--lr-space-xs` (base gap, stalled padding, actions gap), `--lr-radius`
(base corner radius), `--lr-transition-base` (background/border-color transitions and the
dot's color/opacity transitions), and `--lr-transition-ambient` (the streaming pulse cycle).
The phase defaults flow through `--lr-stream-status-dot-color` and
`--lr-stream-status-dot-opacity`; setting either custom property on the element or an ancestor
wins through the shadow cascade and is the supported per-instance override. The stalled row's own
longhands are indirected the same way: `--lr-stream-status-stalled-bg` (falls back to
`--lr-color-warning-quiet`) and `--lr-stream-status-stalled-border-color` (falls back to
`--lr-color-warning`) retheme the `base` part's background/border while stalled, and
`--lr-stream-status-message-color` (also falling back to `--lr-color-warning`) rethemes both the
`phase` and `message` parts' stalled text color independently of the border — the text and border
currently share a default value but are separate hooks, so overriding one never moves the other.

**Optional peer deps:** none.

```html
<lr-stream-status connection-state="streaming" stall-threshold-ms="8000">
  <span slot="actions"><button>Stop</button></span>
</lr-stream-status>
```

```ts
const status = document.querySelector("lr-stream-status")!;
status.addEventListener("lr-stall", () => console.warn("stream stalled"));
status.addEventListener("lr-recover", () => console.info("stream recovered"));

// on every real content chunk from the transport (never on a keep-alive ping):
status.recordActivity();
```

Internally, the inactivity timer runs only while `phase === 'streaming'`. It's (re)armed whenever
`connectionState` transitions to `'streaming'` (or `recordActivity()` recovers from
`'stalled'`) and on every subsequent `recordActivity()` call while already streaming; it's disarmed
the instant the effective phase becomes anything else, including a host-driven transport transition away from
`'streaming'` — so a stale timer can never fire a stall transition after the host has already moved
on. Phase transitions into/out of `'stalled'` are announced through an internal
`<lr-live-region>` rather than a hand-rolled `aria-live` region: entering `'stalled'` announces
"Connection stalled." with `mode="assertive"` (a stall can need the user's attention before they
give up and navigate away). Leaving `'stalled'` always announces with `mode="polite"` (good news
doesn't need to interrupt), but the _wording_ depends on the destination phase: "Connection
restored." only when leaving `'stalled'` for `'streaming'` (a genuine recovery, typically via
`recordActivity()`); a neutral "No longer stalled." when the destination is `'idle'`/`'connecting'`
instead (the host gave up on the stream, which is not the same thing as it recovering — a
screen-reader user must not be told the opposite of what a sighted user sees). Calling
`recordActivity()` itself never announces anything, no
matter how often the host calls it — only the phase _transition_ announces, exactly once. The
decorative indicator dot is `aria-hidden` (a color/motion cue only) and only pulses while
`connection-state="streaming"` and not stalled; `'stalled'` is styled as a warning tone, not danger, since a stall is usually
recoverable — a host that wants to escalate after N stalls can scope its own CSS off
the `lr-stall` event, or stop rendering this component and show its own danger-styled error state
instead. The pulse animation is suppressed under `prefers-reduced-motion: reduce`.

**Known gotchas:**

- `recordActivity()` is a plain instance method, not a reactive property — there's nothing to bind
  to in a template; call it directly from streaming/application code on every real chunk received.
- Never call `recordActivity()` for a heartbeat/keep-alive ping. This component has no
  payload-inspection logic of its own, so a connection that's only sending pings (no real content)
  for longer than `stall-threshold-ms` is _supposed_ to read as stalled — that's the entire
  point of the API.
- Setting `stallThresholdMs` to `0`, a negative number, or a non-finite value disables the stall
  timer outright; the component stays `'streaming'` until the host changes `connectionState` or
  explicitly calls `markStalled()`.
- `phase` is readonly. Call `markStalled()` for a semantic stall detected outside the inactivity
  timer; writing an own `phase` property is unsupported and cannot replace component-owned state.
- reconnecting the element while still `phase === 'streaming'` (e.g. a drag-and-drop reparent that
  keeps the same instance) automatically re-arms the stall timer in `connectedCallback` —
  `disconnectedCallback` always disarms it, and disconnect/reconnect fire back-to-back with no
  `updated()` cycle in between, so nothing else would otherwise notice.
- The `actions` slot's wrapper is always present in the DOM and toggled with the `hidden` attribute
  based on slotted content, not gated by `phase` — content placed there is visible regardless of
  the current phase.

---
