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

# `lr-media-card`

- **Import** `import '@aceshooting/lyra-ui/components/lr-media-card.js';` (stable tag alias; registers the tag)
- **Class** `LyraMediaCard`, also available unregistered from `@aceshooting/lyra-ui/components/media/media-card/media-card.class.js`
- **Family** `components/media/` — 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, 4 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-media-card`

A lightweight inline preview for one already-sent, already-available attachment inside a rendered
chat message body — distinct from `lr-document-preview` (a fuller viewer with an async
server-side-conversion state machine) and `lr-attachment-chip` (a pre-send queued-file chip with
upload progress). This component has neither concern; it only ever shows a `src` that's already
final.

Removing `mime-type` consumes the hint as absent and restores generic file detection unless `kind`
explicitly selects a format. Property readback stays `null`; an explicit empty hint remains empty
and later MIME values restore detection.

**Properties:**

- `src: string = ''` — the media URL. Always re-validated against a safe-scheme allowlist before
  use (see below) — never trust it unsanitized even though it's typed as a plain string.
- `kind?: LyraMediaCardKind` (`'image' | 'video' | 'file'`, reflected) — explicit format dispatch. Leave unset to
  auto-detect from `mimeType`.
- `mimeType: string = ''` (attribute `mime-type`) — drives auto-detection when `kind` is unset.
- `filename: string = ''` — shown in the file-chip fallback, used as the download link's suggested
  filename, and folded into the accessible name.
- `alt?: string` — alt text for the image case (and reused as a video label fallback). Unset falls
  back to `filename`, then a generic per-kind description. An explicit `alt=""` survives to the
  rendered `<img alt="">`, which is the HTML idiom for a decorative image — same contract as
  `<lr-image-viewer>` and `<lr-document-preview>`. The `<video>` case is deliberately outside that
  carve-out: an empty `alt` there still falls through to `filename`/the generic description, because
  an empty accessible name would leave an interactive player unnamed rather than mark it decorative.
- `accessibleLabel: string | null = null` (attribute `aria-label`) — a declarative attribute names
  the host as a whole while its nested button/link keeps a purpose-specific localized action name. A property-only assignment names the nested action and updates reactively when no host label is present, without creating a host attribute. An explicit
  empty string behaves like the unset `null` default — both fall through to the generated
  purpose-specific name. Image alt text and the native video's own purpose label remain independent;
  an explicitly empty host still leaves every interactive descendant named.
- `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"16rem"`); once set,
  overrides the `--lr-media-card-max-height` custom property for this instance only (applied
  inline on `[part="base"]`, so it reliably wins over a `:host{}`-declared default from outside the
  shadow root) — same contract as `<lr-document-preview>`'s identically-named prop. Values that do
  not parse as CSS `max-height`, contain declaration breaks, or contain `url()` are ignored, leaving
  the stylesheet token in control.
- `frame: LyraFrame = 'card'` (reflected) — container treatment, on the library-wide `frame`
  vocabulary. `'card'` (the default) keeps the bordered, filled box. `'plain'` removes
  `[part="base"]`'s border, background, padding, and corner radius, so a card inside a dense chat
  transcript (or any container already drawing its own separation between attachments) doesn't
  double the frame.
- `disabled: boolean = false` (reflected) — turns off this card's OWN action. The `kind="image"`
  button and the `kind="video"` `open-button` render `disabled`; a safe file chip's anchor loses
  its `href` and `download`, so it genuinely cannot fetch, and gains an explicit `role="link"` so
  its accessible name and `aria-current` stay valid on an element that no longer has an implicit
  role. `lr-media-open` and `lr-before-media-download` stop firing from every path, `click()`
  included, the action leaves the tab order, and it paints at `--lr-opacity-disabled` with a
  `not-allowed` cursor. Deliberately does NOT reach into the `kind="video"` player: `<video
  controls>` is media content with its own native transport, not this card's action. An
  unsafe-`src` file chip has no action at all, so `disabled` leaves it unchanged. Not
  form-associated, so an ancestor `<fieldset disabled>` does not cascade here. The disabled paint
  keys off the state each element already carries — `::part(base):disabled` and
  `::part(open-button):disabled` restyle the two buttons from outside; the file chip's anchor has no
  such pseudo-class, so target it through the reflected host attribute
  (`lr-media-card[disabled]::part(base)`).
- `aria-pressed` and `aria-current` (attributes only) — forwarded reactively onto whichever native
  control carries this card's action, the same mechanism `<lr-button>`/`<lr-icon-button>` use.
  `aria-pressed` accepts `'true' | 'false' | 'mixed'` and reaches BUTTONS only (`kind="image"`'s
  `base` and `kind="video"`'s `open-button`) — the file chip's anchor never receives it, because
  `link` has no pressed state. The global `aria-current` accepts `'page' | 'step' | 'location' |
  'date' | 'time' | 'true' | 'false'` and reaches every kind, anchor included. Anything outside
  those sets is dropped rather than passed through.

**Renamed in 8.0.0 — breaking:** this was `appearance`. Library-wide, `appearance` now means only
"how a control fills itself" and `frame` means "whether a container draws itself as a bounded card";
this property was always the second. There is no alias — `appearance` on `<lr-media-card>` is simply
an unknown attribute now, so a card left on `appearance="plain"` silently renders the full card
chrome again.

**Authoring types:** `LyraMediaCardKind` and `LyraMediaCardOpenDetail`; `frame` uses the shared
`LyraFrame` directly. The former `MediaCardKind`, `MediaCardOpenDetail`, and `MediaCardFrame`
names are removed in v9 rather than retained as aliases. URL validators are implementation details,
not exports from the component entry.

**Events:** `lr-media-open` (`detail: LyraMediaCardOpenDetail { src: string; filename: string }`,
noncancelable) notifies
after image-card or video open-button activation; those kinds have no component-owned navigation,
so a host decides what "open" means. `lr-before-media-download` carries the same detail and is
cancelable only for a safe file anchor immediately before its native download/open default; calling
`preventDefault()` there suppresses that exact default. `detail.src` is whichever internally
validated safe-URL sink actually rendered, not necessarily the raw `src` property verbatim — a
whitespace-padded value is trimmed, so it matches the rendered sink.
The former generic `lr-open` event is removed in v9: notification and veto phases now have distinct,
truthful names.
Native `focus` and `blur` are each relayed once from the current primary action as bubbling,
composed `FocusEvent`s whose target is the `lr-media-card` host.

**Methods:** `focus(options?)`, `blur()`, and `click()` forward to the primary action for the
current media kind. The forwarded focus/blur transition produces the relayed host event described
above.

**Slots:** none.

**CSS parts:** `base` (a `<button>` for `kind="image"`, a plain wrapper `<div>` for `kind="video"`,
an `<a>` or `<span>` for the file-chip fallback depending on href safety), `media` (the `<img>`/
`<video>`), `file-icon`, `filename` (file-chip fallback only), `open-button` (video only — see
below).

**Themeable custom properties:** `--lr-media-card-max-height` (default `var(--lr-size-20rem)` — caps `[part="media"]`'s
block-size so one oversized image/video can't blow out a chat bubble; same naming/contract as
`<lr-document-preview>`'s identical `--lr-document-preview-max-height`; override per-instance via
the `max-height` attribute instead of this property directly).
`--lr-media-card-bg` (default `var(--lr-color-surface)`) retints the RESTING `frame="card"`
chrome — the companion to the pressed state's `--lr-media-card-active-bg` below, and the tier a
card sits at all day; `frame="plain"` still drops the fill entirely.
`--lr-media-card-active-border-color` (default
`color-mix(in oklab, var(--lr-color-brand), var(--lr-color-mix-partner) var(--lr-color-mix-active))`)
and `--lr-media-card-active-bg` (default
`color-mix(in oklab, var(--lr-media-card-bg, var(--lr-color-surface)), var(--lr-color-mix-partner) var(--lr-color-mix-active))`)
independently retint only a pressed image/file action; the pressed default mixes from
`--lr-media-card-bg`, so retinting the resting card carries the pressed state with it. Both are inline `var()` fallbacks in the
pressed state, so values on a chat or attachment-list ancestor inherit into every card rather than
being shadowed by host defaults. Plus shared tokens
`--lr-space-xs`/`-s`, `--lr-color-border`, `--lr-color-surface`, `--lr-color-text`/`-text-quiet`,
`--lr-color-brand` (hover border), `--lr-radius`, `--lr-icon-button-size` (video's `open-button`
sizing), `--lr-focus-ring-*`, `--lr-transition-fast`.

**Optional peer deps:** none.

```html
<lr-media-card
  id="image-card"
  kind="image"
  src="https://example.com/photo.jpg"
  alt="Screenshot"
  filename="photo.jpg"
></lr-media-card>
<lr-media-card id="file-card" kind="file" src="https://example.com/report.pdf" filename="report.pdf">
</lr-media-card>
<script type="module">
  document
    .getElementById("image-card")
    .addEventListener("lr-media-open", (e) => openLightbox(e.detail.src));
  document
    .getElementById("file-card")
    .addEventListener("lr-before-media-download", (e) => shouldUseNativeDownload || e.preventDefault());
</script>
```

**Safe-URL checking.** `src` is validated by internal sink-specific helpers before it's
ever assigned to an `<img>`/`<video>` `src` or an `<a href>` — only `http:`/`https:`/`blob:` (plus
`data:` for a _media_ `src` only) or a scheme-relative/relative URL with no scheme at all pass;
anything else (`javascript:`, `vbscript:`, and similarly suspicious schemes) is rejected. `data:` is
allowed for a media source (a browser never executes script from a media element's `src`) but
rejected by the stricter link validator (a `data:text/html` URI navigated to via a clicked `<a
href>` runs as a full document and can execute script) — the same scheme gets a different verdict
depending on which DOM sink it's headed for. Both validators delegate to the platform's own `new
URL()` parser rather than a hand-rolled scheme regex, specifically because `new URL()` already
implements the WHATWG URL Standard's input normalization (stripping tab/newline/leading-trailing
space before looking for a scheme) — a naive regex is vulnerable to exactly the kind of
tab-injected-into-a-scheme bypass a browser attribute sink still normalizes and executes. An
`image`/`video` `kind` whose `src` fails the media-src check falls back to the generic file-chip
rendering, which then separately re-validates `src` against the stricter href allowlist for its own
download affordance.

`kind="video"` renders its open affordance as a separate `[part="open-button"]` next to
`[part="media"]` rather than wrapping the whole card in one `<button>`/`<a>` (the pattern
`image`/`file` use) — a `<video controls>` element is itself interactive content, and HTML forbids
nesting interactive content inside a `<button>`/`<a>`; doing so anyway would also make every click on
the video's own native controls bubble up and spuriously fire `lr-media-open`.

**Known gotchas:**

- `kind` only reflects to the host attribute when explicitly set — CSS keying off the
  auto-detected resolved kind should target the rendered `[part]`/element (e.g. `video[part="media"]`),
  not `:host([kind=...])`, since the latter won't see an auto-detected kind.

---
