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

# `lr-chat-message`

- **Import** `import '@aceshooting/lyra-ui/components/lr-chat-message.js';` (stable tag alias; registers the tag)
- **Class** `LyraChatMessage`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/chat-message/chat-message.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** 14 parts, 19 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-chat-message`

A role-based message bubble _shell_ for a chat/agent conversation surface. It renders none of the
message content itself — the default slot carries whatever a consumer wants to display (plain text,
a `<lr-markdown>`, a custom template, anything) and this component only supplies the surrounding
chrome: alignment/coloring by `messageRole`, an avatar/badges header row, an optional collapse toggle, an
attachments strip, and a status-aware footer (a live-updating status dot + text, the formatted
`timestamp`, a built-in retry affordance for `status="failed"`, and an `actions` slot for everything
else). No built-in copy button is rendered — slot a copy control into `actions` and fire
`lr-copy` (fulfilled-only frozen `detail: { ok: true, text }`) from it if you want one (matching
`<lr-code-block>`'s copy-affordance contract for anything listening at the conversation-surface
level).

Compose `lr-copy-button` in `actions` with its `value` set to the intended message source. The shell
does not infer that source or write the clipboard itself; the composed control emits success only
after the write fulfills.

**Properties:**

- `messageRole: ChatMessageRole = 'assistant'` (`'user' | 'assistant' | 'system'`, attribute
  `message-role`, reflected) — identifies the author without colliding with the platform `role`
  attribute. The role-owning internal article receives the localized author name directly through
  `aria-label`; a host `aria-label` overrides it by attribute presence (including an explicitly
  empty override). Styling keys off the same reflected `message-role` host attribute; a bare `role="assistant"`
  is never an authoring API.
- `status: ChatMessageStatus = 'sent'` (`'sending' | 'sent' | 'failed' | 'streaming'`, reflected) —
  drives the footer's status dot/text, `status="failed"`'s danger treatment on the bubble, and the
  built-in retry button
- `timestamp?: LyraTimestamp` (`Date | string | number`, attribute: false) — normalizes through the
  ECMAScript TimeClip domain; invalid or throwing input is treated as unset (no timestamp rendered)
- `formatTimestamp?: (date: Date) => string` (attribute: false) — overrides the default
  `hour:minute` (`Intl.DateTimeFormat`, runtime locale) rendering of `timestamp`
- `collapsible: boolean = false` (reflected) — shows the built-in collapse/expand toggle in the header
- `collapsed: boolean = false` (reflected) — whether the message body is hidden; effective whenever
  set, independent of `collapsible` (which only controls whether the toggle button itself is
  rendered) — mirrors `lr-widget`'s identical `collapsible`/`collapsed` pair
- `attachmentsPosition: 'before'|'after' = 'after'` (attribute `attachments-position`) — places the
  `attachments` slot before or after the message body; both the visual and reading order follow it
- `actionsPosition: ChatMessageActionsPosition = 'inside'` (`'inside' | 'outside'`, attribute
  `actions-position`, reflected) — `'outside'` renders the `actions` slot's content as a sibling
  immediately after `[part="bubble"]` instead of nested inside `[part="footer"]`'s own
  padding/background box, for an action row that must sit visually outside the bubble's chrome
- `messageId: string = ''` (attribute `message-id`, reflected) — optional stable application id;
  included in `lr-message-retry` detail when the built-in retry control is activated

**Events:** `lr-message-retry` (`detail: { messageId?: string }`; fired by the built-in retry button,
only rendered when `status="failed"`). `lr-toggle-request` is cancelable and carries
`{ collapsed: boolean }`; preventing it vetoes the built-in collapse/expand transaction.
`lr-toggle` carries that same detail after the accepted state is committed.

**Slots:** default (the message body), `avatar` (an avatar/icon for the message author), `badges`
(small status/metric chips — e.g. token count, latency, model name — entirely app-supplied), `actions`
(action controls such as copy/retry, rendered at the end of the footer), `attachments` (file/image
attachment chips, rendered below the message body), `failure` (only meaningful while
`status="failed"`: host-supplied content — typically a `role="alert"` banner plus its own retry
control — that replaces the built-in status text, retry button, and live-region announcement
entirely; unset, `status="failed"` renders exactly as before)

**CSS parts:** `bubble`, `header` (hidden entirely when nothing is in it), `avatar`, `badges`,
`collapse-button` (only rendered when `collapsible`), `body` (hidden while `collapsed`),
`attachments`, `footer` (hidden entirely when nothing is in it), `status-indicator` (a small
decorative `aria-hidden` dot, absent while `status="sent"`), `status-text` (the visible text twin of
`status-indicator`), `timestamp`, `retry-button` (only rendered when `status="failed"` and the
`failure` slot is empty), `actions` (rendered inside the footer by default; a sibling immediately
after `bubble` when `actionsPosition="outside"`), `failure` (`display: contents` wrapper for the
`failure` slot; contributes no box when the slot is empty)

**Themeable custom properties:** `--lr-chat-message-max-width` (default `80%` — the bubble's max
inline size; component-specific, no shared width token exists), four role-scoped bubble color
properties:

- `--lr-chat-message-bubble-bg` (default `var(--lr-color-surface)`) — bubble fill for every role
  except `user`.
- `--lr-chat-message-bubble-color` (default `var(--lr-color-text)`) — bubble text color for those
  same roles.
- `--lr-chat-message-user-bubble-bg` (default `var(--lr-color-brand-quiet)`) — bubble fill for
  `message-role="user"`.
- `--lr-chat-message-user-bubble-color` (default `var(--lr-color-text)`) — bubble text color for
  `message-role="user"`.

Prefer these over re-pointing the shared token a default happens to reference. Overriding
`--lr-color-brand-quiet` on the host also retints `[part='collapse-button']:hover` within this same
component, and which shared token backs each role's fill is not a stable contract — it changed
between 4.x and 5.0.0, which silently turned one consumer's inner-surface scrim into the whole
bubble (near-black text on `rgba(0,0,0,0.22)`, visible only by eye). These four are that stable
contract.

Two matching geometry properties cover the bubble's box:

- `--lr-chat-message-bubble-padding` (default `var(--lr-space-m)`) — the bubble's padding.
- `--lr-chat-message-bubble-radius` (default `var(--lr-radius)`) — the bubble's corner radius.
  Bubble-only by design: `[part='collapse-button']` and `[part='retry-button']` keep reading the
  shared `--lr-radius`, so a rounder bubble never desyncs those controls from the rest of the
  library.

**Prefer these to a `::part(bubble)` padding/radius override.** A consumer `::part()` rule wins only
for the CSS properties it actually declares; changing padding or radius does not erase unrelated
role/status colors or borders. The named hooks are the stable, narrow geometry contract and can be
set once above a whole transcript. They are consumed as inline `var()` fallbacks rather than
declared on `:host`, so the host cannot shadow an inherited value.

Plus shared tokens `--lr-space-xs/-m`, `--lr-color-border`, `--lr-color-surface`,
`--lr-color-brand-quiet`, `--lr-color-brand`, `--lr-color-text-quiet`, `--lr-color-danger`,
`--lr-color-danger-quiet`, `--lr-radius`, `--lr-icon-button-size`, `--lr-focus-ring-*`,
`--lr-transition-fast`, and `--lr-transition-ambient` (default `1.8s ease-in-out`) — the
streaming-indicator pulse animation's cycle, the same shared compound token
`<lr-typing-indicator>` uses.

`[part=bubble]`'s background resolves through `--lr-color-surface`. If your own panel/container
background already maps to that same token, override `[part=bubble]`'s background explicitly (e.g.
via `::part(bubble)`) so message bubbles stay visually distinct from the surrounding panel.

> Retheming a bubble from outside `<lr-chat-message>` (e.g. per-thread or per-role colors)?
> Set `--lr-theme-*` on the ancestor wrapper, not `--lr-*` directly — see `llms/shared.md`'s
> "Theming and design tokens" section for why a `--lr-*` override on a wrapper only reaches that
> wrapper's _direct_ children, not a nested `<lr-*>` host's shadow DOM.

**Optional peer deps:** none. Internally renders a `<lr-live-region>` (a first-party sibling
component, auto-imported alongside this one, not an npm peer) for the status-transition
announcements described below.

```html
<lr-chat-message message-role="assistant" status="streaming">
  <span slot="avatar">🤖</span>
  <span slot="badges">gpt-5.4 · 1.2s</span>
  <lr-markdown content="Here's what I found…"></lr-markdown>
  <button slot="actions">Copy</button>
</lr-chat-message>
<script>
  document
    .querySelector("lr-chat-message")
    .addEventListener("lr-message-retry", () => resend());
</script>
```

Accessibility of `status`: the current status is always available as plain visible text
(`[part="status-text"]`), never color alone. A transition _to_ `"failed"`, or _from_ `"streaming"` to
`"sent"` (a stream finishing), is additionally announced through the internal `<lr-live-region>` —
`"failed"` announces assertively (`"Message failed to send."`), a streaming→sent completion announces
politely (`"Message complete."`) — so a screen-reader user not currently focused on this message
still learns about it. No other status transition is announced (e.g. `streaming`→`sending`, or
`sending`→`sent` without having passed through `streaming`, produce no announcement). This differs
from `<lr-typing-indicator>`'s deliberately simpler `role="status"` approach, appropriate there
since that component only ever announces once (its own mount); this component's `status` can flip
between several values across a single element's lifetime.

**Known gotchas:**

- mounting a message with `status="failed"` (or any other non-`"sent"` status) already set does
  **not** announce anything — only a genuine _later_ transition (`changed.get('status') !==
undefined`, i.e. not the very first update) triggers the live-region announcement.
- `lr-message-retry` carries `{ messageId?: string }`; the field is the component's `messageId` when set,
  and is omitted otherwise.
- the header/footer/avatar/badges/attachments/actions wrappers are shown/hidden via the `hidden`
  attribute, not conditional templating. Whether each slot currently has content is checked once via
  a light-DOM children scan on the very first update (`willUpdate`, gated on `!this.hasUpdated`) and
  thereafter only via each slot's own `slotchange` listener — content added directly with
  `appendChild` after first paint still triggers native `slotchange`, so this works transparently,
  but any code that manually re-parents already-slotted nodes without a real slot-assignment change
  won't refresh the corresponding wrapper's visibility.
- `messageRole` reflects as `message-role`, which is also the host selector used by component
  styling. Never use `[role="user"]` as author state.

**Additional API surface:**

- `--lr-chat-message-system-color` — System-message text color. Default: `var(--lr-color-text-quiet)`.
- `--lr-chat-message-streaming-border-color` — Streaming bubble border. Default: `var(--lr-color-brand)`.
- `--lr-chat-message-failed-border-color` — Failed bubble border. Default: `var(--lr-color-danger)`.
- `--lr-chat-message-failed-bg` — Failed bubble fill. Default: `var(--lr-color-danger-quiet)`.
- `--lr-chat-message-footer-color` — Default footer text. Default: `var(--lr-color-text-quiet)`.
- `--lr-chat-message-user-footer-color` — User-message footer text. Default: `var(--lr-color-text)`.
- `--lr-chat-message-failed-footer-color` — Failed-message footer text. Default: `var(--lr-color-danger)`.
- `--lr-chat-message-indicator-color` — Default status indicator. Default: `var(--lr-color-text-quiet)`.
- `--lr-chat-message-streaming-indicator-color` — Streaming indicator. Default: `var(--lr-color-brand)`.
- `--lr-chat-message-failed-indicator-color` — Failed indicator. Default: `var(--lr-color-danger)`.
- `--lr-chat-message-failed-status-color` — Failed status text. Default: `var(--lr-color-danger)`.

---
