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

# `lr-app-rail`

- **Import** `import '@aceshooting/lyra-ui/components/lr-app-rail.js';` (stable tag alias; registers the tag)
- **Class** `LyraAppRail`, also available unregistered from `@aceshooting/lyra-ui/components/layout/app-rail/app-rail.class.js`
- **Family** `components/layout/` — 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** 11 parts, 29 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Documented with** `lr-app-rail-item`, `lr-app-rail-group` (same section below)
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-app-rail`

A responsive navigation rail that adapts across three presentations as the _viewport_ narrows (not
this element's own inline size): `'full'` (nav items show icon + label, inline), `'icon-only'` (a
narrower inline rail, icons only), and `'mobile'` (hidden behind a toggle button; opening it shows a
focus-trapped floating overlay over the page). First-party invention (no `wa-*`/`sl-*` counterpart).
Breakpoints are viewport-width `matchMedia()` queries against `icon-only-breakpoint`/
`mobile-breakpoint`, not a `ResizeObserver` on this element — presentation tracks the actual device/
window width the way a native OS shell's navigation does, not however much horizontal space a
particular layout happens to give it. `[part="base"]` (the inline `'full'`/`'icon-only'`
presentation) and `[part="panel"]` (the mobile overlay) are the _same_ element promoted in place
across modes (mirrors `<lr-widget>`'s fullscreen mode) — never both at once, and slotted nav
content is never duplicated.

Opting in to `resizable` adds a continuously draggable width for the `'full'` state: a
`[part="resizer"]` handle (pointer-drag and Left/Right-arrow keyboard stepping, RTL-aware) clamped to
`[minRailWidthPx, maxRailWidthPx]`. Set `storageKey` (attribute `storage-key`) to persist the fields
selected by `persist` to `localStorage` under `lr-app-rail:${storageKey}` and restore them on the
next mount (mirrors `lr-multi-split`'s `storage-key`; effective `mode` is breakpoint-derived and never
persisted). The backward-compatible allowlist is `open width`; use
`persist="width preferred-mode"` for durable layout preference without restoring the transient
mobile overlay. Without a `storageKey` there is no persistence — listen for `lr-rail-resize` and
persist its committed `widthPx` yourself. Listen for the preceding cancelable
`lr-rail-resize-request` event when a host needs to veto a proposed width.
`preferredMode` separately lets a host manually prefer `'full'`/`'icon-only'` for the non-mobile
breakpoint axis (e.g. a user's own collapse toggle) while `mobile-breakpoint` continues to be tracked
automatically regardless — it's only consulted while `forceMode` is `'auto'` or unset; an explicit
`forceMode` value takes full priority. A `preferredMode` restored from `localStorage` on mount is
observable the same way a live change is: it fires `lr-mode-change` too (see **Events** below),
letting a consumer that syncs app chrome to the rail's mode pick up the restored value on load.

**Properties:**

- `mode: LyraAppRailMode` (custom accessor, reflected, read-only as of 9.0.0) — always resolves to
  one of the three real modes (`'full'|'icon-only'|'mobile'`), never `'auto'`; assigning it now
  throws (`el.mode = 'icon-only'` -> TypeError).
- `forceMode?: 'full' | 'icon-only' | 'auto'` (attribute `force-mode`, reflected) — replaces `mode`'s
  former write side as of 9.0.0. Assigning `'full'`/`'icon-only'` pins that mode and stops the
  element responding to breakpoint changes; assigning `'auto'` (or leaving it unset) releases the
  pin and resumes automatic viewport tracking. `'mobile'` can never be force-pinned here — the
  mobile breakpoint is always tracked automatically regardless, mirroring `preferredMode`'s scope
  below; widen `mobile-breakpoint` for a guaranteed-mobile state instead. Settable via the
  `force-mode` attribute too (`force-mode="icon-only"`, `force-mode="auto"`).
- `iconOnlyBreakpoint: string = '960px'` (attribute `icon-only-breakpoint`) — any valid CSS length,
  used directly in a `(max-width: ...)` media query; below it the rail switches from `'full'` to
  `'icon-only'`.
- `mobileBreakpoint: string = '600px'` (attribute `mobile-breakpoint`) — same mechanism; below it the
  rail switches from `'icon-only'` to `'mobile'`. Should be smaller than `iconOnlyBreakpoint` to
  produce all three states as the viewport narrows.
- `open: boolean = false` (reflected) — whether the mobile floating overlay is shown. Only meaningful
  while `mode` is `'mobile'`; leaving mobile mode closes it so a later mobile transition cannot
  restore a stale modal. Set this directly, or use the built-in toggle button — there is no separate
  `show()`/`hide()` pair.
- `label?: string` — optional accessible name for the rail's navigation landmark and mobile dialog.
  Every nonempty supplied string is honored literally; only absence/empty uses the localized
  navigation fallback. A host-level `aria-label` attribute (including an explicit empty value)
  takes precedence.
- `preferredMode?: 'full' | 'icon-only' | null` (attribute `preferred-mode`) — manually prefers
  `'full'` or `'icon-only'` for the non-mobile breakpoint axis, while `mobile-breakpoint` continues to
  be tracked automatically regardless — e.g. a user's manual collapse toggle that should still yield
  to a genuinely too-narrow-for-any-inline-rail viewport. Only consulted while `forceMode` is
  `'auto'` or unset (see above); an explicit `forceMode` value takes full priority.
  Unset (the default, `null`) reproduces the original breakpoint-only behavior exactly.
- `collapsible: boolean = false` (reflected) — opts in a desktop collapse control rendered inside
  `[part="header"]`. It flips the rail between its `'full'` and `'icon-only'` presentations by
  writing `preferredMode`, so the `mobile-breakpoint` keeps being tracked automatically and a
  genuinely too-narrow viewport still wins. The control is not rendered at all while `mode` is
  `'mobile'`, and `[part="header"]`'s layout is unchanged when this is unset. The collapse survives
  a reload only when `storage-key` is set AND `persist` includes `preferred-mode` — the default
  `persist` is `open width`, which does not. Pair them: `persist="width preferred-mode"`. Either
  route announces itself through the existing `lr-mode-change` event; there is no new event.
- `hideToggle: boolean = false` (reflected, attribute `hide-toggle`) — suppresses the built-in mobile
  `[part='toggle']` hamburger/OPEN button, for a consumer that already owns an external mobile-menu
  trigger wired to this rail's own `open` property (pair it with `trigger`/`for` below so focus
  still returns to that external trigger on close). `false` (the default) reproduces the exact
  existing output. This does not remove the button once the overlay is open: at that point it has
  been reparented inside the trapped `[part="panel"]` (see the CSS parts entry below) as the
  panel's only in-panel dismiss control, and hiding it there too would leave the open panel with no
  in-panel way to close it at all besides Escape/backdrop.
- `trigger: HTMLElement | null = null` (attribute: false) — direct reference to an external element
  that opens this rail's mobile overlay (e.g. an application-chrome hamburger button used together
  with `hideToggle`). When set (or resolved through `for`), closing the overlay by any path —
  Escape, backdrop click, a nav-item click, or the built-in toggle itself — returns focus to it, the
  same guarantee the built-in toggle's own click already gets. Needed because a consumer's own
  JS-driven `open = true` never focuses anything, and even a real click does not reliably focus its
  target in every browser. Resolved once when the overlay opens; reassigning afterward changes the
  return target for the remainder of that overlay's open lifetime. Read alongside `for`; this direct
  reference wins when both resolve to different elements. Unset (the default, `null`) reproduces the
  exact existing behavior: only the built-in toggle's own click supplies a return target, for that
  interaction alone. The resolved trigger also receives `aria-expanded` (rendered in both states)
  and `aria-controls` pointing at the rail's own panel, so an external control announces the
  overlay's state across the shadow boundary. Because a light-DOM element cannot hold a raw
  reference into another element's shadow tree, engines resolve `aria-controls` to the
  `<lr-app-rail>` host itself; either resolution is correct. The association applies while `mode` is
  `'mobile'` and is released when the rail leaves that mode or disconnects, and it tracks live —
  reassigning `trigger` moves the state to the new element.
- `for: string = ''` — id of an external element that opens this rail's mobile overlay, the
  label/`htmlFor`-style alternative to assigning `trigger` directly (mirrors `<lr-page-rail>`'s
  `for`). Resolved against this element's own root (shadow root or document) when the overlay opens.
  Ignored once `trigger` is itself set.
- `resizable: boolean = false` (reflected) — opts a continuously draggable width in for the `'full'`
  state, exposing a `[part='resizer']` handle clamped to `[minRailWidthPx, maxRailWidthPx]`. `false`
  (the default) renders no resizer and leaves the fixed-width `--lr-app-rail-width` CSS token
  exactly as before this property existed.
- `railWidthPx?: number` (attribute `rail-width-px`) — the rail's current width in px while
  `resizable`; settable/gettable directly. Unset defers to `--lr-app-rail-width`'s own resolved
  width.
- `storageKey?: string` (attribute `storage-key`) — when set, persists the fields selected by
  `persist` to `localStorage` under `lr-app-rail:${storageKey}` and restores them on the next
  mount. Each field is restored only when the consumer has not assigned it on that same mount: an
  `open`/`rail-width-px`/`preferred-mode` attribute, or a `.open=${false}`-style binding, wins over
  stored state, and a restored `open` fires no `lr-toggle`. Effective `mode` is breakpoint-derived
  and never persisted. Unset means no persistence.
- `persist: string = 'open width'` — whitespace-separated field allowlist used with `storageKey`.
  Valid `LyraAppRailPersistField` tokens are `open`, `width` (`railWidthPx`), and `preferred-mode`
  (`preferredMode`). The default preserves the existing open+width behavior. Use
  `persist="width preferred-mode"` when overlay-open state is controlled or should stay
  session-only.
- `minRailWidthPx: number = 190` (attribute `min-rail-width-px`) — minimum `railWidthPx` a
  drag/keyboard resize can reach.
- `maxRailWidthPx: number = 440` (attribute `max-rail-width-px`) — maximum `railWidthPx` a
  drag/keyboard resize can reach.
- `dragging: boolean = false` (reflected, read-only as of 9.0.0) — `true` for the duration of an
  active pointer-driven resize drag (not a keyboard step); reflected so a consumer (or this
  component's own styles) can suppress `[part='base']`'s `transition: inline-size` during the drag,
  which otherwise visibly "chases" the pointer instead of tracking it 1:1. This component always
  owned every drag transition itself; assigning it now throws (`el.dragging = true` -> TypeError).

Also settable as a plain `aria-label` attribute (not a reactive property): overrides the computed
`label`/localized-default accessible name on both the navigation landmark and the mobile dialog
role, matching `<lr-date-input>`'s `accessibleLabel`.

**Methods:** `toggleCollapse(): void` performs the same `'full'`/`'icon-only'` flip `collapsible`'s
built-in control does, for a consumer rendering its own control (app chrome, a command palette, a
keyboard shortcut). A no-op while `mode` is `'mobile'`. While `forceMode` pins the mode the
preference is still recorded and takes effect once the pin is released.

**Events:** `lr-mode-change` (`detail: LyraAppRailModeChangeDetail` = `{ mode: LyraAppRailMode }`; the
effective mode changed, whether from a breakpoint crossing, a `forceMode` assignment, or a
persisted `preferred-mode` restored on mount (`storage-key` + `persist="preferred-mode"`) — the
restored-on-mount case fires once, from the first `updated()` after that mount's render and
attribute reflection have both landed, rather than synchronously during the mount itself; it is
not fired for a redundant reassignment to the mode already in effect, nor when no preferred mode
was persisted), `lr-toggle`
(`detail: LyraAppRailToggleDetail` = `{ open: boolean }`; the mobile overlay is opening or closing — via
the built-in toggle button, Escape, a backdrop click, a nav-item click while open, or a
breakpoint/forced mode change leaving `'mobile'` while open — not fired when a consumer sets `open`
directly. Cancelable for every trigger except the forced mode-change close, which always applies —
vetoing that one would leave `open` stuck `true` in a mode where it's meaningless; call
`preventDefault()` to keep the overlay as it is for the other triggers),
`lr-rail-resize-request` (`detail: LyraAppRailResizeDetail` = `{ widthPx: number }`; a cancelable
proposed width from drag or keyboard stepping, emitted before the component assigns
`railWidthPx` — call `preventDefault()` to keep the current width. A synchronous request listener
that disables resizing, leaves full mode, or disconnects the rail also cancels the proposal,
preserving any width the listener assigned and publishing no accepted resize. Assigning only
`railWidthPx` does not veto the proposal. It is not fired when a consumer sets `railWidthPx` directly), and `lr-rail-resize` (`detail: LyraAppRailResizeDetail` =
`{ widthPx: number }`; non-cancelable committed width, emitted immediately for a genuine keyboard
step and once at pointerup for a genuine drag. Clamped/no-op steps, canceled/lost gestures, and
consumer property writes emit no committed event).

**Slots:** default (nav items — generic slotted content, e.g. `<a>`/`<button>` elements the consumer
builds with its own icon+label structure; clicking anywhere in this slot closes the mobile overlay if
open), `header` (logo/brand content, shown above the nav items in every mode), `footer` (a trailing
user/settings trigger, shown below the nav items).

**CSS parts:** `base`, `header`, `nav`, `footer`, `toggle` (hidden via CSS outside `'mobile'` mode, or
-- while it is not also serving as the panel's only in-panel dismiss control -- via `hideToggle`;
reparented to be the first child of `[part="panel"]` for exactly as long as the mobile overlay is
open, so the shared focus trap, scoped to the panel alone, can reach it and Tab cycles through it
like `<lr-dialog>`'s in-panel close button, then moved back to its resting position, a sibling
immediately ahead of `[part="panel"]`, once closed; the same button element is reused throughout,
never destroyed/recreated, so a reference captured before opening remains valid after closing, and
it renders as its own reserved row ahead of the `header` slot rather than an absolute overlay on
top of it, so a wide/slotted header is never obscured), `backdrop`, `panel` (`base`/`panel` are mutually exclusive on the same
underlying element — see above), `resizer` (the `resizable` opt-in's drag handle, only rendered while
`resizable` and `mode` is `'full'`; its hit target is `--lr-icon-button-size`-wide), `resizer-track`
(the slim 3px visible drag line centered inside that hit target, tinted `--lr-color-brand` on hover),
`collapse-toggle` (the opt-in desktop collapse control, rendered inside `[part="header"]` only while
`collapsible` is set and `mode` is not `'mobile'`; it renders `aria-expanded` in both states, points
`aria-controls` at `[part="nav"]` — the item list whose presentation actually changes, never the
containing `[part="base"]`/`[part="panel"]` — and takes a localized name from the
`appRailCollapse`/`appRailExpand` keys) and `collapse-icon` (the chevron wrapper, mirrored by its own
`transform` under RTL). Collapsing to `'icon-only'` removes nothing from the accessibility tree — it
clips each item's `label`/`meta` visually — so `aria-expanded` reports which of the two
presentations is on screen, for magnifier and braille users, rather than announcing hidden content.

**Themeable custom properties:** `--lr-app-rail-width` (default `15rem` — the inline rail width in
`'full'` mode), `--lr-app-rail-icon-width` (default `4rem` — the inline rail width in `'icon-only'`
mode), `--lr-app-rail-mobile-width` (default `18rem`, capped at `85vw` — the mobile overlay panel
width), `--lr-app-rail-overlay-color` (default `var(--lr-color-overlay)` — the mobile backdrop scrim
color; component-specific since no shared token exists), `--lr-app-rail-panel-inset-block-start`
(default `0`, applied to both `[part="panel"]` and `[part="backdrop"]` — raise it to leave room for
a fixed app bar/status area above the drawer instead of the panel/scrim starting flush with the
viewport top), `--lr-app-rail-panel-radius` (default `0` — uniform corner radius of `[part="panel"]`;
pairs naturally with a nonzero `--lr-app-rail-panel-inset-block-start`, which exposes the panel's top
corners). Four direction-aware per-corner tokens each default to `--lr-app-rail-panel-radius`, so
setting only the uniform token still rounds all four corners exactly as before:
`--lr-app-rail-panel-radius-start-start` and `--lr-app-rail-panel-radius-end-start` (logical
`border-start-start-radius`/`border-end-start-radius` — the two corners at the panel's own flush
inline-start edge, since the drawer always sits flush against `inset-inline-start: 0`) and
`--lr-app-rail-panel-radius-start-end`/`--lr-app-rail-panel-radius-end-end` (logical
`border-start-end-radius`/`border-end-end-radius` — the two corners away from that flush edge, the
pair a flush-against-one-edge drawer typically rounds). All four are logical, so which physical
corner each one paints swaps under `dir="rtl"` with no second consumer rule.
`--lr-app-rail-panel-overflow-block` (default `auto`) and
`--lr-app-rail-panel-overflow-inline` (default `clip`) — `[part="panel"]`'s logical overflow axes;
either non-`visible` value clips a `position: fixed` popup opened by a slotted/nav-item control
(e.g. a slotted `<lr-select>`/`<lr-menu>`) whenever its rendered box extends past the panel,
regardless of that popup's own containing block. Per the CSS overflow spec, a lone `visible` axis
paired with a non-`visible` other axis computes as `auto` instead (still clipping) — set **both**
tokens to `visible` together to actually stop the clipping, accepting that wide header/footer
content can then scroll/bleed both ways. `--lr-app-rail-background` (default
`var(--lr-color-surface)` — `[part="base"]`'s background, the docked non-overlay presentation) and
`--lr-app-rail-panel-background` (default `var(--lr-color-surface-overlay)` — `[part="panel"]`'s
background, the mobile overlay presentation; kept separate from `--lr-app-rail-background`/
`--lr-app-rail-overlay-color` since the panel is deliberately themed as a modal surface, not the
docked rail chrome). `--lr-app-rail-header-padding` and `--lr-app-rail-footer-padding` (both default
`var(--lr-space-m)`) retune `[part="header"]`/`[part="footer"]`'s padding independently.
`--lr-app-rail-header-min-block-size` (default `auto`, the property's own initial value, so unset
reproduces today's exact height) reserves a minimum height for `[part="header"]`, for content that
mounts or resizes asynchronously. `--lr-app-rail-nav-padding` and `--lr-app-rail-nav-gap` (default
`var(--lr-space-s)`/`var(--lr-space-xs)`, the values this rule hard-coded before either token
existed) retune `[part="nav"]`'s own padding and inter-item gap — the rail's vertical rhythm,
previously reachable only through `::part(nav)`. Plus shared
tokens (`--lr-color-border`,
`--lr-color-surface`, `--lr-color-text`, `--lr-color-brand`, `--lr-color-brand-quiet`,
`--lr-space-*`, `--lr-radius`, `--lr-shadow`, `--lr-icon-button-size`,
`--lr-focus-ring-*`, `--lr-transition-base`). `resizable`'s width is driven entirely by
`railWidthPx`'s inline `inline-size` style rather than a new custom property.
The mobile toggle's hover/pressed background and foreground are independently inheritable through
`--lr-app-rail-toggle-hover-bg`, `--lr-app-rail-toggle-hover-color`,
`--lr-app-rail-toggle-active-bg`, and `--lr-app-rail-toggle-active-color`. `[part="collapse-toggle"]`
has the matching set: `--lr-app-rail-collapse-toggle-hover-bg` (default
`var(--lr-color-brand-quiet)`), `--lr-app-rail-collapse-toggle-hover-color` (default
`var(--lr-color-brand)`), `--lr-app-rail-collapse-toggle-active-bg` (no default) and
`--lr-app-rail-collapse-toggle-active-color` (default `var(--lr-color-brand)`). The resizer track uses
`--lr-app-rail-resizer-hover-bg` and `--lr-app-rail-resizer-active-bg`. Each hook is an inline
fallback at its exact state rule and preserves the previous brand or active-mix value when unset.

**Optional peer deps:** none.

```html
<lr-app-rail
  label="Main navigation"
  icon-only-breakpoint="960px"
  mobile-breakpoint="600px"
  resizable
>
  <span slot="header"><img src="/logo.svg" alt="Acme" /></span>
  <a href="/inbox" aria-label="Inbox"
    ><svg aria-hidden="true">...</svg><span>Inbox</span></a
  >
  <a href="/settings" aria-label="Settings"
    ><svg aria-hidden="true">...</svg><span>Settings</span></a
  >
  <span slot="footer"><button>Profile</button></span>
</lr-app-rail>
<script type="module">
  const rail = document.querySelector("lr-app-rail");
  rail.addEventListener("lr-rail-resize-request", (e) => {
    if (e.detail.widthPx > 360) e.preventDefault();
  });
  rail.addEventListener("lr-rail-resize", (e) =>
    localStorage.setItem("railWidthPx", String(e.detail.widthPx))
  );
</script>
```

```ts
rail.forceMode = "icon-only"; // force a presentation regardless of viewport width
rail.forceMode = "auto"; // release the force, resume live breakpoint tracking
```

The package root also exports a pure `computeAppRailMode(iconOnlyMatches: boolean, mobileMatches:
boolean, preferredMode?: 'full' | 'icon-only' | null): LyraAppRailMode` resolver (plus the
`LyraAppRailMode`/`LyraAppRailModeInput`/`LyraAppRailPreferredMode`/`LyraAppRailPersistField`/
`LyraAppRailModeChangeDetail`/`LyraAppRailToggleDetail`/`LyraAppRailResizeDetail` types) — the same
logic the element's internal `matchMedia` listeners call, exposed standalone so a
consumer can compute or unit-test the same resolution without a real browser window. `mobileMatches`
wins over everything else when true (the viewport is narrower than both breakpoints at once);
otherwise `preferredMode` (when set) wins over `iconOnlyMatches`.

The mobile state keeps its own panel template rather than nesting `<lr-dialog>`, while its modal
behavior participates in the shared overlay stack. It is a plain `<div>` with an explicit
`role="navigation"` (swapping to `role="dialog"` while the overlay is open) rather than a literal
`<nav>` tag, since a
`<nav>`'s implicit role can't be overridden to `role="dialog"` without an `aria-allowed-role`
violation. In `'icon-only'` mode, slotted nav items lose their visible text label — give each one a
real accessible name (`aria-label`, visually hidden text, or `title`) regardless, since this
component only lays out whatever is slotted and can't inspect or fix up a consumer's own markup.

**Known gotchas:**

- `mode` is the readonly `LyraAppRailMode` result. `forceMode` accepts the non-mobile
  `LyraAppRailPreferredMode` values plus the `'auto'` release sentinel, so whether the rail is
  pinned or tracking the viewport remains directly observable.
- reassigning `icon-only-breakpoint`/`mobile-breakpoint` after first render tears down and rebuilds
  the `matchMedia` listeners, but does not itself clear `forceMode` — if a consumer set
  `forceMode = 'icon-only'`, changing the breakpoints won't resume auto-tracking until
  `forceMode = 'auto'` is set explicitly.
- leaving `'mobile'` mode while `open` (via a breakpoint crossing or a `forceMode` reassignment)
  auto-closes the overlay through the same path as the toggle button, so `lr-toggle` still fires
  and the scroll lock/focus trap still release normally — a consumer listening only for explicit
  toggle-button clicks would miss this closure.
- the mobile panel is also given `inert` whenever `mode === 'mobile'` and `open` is `false` — it's
  removed from the accessibility tree and tab order via `inert` at the same time it's hidden visually
  via `transform: translateX(-100%)`, both applied simultaneously rather than one implying the other.
- the offscreen slide direction for the mobile panel is flipped for RTL via a `:dir(rtl)` CSS
  selector (`translateX(100%)`), not through the shared `internal/rtl.ts` JS helper used for pointer/
  keyboard math elsewhere in this library — a physical `transform` isn't expressible with logical
  properties, so this one case is handled purely in CSS.
- 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 the overlay was still active
  across the move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in
  between, so `willUpdate()` alone wouldn't otherwise notice.
- `resizable`'s drag handle is pointer-only (`pointerdown`/`pointermove`/`pointerup`/
  `pointercancel`/`lostpointercapture`) plus discrete Left/Right-arrow keyboard stepping (8px per
  press, RTL-aware) — there's no dedicated touch gesture beyond what Pointer Events already unify.
- reassigning `railWidthPx` while `resizable` is unset has no visible effect on the rendered width —
  the fixed-width `--lr-app-rail-width` token still governs `'full'`-mode width until `resizable`
  is also set.
- reassigning `icon-only-breakpoint`/`mobile-breakpoint`/`preferredMode` does not itself un-force a
  previously-forced `mode` — same caveat as above, `preferredMode` is only consulted while `mode`
  isn't force-pinned.
- the built-in toggle physically moves in the DOM: it is a sibling immediately ahead of
  `[part="base"]`/`[part="panel"]` while closed (or outside mobile mode), and the first child of
  `[part="panel"]` while the mobile overlay is open. This is a real reparent via `insertBefore`
  (not a template-conditional recreate), so an event listener or a captured element reference
  stays valid across the transition — a MutationObserver watching a specific fixed container would
  still need to account for the move.
- opening the mobile overlay moves initial focus to the first focusable nav item (or the panel
  itself when nothing in the slotted content is focusable), never to the toggle — even now that
  the toggle lives inside the panel as its structurally-first child. Tab still reaches it as part
  of the trap's normal cycle.

### `lr-app-rail-item`

An explicit navigation item for `<lr-app-rail>`. It renders an accessible link when `href` is
set and enabled, otherwise a button; the rail can add its `icon-only` presentation state without
removing the label from the accessibility tree.

**Properties:**

- `href: string = ''` — optional destination. Without it, the item renders as a button.
- `target: string = ''` — optional link target.
- `disabled: boolean = false` (reflected) — prevents activation while retaining the item in the rail.
- `current: boolean = false` (reflected) — marks this as the destination for the current page/view;
  reflects `aria-current="page"` on `[part='base']` and drives the current visual treatment. The rail
  has no built-in routing, so the consumer sets this per item (e.g. by comparing `href` against the
  current location). `active`, a deprecated alias in both property and attribute form, was removed
  in 16.0.0 (available since 11.2.0; eligible for removal from 13.0.0) — use `current`.
- `tooltip: boolean = false` (reflected) — opt-in hover/focus flyout (`[part='tooltip']`) showing
  this item's label text while the rail's `icon-only` mode (set externally by the parent
  `<lr-app-rail>` as the viewport narrows) hides it from view. No effect outside icon-only mode,
  since the label is already visible there. `false` (the default) reproduces the exact existing
  output.
- `expanded: boolean = false` (reflected) — whether this item's own `children` are shown. `false`
  reproduces exactly what an item without this property rendered before this feature existed.
  Driven through the same request/commit pair as `<lr-app-rail-group>`'s `open`, see Events below.

A host `aria-label` is copied to the rendered native link or button by attribute presence,
including an explicitly empty value; without it, the default slot supplies the native name. The
same precedence supplies the tooltip text when that opt-in flyout is visible, and the disclosure's
interpolated `{label}` (see Events below).

**Events:** `lr-toggle-request` — cancelable, emitted before `expanded` changes from the built-in
disclosure (`detail: { open }` — the field is named `open`, matching `<lr-app-rail-group>`'s
identical event name and detail shape exactly). Call `preventDefault()` to keep the current state,
or assign `expanded` from the listener to resolve it yourself; a write during the dispatch
suppresses the default commit even when it assigns the value the property already held. Not
emitted for a direct `expanded` write. `lr-toggle` — non-cancelable, emitted after `expanded` is
written, never for a vetoed or listener-resolved request (`detail: { open }`).

**Methods:** `click(): void` activates the internal native link or button; it is a no-op while
`disabled`.

If an `href`/`disabled` update replaces a focused link or button, focus follows an available native
replacement. When that replacement is disabled or inert, focus returns to the available element
that led into the item, or to the stable owning rail surface when no return target exists. A newer
external focus move is always preserved, and this repair dispatches no activation event.

**Slots:** default (the visible label), `icon` (the leading decorative icon, always hidden from
assistive technology and inert across its flattened subtree; the default slot or host `aria-label`
names the native control, which remains the sole action).

- `meta` slot — secondary trailing text (an unread count, a keyboard shortcut). Rendered as a
  SIBLING of the item's own link/button, so its text is not part of the item's accessible name and a
  pointer landing on it does not activate the item. Visually clipped in `icon-only` mode exactly as
  the label is, staying available to assistive technology.
- `end` slot — trailing controls or adornments (an overflow-menu trigger, a status badge). Also a
  sibling of the link/button — the shape `<lr-details>` uses for `header-actions` — so a slotted
  control keeps its own click, keyboard activation and focus order instead of being swallowed.
  Unlike `meta` it stays visible in `icon-only` mode, where it shares the narrow rail's width with
  the icon.
- `children` slot — nested `<lr-app-rail-item>`s disclosed beneath this item (the
  treeitem-with-link pattern: the row itself navigates, a separate disclosure expands its own
  child rows). Slotting anything into it grows a built-in `[part="toggle"]` disclosure button as a
  SIBLING of the item's own link/button, never nested inside it, so the link keeps navigating on
  its own and the disclosure keeps toggling on its own — clicking one never triggers the other.
  Leaving `children` empty renders neither the disclosure nor `[part="children"]` at all: an item
  authored without any `children` content renders byte-identically to one authored before this
  slot existed. The disclosure carries `aria-expanded` (both states) and `aria-controls` pointing
  at `[part="children"]`'s id, and a localized accessible name interpolating this item's own label
  (`Expand {label}`/`Collapse {label}` in the default locale — no literal fallback, so a
  `registerLyraLocale()` translation or a `.strings` override always reaches it). `<lr-app-rail-group>`
  cannot express this pattern: its collapsible heading *is* the toggle, so a navigable link cannot
  live inside it without nesting an interactive element inside a button.

  `icon-only` forwards from this item onto every `<lr-app-rail-item>` it directly owns through
  `children` — including ones appended later — exactly how `<lr-app-rail-group>` forwards onto the
  items and nested groups it owns. The disclosure itself never changes shape between
  presentations: it is always a fixed icon-button-sized square beside `[part="base"]`, reusing the
  same hover/active/focus tokens as the link/button (`--lr-app-rail-item-hover-bg` etc.) rather than
  a second disclosure-only set. There is no ancestor-current treatment — `<lr-app-rail-group>` has
  no equivalent concept for a group containing the current item, so none is invented here either; a
  current descendant stays perceivable only through its own `current` property.

Both wrappers (`[part="meta"]`, `[part="end"]`) are hidden while empty, so an item using neither
renders exactly as before. Note that while the mobile overlay is open, a click anywhere in the
rail's default slot closes it — including a click on an `end` control; that is the rail's documented
nav-slot behaviour, not new to these slots.

**CSS parts:** `base`, `icon`, `label`, `current-indicator` (a decorative inline indicator rendered
only while the item is `current`/`aria-current="page"`, mirroring `<lr-conversation-item>`'s
shipped `active-indicator` part — suppressed by default while `icon-only`, see the current-ring
tokens below), `tooltip` (the hover/focus label flyout, only rendered while `tooltip` is set, the
item is `icon-only`, and it is hovered or focused), `meta` (the wrapper around the `meta` slot,
hidden while empty), `end` (the wrapper around the `end` slot, hidden while empty), `toggle` (the
`children` disclosure, rendered only while something is slotted into `children`; a sibling of
`base`, never nested inside it), `toggle-icon` (the wrapper around the disclosure chevron,
direction-aware through this wrapper's own `transform` — mirrors `<lr-app-rail-group>`'s own
`[part="toggle-icon"]`) and `children` (the wrapper around the `children` slot, rendered only
alongside `toggle`; hidden — but present, so `aria-controls` keeps resolving — while `expanded` is
`false`).

**Themeable custom properties:** `--lr-app-rail-item-current-bg` (default
`var(--lr-color-brand-quiet)`), `--lr-app-rail-item-current-color` (default
`var(--lr-color-brand)`), and `--lr-app-rail-item-current-font-weight` (default
`var(--lr-font-weight-semibold)`) — background, text/icon color, and font weight of the `current`
(`aria-current="page"`) item. All three are scoped to `[aria-current='page']` only and declared as
inline `var()` fallbacks at the point of use, never on `:host`, so any can be set on the item itself
_or on any ancestor_ — including on `<lr-app-rail>` or a wrapper above it, to retheme every item's
current state at once. `::part(base)[aria-current='page']` is invalid CSS (Shadow Parts forbids an
attribute selector after `::part()`), so before these hooks the only lever was overriding the
library-wide `--lr-color-brand-quiet`/`--lr-color-brand`/`--lr-font-weight-semibold` tokens, which
repainted every other element reading them. Unset, each falls back to the token its rule used
before. `--lr-app-rail-item-current-font-weight` mirrors `<lr-stepper>`'s
`--lr-stepper-current-font-weight` and `<lr-segmented>`'s `--lr-segmented-selected-font-weight`.
`--lr-app-rail-item-current-indicator-color` (default `var(--lr-color-brand)`),
`--lr-app-rail-item-current-indicator-width` (default `var(--lr-size-2px)`), and
`--lr-app-rail-item-current-indicator-inset-inline` (default `0 auto`; set `auto 0` to place the
indicator at the inline-end edge instead) theme `[part="current-indicator"]`.
`--lr-app-rail-item-current-indicator-display` (no default; unset resolves to `none` while
`icon-only`) restores the indicator bar in icon-only presentation — a full-height edge bar reads
as a rendering glitch on the square icon-only tile, so it is suppressed there by default; full
presentation is unaffected either way, since its own `[part="current-indicator"]` rule declares no
`display` at all. `--lr-app-rail-item-current-ring` (no default; unset resolves to `none` in full
presentation and an inset ring in icon-only presentation) sets `box-shadow` on `[part="base"]`
while current: unset, icon-only gets an inset ring automatically — the non-color-only signal
(WCAG 1.4.1) that replaces the bar suppressed there, since full presentation already conveys
current state through the indicator bar and `--lr-app-rail-item-current-font-weight`. Setting this
token explicitly applies the same value in both presentations.
Ordinary interaction states are independently inheritable through
`--lr-app-rail-item-hover-bg`, `--lr-app-rail-item-hover-color`,
`--lr-app-rail-item-active-bg`, and `--lr-app-rail-item-active-color`, again retaining the former
brand/active-mix values as fallbacks.
`--lr-app-rail-item-min-block-size` (default `var(--lr-icon-button-size)`, floor-clamped to that
same token regardless of the override so the row's own hit target can never shrink below the WCAG
2.5.8 minimum), `--lr-app-rail-item-padding` (default `var(--lr-space-s)`),
`--lr-app-rail-item-gap` (default `var(--lr-space-s)`, the gap between `[part="icon"]` and
`[part="label"]`, and now also between the item's control and the `meta`/`end` adornments),
`--lr-app-rail-item-meta-color` (default `var(--lr-color-text-quiet)`),
`--lr-app-rail-item-meta-font-size` (default `var(--lr-font-size-sm)`),
`--lr-app-rail-item-icon-size` (default `var(--lr-icon-button-size)`, not
floor-clamped since the icon is decorative, not itself a pointer target), and
`--lr-app-rail-item-font-size` (default `inherit`, set after the `font` shorthand so only the size
is retuned while family/weight/line-height stay inherited) retune the row's geometry.
While `icon-only`, `[part="base"]` resolves to a square hit target matching the icon-button
footprint used elsewhere in this library (`aspect-ratio: 1` against its already floor-clamped
block size) instead of stretching across the rail's icon column. `--lr-app-rail-item-icon-only-size`
(no default) sizes that square directly — both `inline-size` and `block-size`, and the row's own
`min-block-size` floor — independent of `--lr-app-rail-item-min-block-size`, so a taller expanded
row and an icon-only square pinned to `--lr-icon-button-size` can coexist. Unset, the square is
still derived via `aspect-ratio: 1` against the row's block size exactly as before.

**`--lr-positioning-strategy`** (16.0.0) — the icon-only flyout tooltip reads this same cascading
`absolute`/`fixed` override documented on `<lr-popover>` when it is (re)positioned, falling back to
its own `fixed` default when nothing is set. There is no per-instance `positioning-strategy`
property on `<lr-app-rail-item>`; set the custom property on `:root`, a theme, or one clipping
ancestor to change every unset rail item's flyout beneath it.

`--lr-app-rail-item-indent` (default `var(--lr-space-l)`) sets `[part="children"]`'s
`padding-inline-start`. Applied once per nesting level — a doubly-nested `children` list compounds
two insets automatically, since each level's own `[part="children"]` applies the token again.
Logical, so it mirrors under `dir="rtl"` with no separate rule.

**Optional peer deps:** none.

---

### `lr-app-rail-group`

Titles, and optionally collapses, a section of `<lr-app-rail-item>`s. Grouping is by composition —
the group holds whatever it is given; there is no items array and no renderer callback, so it can
never disagree with what is rendered inside it.

```html
<script type="module">
  import '@aceshooting/lyra-ui/components/layout/app-rail-group/app-rail-group.js';
</script>

<lr-app-rail>
  <lr-app-rail-group heading="Workspaces" collapsible>
    <button slot="header-actions" aria-label="Add workspace">+</button>
    <lr-app-rail-item href="/atlas" current>Atlas</lr-app-rail-item>
    <lr-app-rail-item href="/beacon">Beacon</lr-app-rail-item>
  </lr-app-rail-group>
</lr-app-rail>
```

**Properties:**

- `heading: string = ''` — the section title. The `heading` slot replaces it when populated.
- `headingLevel: number = 3` (attribute `heading-level`) — the `aria-level` the heading landmark
  reports. Clamped to 1-6 and rounded; a non-finite value falls back to `3`. Settable because a rail
  sits at a different depth in every page that embeds it.
- `collapsible: boolean = false` (reflected) — opts in the built-in collapse control. The heading's
  own text becomes the button carrying `aria-expanded` and `aria-controls`, which is the accordion
  pattern; an unnamed group falls back to a localized `Collapse`/`Expand` name.
- `open: boolean = true` (reflected) — whether the content is shown. Carries a true-default
  converter, so `open="false"` parses from markup (a plain presence-based boolean cannot). `open`
  governs visibility whether or not `collapsible` is set, so a consumer can drive collapse entirely
  from its own chrome.

**Events:** `lr-toggle-request` — cancelable, emitted before `open` changes from the built-in
control (`detail: { open }`). Call `preventDefault()` to keep the current state, or assign `open`
from the listener to resolve it yourself; a write during the dispatch suppresses the default commit
even when it assigns the value the property already held. Not emitted for a direct `open` write.
`lr-toggle` — non-cancelable, emitted after `open` is written, never for a vetoed or
listener-resolved request (`detail: { open }`).

**Slots:** default — the group's items, and any nested `<lr-app-rail-group>`s; `heading` — rich
heading content; `header-actions` — controls beside the heading, rendered as a sibling of the
collapse control so activating one never toggles the group.

**CSS parts:** `base`, `header`, `heading`, `heading-text`, `toggle`, `toggle-icon`,
`header-actions`, `content`.

**Themeable custom properties:** `--lr-app-rail-group-gap` (default `var(--lr-space-xs)`),
`--lr-app-rail-group-padding-block` (default `var(--lr-space-xs)`),
`--lr-app-rail-group-heading-color` (default `var(--lr-color-text-quiet)`),
`--lr-app-rail-group-heading-font-size` (default `var(--lr-font-size-sm)`),
`--lr-app-rail-group-hover-bg` (default `var(--lr-color-brand-quiet)`),
`--lr-app-rail-group-hover-color` (default `var(--lr-color-brand)`),
`--lr-app-rail-group-active-bg` (no default), `--lr-app-rail-group-active-color` (default
`var(--lr-color-brand)`).

The owning rail marks a slotted group `icon-only` exactly as it marks a slotted item, and the group
forwards that to the items and nested groups it *directly* owns — including ones appended later —
so grouping survives the rail's icon-only presentation. A nested group re-forwards in turn, so
exactly one element ever writes `icon-only` onto any given node and a nested group clips its own
heading too. In that mode the heading text is clipped out of layout — whether or not the group is
collapsible — but stays in the accessibility tree. While `collapsible` too, `[part="toggle"]`
resolves to a square hit target matching the icon-button footprint used elsewhere in this library,
instead of stretching across the header row (mirroring `<lr-app-rail-item>`'s own `[part="base"]`).

**Optional peer deps:** none.

---
