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

# `lr-dialog`

- **Import** `import '@aceshooting/lyra-ui/components/lr-dialog.js';` (stable tag alias; registers the tag)
- **Class** `LyraDialog`, also available unregistered from `@aceshooting/lyra-ui/components/overlays/dialog/dialog.class.js`
- **Family** `components/overlays/` — 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** 15 parts, 21 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-dialog` / `confirm()`

General-purpose modal/overlay plus a promise-based confirmation helper built on top of it.

Removing `label` safely omits its fallback title. Open dialog and drawer names follow supported name
and exclusion attribute changes on direct unslotted headings, while host naming retains precedence;
nested and `slot=""` headings remain outside automatic discovery.

### `lr-dialog`

A modal/overlay: `role="dialog"`, focus-trapped while open, dismissible via Escape or (opt-in) a
backdrop click, and scroll-locks the document for as long as it's open. Mapped chrome is present by
default: `label` renders as a visible title and `closable` renders a localized close button.
`closable="false"` plus either header-suppression spelling support custom chrome: `no-header` is
Shoelace's name and `without-header` is Web Awesome's. Both are current upstream spellings, both are
read, and neither is deprecated.

**Properties:**

- `open: boolean = false` (reflected) — **changed in 8.0.0:** `lr-dialog` now also has a
  `show()`/`hide()` pair, and assigning `open` runs exactly the same lifecycle as calling them, so
  the property, the reflected attribute and the two methods can never disagree. `el.open = false`
  therefore emits the full close lifecycle and can be vetoed, where it used to be a silent state
  flip. Markup that renders open from the start (`<lr-dialog open>`) emits nothing.
- `label: string = ''` — mapped visible title. The richer `label` slot wins over it.
- `headingLevel: LyraHeadingLevel = '3'` (attribute `heading-level`, reflected) — `1`–`6` expose
  the generated visible title (string property or rich `label` slot) at that semantic level;
  invalid untyped values retain level 3, while `none` keeps visual title text without heading
  semantics. A direct light-DOM heading retains its own native/ARIA level.
- `accessibleLabel: string = ''` (attribute `accessible-label`) — explicit accessible-only name;
  unlike `label`, it never renders visible text
- `heading?: string` — legacy visible-title fallback, after the `label` slot and `label` property;
  it has no effect when a direct light-DOM heading already supplies custom chrome
- `closable: boolean = true` (attribute `closable`, reflected) — renders the localized close (X)
  button. This true-default boolean parses `closable="false"`; removing the attribute also restores
  the default.
- `noHeader: boolean = false` (attribute `no-header`, reflected) — Shoelace's spelling
  (`sl-dialog`'s `no-header`), which suppresses the entire header row
- `withoutHeader: boolean = false` (attribute `without-header`, reflected) — **new in 8.0.0.**
  Web Awesome's spelling (`wa-dialog`'s `without-header`) for the same header suppression. Both
  names are current upstream spellings, both are read, and neither is deprecated or removable
- `withFooter: boolean = false` (attribute `with-footer`, reflected) — keeps the footer wrapper
  rendered as an SSR/hydration presence hint even before assigned slot content is observable
- `size: LyraSize = 'm'` (reflected) — `'2xs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'small' | 'medium' |
  'large'`. Panel-width tier, on the shared six-step ladder: `20rem` (`2xs`), `24rem` (`xs`), `28rem`
  (`s`/`small`), `32rem` (`m`/`medium`, unchanged from before this property existed), `38rem`
  (`l`/`large`), `48rem` (`xl`) — each value feeds `--lr-dialog-max-width`'s private default. An
  explicit `--lr-dialog-width`/`--lr-dialog-max-width` still wins over every tier.
- `lightDismiss: boolean = false` (attribute `light-dismiss`) — opt in to a backdrop click closing
  the dialog; Escape and explicit `close()`/`hide()` calls remain available. **Changed in 8.0.0:**
  this was previously spelled `no-light-dismiss`, an opt-_out_ whose default left backdrop dismissal
  on. The polarity now matches `wa-dialog` exactly, so a mechanical rename no longer flips what the
  markup does.
- `modal: LyraDialogModalController` (writable, property only) — `activateExternal()` temporarily
  yields focus/Escape ownership to a third-party modal; balanced `deactivateExternal()` resumes it
  without changing `open`

A plain host `aria-label` is the strongest naming override. It changes naming only: mapped title
chrome remains visible. The fallback order appears below.

**Methods:**

- `show(): Promise<void>` — opens the dialog and resolves after `lr-after-show`; no-op/veto returns
  an already-resolved promise
- `hide(): Promise<void>` — identical to `close('api')`, resolving after `lr-after-hide`
- `close(reason: DialogCloseReason = 'api'): Promise<void>` — closes the dialog, returns focus to
  whatever had it right before opening, and resolves after `lr-after-hide`.
  `DialogCloseReason = 'escape' | 'backdrop' | 'close-button' | 'api' | 'unmount' | string` —
  `'escape'`/`'backdrop'` are emitted by the dialog's own built-in dismiss triggers;
  `'close-button'` by the built-in header close button (rendered when `closable` is set); `'api'`
  covers `close()` with no argument, `hide()`, and `open = false`; `'unmount'` is emitted
  automatically if the dialog is removed from the DOM while still `open` by anything other than its
  own `close()` (a consumer's own cleanup code, a parent re-render that drops it); any other string
  is whatever a caller passes (e.g. a footer Cancel button calling `dlg.close('cancel')`, or
  `confirm()`'s own `'confirm'`/`'cancel'`).

**Events:**

- `lr-show` — cancelable pre-open veto
- `lr-after-show` — opening animation finished
- `lr-hide` — cancelable pre-close veto; detail is `{ source: Element }`, the host or built-in
  affordance that requested the transition
- `lr-after-hide` — closing animation finished
- `lr-initial-focus` — cancelable immediately before the first automatic focus movement. A
  CSS-hidden dialog defers it until rendered; reconnecting the same open activation does not repeat
  it.
- `lr-request-close` — cancelable request from a built-in affordance; detail source is
  `'close-button' | 'keyboard' | 'overlay'`. Veto stops the close lifecycle. Direct `close()` and
  `hide()` calls do not emit this request event.
- `lr-close` — cancelable, with `detail: DialogCloseReason`; emitted after `lr-hide`. The same name
  is already used by `<lr-tool-select-dialog>`, `<lr-tool-result-dialog>`, and
  `<lr-tool-approval-dialog>`, whose own docs describe an identical detail shape, so one listener
  covers all of them. A listener calling `preventDefault()` vetoes the close. Also fired (with
  reason `'unmount'`, non-cancelable there) when the dialog is removed from the DOM while still
  open. **But the name is not dialog-scoped.** Nine components in this library emit `lr-close`,
  several of them commonly nested *inside* a dialog: `<lr-callout>`, `<lr-tab>`/`<lr-tab-group>`,
  `<lr-command-palette>`, `<lr-document-viewer>`, `<lr-responsive-panel>`, and the three tool
  dialogs (`<lr-tool-select-dialog>`, `<lr-tool-result-dialog>`, `<lr-tool-approval-dialog>`).
  Library events bubble and are composed, so a listener bound directly on
  `<lr-dialog>` also receives a descendant's close — a closable callout or tab inside a dialog would
  otherwise dismiss the whole dialog. Guard on the target, the way `<lr-document-viewer>` already
  does internally:

  ```js
  dialog.addEventListener('lr-close', (event) => {
    if (event.target !== event.currentTarget) return; // a descendant's close, not this dialog's
    // ...
  });
  ```

The two `lr-after-*` events are never cancelable.

The open sequence is `lr-show` → `lr-initial-focus` (when focus would move) → `lr-after-show`; the
direct close sequence is `lr-hide` → `lr-close` → `lr-after-hide`.
A built-in dismissal prepends `lr-request-close`. **Both state pre-events fire _before_ the state changes**, so reading
`el.open` inside an `lr-show`/`lr-hide` handler returns the _old_ value — this is the polarity
`wa-show`/`wa-hide` already had, and the opposite of what Lyra 7.x's own `lr-show`/`lr-hide` did on
`lr-popover`/`lr-dropdown`. The `wa-*` → `lr-*` migration table treats the rename as mechanical, and
as of 8.0.0 that is finally true for these four names: `wa-show`/`wa-after-show`/`wa-hide`/
`wa-after-hide` map to `lr-show`/`lr-after-show`/`lr-hide`/`lr-after-hide` with matching timing and
matching cancelability. Code written against Lyra 7.x that read `el.open` in a handler, or assumed
the events were informational rather than vetoable, has to be re-read.

`lr-after-show`/`lr-after-hide` settle after the public registry animations `dialog.show` /
`dialog.hide` (panel) and `dialog.overlay.show` / `dialog.overlay.hide` (backdrop). Per-element
registrations win over page defaults; keyframes-only overrides retain the token-derived duration
and easing. Under `prefers-reduced-motion: reduce`, registry timing flattens to zero while the end
frame and lifecycle remain intact. Passing `null` skips native interpolation but still emits the
matching after-event before the method promise resolves. Because dialogs now animate on close too,
`lr-after-hide` is normally deferred by roughly one animation. A removal while open emits
`lr-hide`, `lr-close` (reason `'unmount'`) and `lr-after-hide` in that
order, none of them cancelable, since the element is already gone.

**Stacking and the top layer:** an open dialog is promoted into the browser **top layer** (via
`popover="manual"`), new in 8.0.0. That means it escapes every ancestor stacking context and every
ancestor `overflow` clip: a `transform`ed parent, an `isolation: isolate` wrapper or a
`z-index: 2147483647` sticky header can no longer render on top of it or crop it, which no `z-index`
value alone can guarantee. The `z-index` in the stylesheet remains only as the fallback for a user
agent without popover support, and `popover="manual"` is deliberate — light dismiss and Escape stay
this component's own contract rather than the user agent's, where an `auto` popover would close on
the user agent's terms instead. What a consumer sees: the host gains a `popover="manual"` attribute
while open (component-owned bookkeeping — don't set or remove it), any `z-index` you were fighting
with becomes irrelevant, and the panel is no longer clipped by an ancestor's `overflow: hidden`.
Beyond that, the dialog participates in the shared per-document overlay stack: only the topmost
overlay receives Escape, Tab trapping, or backdrop dismissal, while overlays beneath stay open until
the top one closes.

**Slots:** default (the dialog body), `label` (rich header content — an element, markup, anything;
rendered inside `[part="heading"]` and used as the panel's accessible name, winning over the
plain-string `label` and legacy `heading` properties), `header-actions` (extra header controls,
rendered in the header row _before_
the built-in close button), `footer` (action buttons, rendered in a bottom row, hidden entirely when
empty). The `label` and `header-actions` slots are new in 8.0.0.

**CSS parts:** `base`; `backdrop overlay`; `panel dialog`; `header`; `heading title label`;
`header-actions`; `close-button close-button__base`; `close-button__control`; `body`; `footer`.
Names grouped together are additive aliases on the same functional node, so a mapped
`::part(title)` rule styles the same visible title as Lyra's `::part(heading)`.
`close-button__control` is the composed `<lr-icon-button>`'s own native `<button>`: as of 16.0.0 the
close button IS an `<lr-icon-button>`, so `close-button`/`close-button__base` name that host and the
painted surface sits one boundary deeper. `<lr-drawer>` inherits this control and now registers
`<lr-icon-button>` itself.

**The body is keyboard-reachable while it overflows.** `[part="body"]` is the element that scrolls,
so it carries `tabindex="-1"` and joins the focus order **only while its content actually
overflows** — a dialog whose content is nothing but prose, a table, or a rendered document used to
be scrollable with a mouse and completely unreachable from the keyboard, because a scroll container
with no focusable child is not a stop of its own. A short body never becomes a gratuitous stop.

It takes focus like any other stop, so it styles like one: `::part(body):focus-visible` draws the
standard `--lr-focus-ring-*` ring, inset (`outline-offset` is negative) because the body is flush
with the panel edges, where an outset ring would be clipped or would collide with the header rule.
Restyle it through `::part(body)` as usual; do not remove the outline without replacing it.

It never steals initial focus from real content: an `[autofocus]` element wins, then the first
focusable control _inside_ the body, and the body itself is used only when there is nothing else to
focus. So a dialog full of form controls behaves exactly as before, and a dialog full of text is now
scrollable with the arrow keys, Page Up/Down and Home/End once Tab reaches it.

**Migrating a pre-16.0.0 `::part()` rule.** This component's icon-only action is a composed
`<lr-icon-button>`, so the part naming that action now names the composed child's HOST, which
paints nothing. A `border`, `background` or `border-radius` set on it is silently dead — only
`color` still appears to work, because it inherits, which makes such a rule look half-alive rather
than broken. Set `--lr-icon-button-background`/`-color`/`-border`/`-radius` (and their
`-hover`/`-active` variants) on this element or an ancestor instead: the composed control reads
those public tokens ahead of any default this component supplies. For SIZE use
`--lr-theme-icon-button-size`, not `--lr-icon-button-size` — every `LyraElement` re-declares the
latter on its own `:host`, so it never reaches a composed child (see `llms/tokens.md`).

**Border reaches the composed close control the same way background/color/radius do.** This
component paints no resting border of its own, so it relays no `--_lr-icon-button-border-default`
into the close control's private fallback tier — but that absence is not a gap. The public
`--lr-icon-button-border` (and its `-hover`/`-active` variants) is the FIRST arm of the token
chain, resolved by ordinary custom-property inheritance regardless of whether this component
relays a default for that same property, so setting it on this element or an ancestor reaches the
close control exactly as the background/color/radius tokens do. A component with no resting
border simply has no default to relay, which is different from border theming being broken. Size
remains the one exception that does not cross this way: use `--lr-theme-icon-button-size`, never
`--lr-icon-button-size`, as noted above.

**Themeable custom properties:** mapped aliases are `--backdrop-filter`, `--width`, `--spacing`,
`--header-spacing`, `--body-spacing`, `--footer-spacing`, `--show-duration`, and
`--hide-duration`. The individual region properties override `--spacing`; mapped properties in
turn fall back to the retained Lyra tokens: `--lr-dialog-overlay-color` (default
`var(--lr-color-overlay)` —
the backdrop scrim color), `--lr-dialog-backdrop-filter` (default `none` — a `backdrop-filter` on
the scrim, e.g. `blur(3px)`, for a frosted-glass treatment over the page behind it),
`--lr-dialog-width` (default `auto` — the panel shrink-wraps to content; set it for an assertive
width instead), `--lr-dialog-max-width` (default `var(--lr-dialog-width, var(--_lr-dialog-max-width))`
— the panel's max-inline-size cap, applied as
`min(var(--lr-dialog-max-width, var(--lr-dialog-width, var(--_lr-dialog-max-width))), 100%)`, where
the private `--_lr-dialog-max-width` is the `size` property's own tier value (`32rem` at the `m`
default, unchanged); when
`--lr-dialog-width` is set but `--lr-dialog-max-width` is left at its default, the cap falls back to
the requested width itself — not the tier default — so an assertive width isn't silently clipped;
the viewport is still a hard limit either way), `--lr-dialog-height` (default `auto` — the panel
shrink-wraps to content on the block axis, same as before this property existed; always capped at
`100%`, i.e. the viewport, like every other panel dimension). With it set, `[part="body"]`'s own
`flex: 1 1 auto` is what actually gives slotted content a definite, fillable block size:
`[part="header"]` and `[part="footer"]` keep their natural size and only `[part="body"]` grows or
shrinks into the remaining space, matching `--lr-dialog-width`'s pairing with `--lr-dialog-max-width`
on the other axis. `--lr-dialog-spacing` (default `var(--lr-space-l)` —
the padding inside `[part="body"]` and the _inline_ padding of the header and footer rows),
`--lr-dialog-spacing-block` (default `var(--lr-space-m)` — the _block_ padding of the header and
footer rows, which are tighter than the body by default), `--lr-dialog-panel-duration` (default
`var(--lr-duration-base)` — the panel's enter/exit animation duration) and
`--lr-dialog-backdrop-duration` (default `var(--lr-duration-fast)` — the backdrop's fade duration).
The panel paints from the **shared overlay-surface family** (16.0.0), not from the page-surface
tokens this entry previously named: `--lr-overlay-surface` (default `var(--lr-color-surface-overlay)`
— the panel fill), `--lr-overlay-border` (default `var(--lr-color-border)` — the panel edge and the
header's and footer's dividing rules), `--lr-overlay-radius` (default `var(--lr-radius)` — the panel
corner and the close button's) and `--lr-overlay-shadow-modal` (default `var(--lr-shadow-xl)` — the
panel's elevation). None is declared on `:host`, so one declaration on `:root` (or on any ancestor,
to scope it) retints the dialog together with every popup opened from it. The modal tier is a
separate name from `--lr-overlay-shadow-anchored`, which anchored popups read, so raising popups
never raises dialogs.

Otherwise the shared tokens the panel and its chrome read include the `--lr-size-20rem` /
`-24rem` / `-28rem` / `-32rem` / `-38rem` / `-48rem` width ladder behind `size`, `--lr-space-l`,
`--lr-space-m`, `--lr-space-s` and `--lr-space-xs` (spacing defaults), the `--lr-safe-area-*` insets
on the fixed frame, `--lr-color-overlay` (the scrim default), `--lr-color-text-quiet`,
`--lr-color-brand` and `--lr-color-brand-quiet` with `--lr-color-mix-partner`/`--lr-color-mix-active`
(the close button's foreground and its hover/pressed fills), `--lr-border-width-thin`,
`--lr-icon-button-size` (the close button's hit-area floor), `--lr-focus-ring-width`,
`--lr-focus-ring-color`, `--lr-focus-ring-offset`, `--lr-font-weight-semibold`,
`--lr-transition-fast`, and `--lr-layer-modal` behind `--lr-overlay-stack-index`. Earlier revisions
of this entry instead named `--lr-color-surface`, a bare `--lr-shadow` and `--lr-easing-standard`:
none of the three has ever been part of a declaration in `dialog.styles.ts`, and the panel's real
fill and elevation are the overlay-family tokens above, eased through `--lr-transition-fast` (itself
derived from `--lr-duration-fast` and `--lr-easing-standard`, which is where that last name came
from).

**Optional peer deps:** none.

```html
<lr-dialog id="dlg" heading-level="2" closable>
  <span slot="label">Delete item?</span>
  <button slot="header-actions" type="button">Help</button>
  <p>This cannot be undone.</p>
  <div slot="footer">
    <button id="cancel" type="button">Cancel</button>
    <button id="confirm" type="button">Delete</button>
  </div>
</lr-dialog>
<script type="module">
  import "@aceshooting/lyra-ui/components/overlays/dialog/dialog.js";

  const dlg = document.getElementById("dlg");
  // Listeners first: `lr-show` is emitted synchronously inside show().
  dlg.addEventListener("lr-show", () =>
    console.log("opening; el.open is still", dlg.open)
  );
  dlg.addEventListener("lr-after-show", () =>
    console.log("enter animation done")
  );
  dlg.addEventListener("lr-close", (e) =>
    console.log("closed:", e.detail)
  );
  dlg.addEventListener("lr-after-hide", () =>
    console.log("exit animation done")
  );
  document
    .getElementById("cancel")
    .addEventListener("click", () => dlg.close("cancel"));
  await dlg.show(); // identical state change to dlg.open = true; settles after lr-after-show
</script>
```

A dialog with no chrome of Lyra's own, animating faster and blurring the page behind it:

```html
<lr-dialog
  without-header
  accessible-label="Preview"
  light-dismiss
  style="--lr-dialog-backdrop-filter: blur(4px); --lr-dialog-panel-duration: 120ms;
         --lr-dialog-backdrop-duration: 80ms; --lr-dialog-spacing: 0"
>
  <img src="/poster.jpg" alt="Poster" />
</lr-dialog>
```

Accessible naming and visible title are separate. Naming precedence is: (1) host `aria-label`, by
attribute presence including an explicitly empty value, (2) `accessible-label`, (3) the copied text
of an unslotted direct light-DOM heading, then (4) the shadow-owned visible title wrapper.
Visible-title precedence is the rich `label` slot, then the mapped `label` property, then legacy
`heading`. The direct-heading case copies text because an IDREF cannot cross from the panel's shadow
tree to a light-DOM heading; the mapped title wrapper can use `aria-labelledby` because it lives in
the same shadow root. `no-header`/`without-header` removes the mapped title, so custom-chrome dialogs
should provide a direct heading, `accessible-label`, or host `aria-label`.

**Known gotchas:**

- `role="dialog"`/`aria-modal="true"` are only present on `[part="panel"]` while `open` is `true` —
  inspecting closed markup won't show them.
- An `[autofocus]` element anywhere in the slotted content takes initial focus instead of the first
  focusable element, including one inside a slotted custom element's own open shadow root — so
  `<lr-input autofocus>` behaves like `<input autofocus>`. With nothing marked, the first focusable
  element still wins, unchanged.
- `lr-initial-focus` is the veto point for Lyra's automatic focus move. It fires once per logical
  open, only when the rendered panel is ready to receive focus; canceling it does not disable the
  trap or later focus return.
- The host gains a `popover="manual"` attribute the first time it opens and keeps it from then on —
  only top-layer membership (`:popover-open`) tracks `open`, not the attribute — and carries
  `data-closing` for exactly as long as the exit animation runs (pointer events are dead for that
  window, so a dismissing dialog can't swallow a click meant for the page underneath). Both are
  component-owned bookkeeping — don't set or remove them.
- Heading detection observes child, subtree, and character-data changes, so mutating an
  already-slotted direct heading's text updates the copied panel `aria-label` live.
- Only _unslotted direct_ children are scanned for a heading — one nested several layers deep,
  inside a slotted custom element's own shadow root, or carrying a `slot` attribute, is left to the
  consumer to label explicitly via `label` or the `label` slot.
- A reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) resumes its
  shared overlay registration and re-acquires the scroll lock if `open` was still `true` across the
  move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
  `willUpdate()` alone wouldn't otherwise notice.
- Tab-trap focus order follows the default (body) slot, then the `footer` slot — the same order the
  flattened tree already tabs through — and is resolved shadow-piercingly, so a slotted custom
  element's real focusable target inside its own shadow root is found even though the host tag
  itself isn't a native focusable element.

### `confirm()`

A drop-in async replacement for `window.confirm()`, built on `<lr-dialog>`.

```ts
import { confirm } from "@aceshooting/lyra-ui/components/overlays/dialog/confirm.js";

const ok = await confirm({
  title: "Delete conversation?",
  description: "This cannot be undone.",
  confirmLabel: "Delete",
  variant: "danger",
});
if (ok) deleteConversation();
```

`confirm(options: ConfirmOptions): Promise<boolean>` where
`ConfirmOptions = { title: string; description?: string; confirmLabel?: string /* = 'Confirm' */; cancelLabel?: string /* = 'Cancel' */; variant?: 'neutral' | 'danger' /* = 'neutral' */ }`.

Resolves `true` only when the confirm button is pressed — Escape, a backdrop click, and the cancel
button all resolve `false`. It sets `lightDismiss = true` on its transient dialog explicitly, so the
backdrop-click branch survives 8.0.0's flip of that property's own default to `false`. Mounts a
transient `<lr-dialog>` on `document.body` for the duration
of the call and removes it once settled, rather than reusing a persistent page-level region
(contrast `lr-toast`'s `toaster.ts`). Concurrent calls are distinct dialogs in the shared overlay
stack, each tied to its own returned promise. `title` becomes a direct light-DOM `<h2>`, which per `<lr-dialog>`'s
own heading-detection also drives the dialog's accessible name; `description`, if provided, becomes
a direct light-DOM `<p>`. `variant: 'danger'` fills the confirm button with `--lr-color-danger`
instead of `--lr-color-brand`, for destructive actions. The deprecated `tone` option that preceded
`variant` was removed in 10.0.0. Confirm/cancel actions deliberately use native
inline-styled `<button>` elements so this helper does not register or import the broader button
component; every color value is still a `--lr-*` token reference, never a raw literal. They carry the same interaction
states as every other control in the library: a hover/pressed fill mixed toward
`--lr-color-mix-partner` by `--lr-color-mix-hover`/`--lr-color-mix-active`, and a
`--lr-focus-ring-width`/`--lr-focus-ring-color`/`--lr-focus-ring-offset` `:focus-visible` ring. An
inline `style` attribute cannot express a pseudo-class, so those rules ship in a small `<style>`
element mounted inside the transient dialog (and removed with it), targeting the buttons through
their `data-lr-confirm-action` attribute.

**Known gotchas:**

- Every dismissal path (confirm button, cancel button, Escape, backdrop click) funnels through
  `<lr-dialog>`'s own `close()`/`lr-close` event, so there is exactly one place that
  resolves the promise and tears the dialog down — a consumer never needs to (and shouldn't) call
  `.remove()` itself. Because the close event is cancelable, `confirm()` waits through the full
  dispatch and remains pending/mounted when a listener calls `preventDefault()`.
- The neutral confirm button pairs `--lr-color-on-brand` with `--lr-color-brand`; the danger
  variant pairs `--lr-color-on-danger` with `--lr-color-danger`. Each of those resolves through its
  variant's row of the semantic grid (`--lr-color-<variant>-fill-loud` /
  `--lr-color-<variant>-on-loud`), which in turn reads the matching `--lr-theme-color-*` hook and
  falls back to the shared neutral ramp — so retheming the grid retints the confirm button with no
  `::part()` rule, in light and dark alike.
- Importing `confirm` is side-effect free and does not register `<lr-dialog>`. Invoking the helper
  synchronously registers exactly the dialog class it creates before mounting the transient
  element; consumers do not need a separate registration import for helper-created dialogs.

---
