# Changelog

## 1.23.2

- Topbar account-name button and language toggle get a 44x44 minimum hit box (KEFI-HEADER-HITBOX-1 "kefi-web header account control reaches 44x44").

## 1.23.1

**Overlay z-indexes come from the shared `@dloizides/design-tokens` `Z_INDEX` scale (UI-ZINDEX-SCALE-1 "Shared overlay z-index scale").**

- `MobileDrawer` overlay uses `Z_INDEX.drawer`; `CommandPalette` overlay uses `Z_INDEX.palette`.
- Values are unchanged (50 / 60), so stacking behaviour is identical. The drawer's scrim/panel
  order (1 / 2) is local to the overlay and stays as is.
- New dependency: `@dloizides/design-tokens` `^1.13.0`.

## 1.23.0

**The NavBar's stacked mobile menu now scrolls within the viewport (SHOWCASE-1).**

Below `collapseBelow` the open hamburger menu grew to its natural height (734px with ~15 items on
ui.dloizides.com at 360x640). Portals set `body { overflow: hidden }`, so the last items sat below
the fold and neither wheel nor touch could reach them — in every portal with more than ~10 items.

- The stacked links region is now a `ScrollView` capped to `windowHeight - closedBarHeight -
  stackedRightSlot - chrome` (pure `computeStackedMenuMaxHeight`, unit-tested; floor of three 44px
  rows). Rows keep their 44px minimum; nothing depends on hover.

## 1.20.0

**The auth-pending and page-loading spinners now OVERLAY stably-mounted children instead of
replacing them — closing a latent deep-link-collapse trap in the shared shell.**

`AppShell`'s `gate.pending` branch and `appShellContent`'s `state.loading` branch each `return`ed a
full-screen spinner *instead of* `children`. Replacing the subtree unmounts whatever `children` host
— in a portal that is a navigator/`<Slot/>` — and on remount React Navigation re-derives its default
route, so a deep-linked sub-route silently collapses to its default on every reload. (This is the
exact class of bug that hit kefi-web's organizer portal, fixed there in its `OnboardingGate`; the
same shape lived dormant here because no consumer passes `gate=`/`state=` yet.)

- **`AppShell` (`gate.pending`)** now renders the shell tree normally and paints the pending spinner
  as an opaque `position:absolute` overlay on top, so `children` mount once and are never torn down
  across `pending → authenticated` (proven by a "same child instance, no remount" test).
- **`appShellContent` (`state.loading`)** likewise overlays the loading spinner over mounted
  `children` rather than swapping them out. The `loadingHost` wrapper is gated on `state` being
  DEFINED, not on `loading`'s live value — so the element type at the mount point is invariant across
  a `loading` true↔false toggle and `children` are reconciled in place, never remounted on the edge
  (also proven by a "same child instance, no remount" test). Consumers that pass no `state` render
  `children` bare, byte-identical to 1.19.0. Error / forbidden cards are TERMINAL and still replace;
  unauthenticated still returns null + `onRedirect`.
- **Rule encoded in the docblock:** a *transitional* status overlays; only a *terminal* state
  replaces. Corollary for consumers — `children` mount beneath the pending overlay, so gate data
  FETCHES on auth state (`enabled: isAuthenticated`), not on mount. No public API changed; behavior
  is unchanged for any consumer not passing `gate`/`state`.

## 1.19.0

**The "More ▾" overflow menu now opens at a readable width instead of cramping onto the trigger's tiny footprint.**

`NavOverflowMenu` rendered the shared `@dloizides/ui-layout` `ModalDropdown` without a `menuMinWidth`,
so the popover matched the width of the compact "More ▾" pill (~its own label). Option labels
("Settings", "API docs") wrapped onto two lines, and the resulting tall rows overflowed the menu's
max-height — so the lower items scrolled out of view and read as missing.

- **`NavOverflowMenu`** now passes a 200px `menuMinWidth` floor, so the overflow menu opens wide
  enough to show each label on one line. `ModalDropdown`/`InlineMenu` clamp a floored menu inside the
  viewport (ui-layout ≥ 1.24.0, now the peer floor), so the extra width never pushes it off the right
  edge. No public API changed.

## 1.18.0

Root-cause fix for sidebar leaf co-activation. Behavioral/additive — no breaking API.

- **Fix (two sidebar leaves showed `aria-current="page"` at once).** On the live finreg
  `/accounting/journals`, BOTH "Chart of Accounts" (route `/accounting`) and "Journals"
  (route `/accounting/journals`) were marked active, because the active predicate was purely
  prefix-based (`pathname === route || pathname.startsWith(route + '/')`) — a module-home leaf
  routed at `/accounting` is a prefix of every `/accounting/*` sibling, so it co-activated on all
  of them. (Second occurrence of this class; the first, CRM Contacts at `/crm`, was worked around
  by moving the route.) Now resolution is **most-specific (longest) match wins**: among a nav
  model's LEAF items, at most ONE is active for any path — the leaf whose route is the longest
  prefix match. On `/accounting/journals` only Journals is active; on `/accounting` exactly only
  Chart of Accounts is. For nav models with no parent/child route overlap (the common case)
  behaviour is unchanged.
  - **New export** `resolveActiveRoute(items, pathname)`: flattens the tree to its leaf routes and
    returns the single longest matching route (honouring per-leaf `exact`), or `undefined`.
  - `Sidebar` resolves the winning route once over the full leaf set and passes `activeRoute` to
    each row; `NavExpandableItem` marks a leaf active iff `item.route === activeRoute` (falls back
    to the per-item predicate when used standalone — backward compatible). Group HEADER expansion
    (`hasActiveDescendant`) is a separate concern and is unchanged.
  - The same winner-based resolution now backs `NavBar`, `PillNav`, `CollapsedRail` and
    `NavOverflowMenu`, so the latent prefix co-activation cannot resurface in the horizontal navs.
  - **Additive API**: new optional `NavItem.exact?: boolean` — a leaf active ONLY on its exact
    path (never a nested one). A complementary escape hatch; the longest-match default is the real
    fix, so no consumer needs to set it.
  - `isRouteActive(pathname, route, exact?)` gains an optional third arg; the two-arg call is
    unchanged.

## 1.17.0

Two live-defect fixes to the shared sidebar chrome. Behavioral/additive — no breaking API.

- **Fix (two search bars on desktop → mutually exclusive by affordance).** 1.16.1 shipped the
  desktop inline `SidebarSearch` field, but the ⌘K `CommandPaletteTrigger` could be present at
  the same time, so a wide viewport showed BOTH. `Sidebar` now owns both affordances and renders
  exactly ONE, chosen by its own viewport width via `resolveSearchAffordance`:
  - inline affordance (≥768) → the inline field ONLY; the ⌘K trigger is suppressed.
  - palette affordance (<768) → the ⌘K trigger ONLY; the inline field is suppressed.
  - The ⌘K keyboard shortcut (`useCommandPaletteHotkey`) is untouched and stays bound at every width.
  - **Additive API**: new `Sidebar.paletteTrigger?: SidebarPaletteTriggerConfig`
    (`{ label, hint, shortcut, onOpen, testID? }`), forwarded from `NavShellSideRail.paletteTrigger`.
    Consumers that wire neither, or only the inline field, are unchanged (the inline field simply
    no longer renders below 768, where a persistent field would steal vertical space).

- **Fix (hover wash never appeared on RN-web).** The nav rows were `TouchableOpacity`, which in
  react-native-web has NO hover support and silently drops `onHoverIn`/`onHoverOut` — so the row's
  `hovered` state never toggled and the primary-tinted overlay stayed at opacity 0. The leaf and
  group-header rows in `NavExpandableItem` are now `Pressable`, whose `useHover` responder actually
  drives `hovered`, lifting the token-tinted overlay to the hover wash (`HOVER_TINT_OPACITY`, ~160ms
  reduced-motion-gated transition) — distinct from and lighter than the active tint. Token-only
  (colours via the theme; no raw hex/rgb/hsl). aria wiring (`aria-current` / `aria-expanded`, which
  `MobileDrawer` depends on) is preserved across the switch.

## 1.16.1

- **Fix (NavShell forwards inline-search props to the inner `Sidebar`).** 1.16.0 added
  desktop inline search to `Sidebar` (`enableInlineSearch`, `search`, controlled
  `searchQuery` / `onSearchChange`), but all seven portals consume the higher-level
  `NavShell` (via `sideRail`), which did **not** forward those props — so inline search was
  unreachable by every real consumer. `NavShellSideRail` now carries the same four fields
  (`enableInlineSearch?`, `search?: SidebarSearchConfig`, `searchQuery?`, `onSearchChange?`)
  and threads them onto the `<Sidebar>` it builds. Desktop-vs-mobile resolution is unchanged
  (inline ≥768, ⌘K palette <768 via `resolveSearchAffordance`).
  - **Purely additive / backward compatible**: with the fields unset, `NavShell` renders
    exactly as 1.16.0. No breaking change; no styling touched.

## 1.16.0

- **Feature (docs-style sidebar rail — hover wash, tinted active pill, rotating chevron).**
  The `Sidebar` / `NavExpandableItem` rows now read like the aml-screening docs navigation:
  each row carries a PRIMARY-TINTED overlay whose opacity eases `0 → hover → active` as a
  smooth ~160ms CSS cross-fade on web (gated by `prefers-reduced-motion`, omitted on native).
  - **Hover** lights a row with a subtle brand wash (`HOVER_TINT_OPACITY` = 0.06); **active**
    deepens it to a soft brand pill (`ACTIVE_TINT_OPACITY` = 0.14) over the transparent
    left-accent gutter every leaf already reserved, so the active leaf shows a tinted pill + a
    coloured left bar + brand-coloured label — no layout shift, all from `theme.palette.primary`.
  - **Rotating chevron.** Expandable groups now get a built-in `SidebarChevron` (a `›` glyph
    that rotates to `⌄` on expand, tweened on web) when the caller supplies no `renderChevron`.
    A caller-supplied `renderChevron` still wins, so existing icon-set chevrons are unaffected.
  - Row corner radius softened to `NAV_ROW_RADIUS` (8px) with the rounded overlay clipped to it.
  - No public API change to existing props; hover uses RNW's `onHoverIn`/`onHoverOut` (no-op on
    native). New exports: `SidebarChevron`, `NAV_ROW_RADIUS`, `ACTIVE_TINT_OPACITY`,
    `HOVER_TINT_OPACITY`.

- **Feature (desktop INLINE sidebar search — opt-in — with the ⌘K palette kept for mobile).**
  The owner found the ⌘K command-palette popup inconvenient on desktop. `Sidebar` now accepts
  an opt-in persistent inline search field at the top of the rail that narrows the nav list
  LIVE as you type.
  - **New `Sidebar` props (all additive, default OFF):** `enableInlineSearch` + a pre-localized
    `search: SidebarSearchConfig` (`placeholder` / `hint` / `clearLabel` / `clearHint` /
    `emptyText`), plus optional controlled `searchQuery` + `onSearchChange` (the sidebar owns the
    state when omitted). With the flag off the render tree is byte-identical to 1.15 — the mobile
    drawer, which should keep using the ⌘K palette, is untouched.
  - **New exports:** `SidebarSearch` (the field), `filterNavItems` / `isFilterActive` /
    `searchTokens` (the pure tree-prune, same all-tokens-must-match semantics as `filterCommands`
    so inline + ⌘K rank identically), and `resolveSearchAffordance(viewport)` +
    `DEFAULT_SEARCH_BREAKPOINT` — the pure `'inline'` (≥768px) vs `'palette'` (<768px) rule so a
    shell can wire desktop-inline / mobile-palette from one function. The ⌘K hotkey
    (`useCommandPaletteHotkey`) stays bound at every width.

## 1.15.0

- **Feature (an expandable nav group now ANIMATES its expand/collapse instead of snapping).**
  `NavExpandableItem` used to conditionally render its children (`{expanded ? … : null}`),
  so a sidebar/drawer group's leaves appeared and vanished INSTANTLY on toggle — a hard,
  jarring cut with no transition. The group's children are now wrapped in the
  `@dloizides/ui-motion` `<Collapse open={expanded}>`, which measures the children's natural
  height and animates a real `Animated.Value` height on WEB and native alike (the height code
  path RN's native-only `LayoutAnimation` never gave the web build), collapsing to instant
  under `prefers-reduced-motion`.
  - **Collapsed leaves stay non-interactive.** Per the `Collapse` contract the children remain
    MOUNTED at all times (their state and a11y focus order are preserved); when collapsed they
    are clipped to zero height under `overflow: hidden`, so a collapsed group's leaves are
    neither visible nor tappable in a real browser even though they are in the tree. This is a
    deliberate change from the prior "absent from the DOM when collapsed" behaviour.
  - **`aria-expanded` and the mobile-drawer contract are intact.** The group header still emits
    `aria-expanded` (the `ariaExpandedProps` spread is unchanged), so `MobileDrawer` still tells
    a group TOGGLE apart from a navigating LEAF and does not dismiss the drawer when a group is
    expanded on mobile. The active-leaf accent bar, docs-style section-header styling, chevron
    slot, testIDs, and all a11y roles/labels/hints are unchanged.
  - **No public API change.** `NavExpandableItemProps` / `NavItem` are untouched — the animation
    is derived from the `expanded` state the component already tracked. `@dloizides/ui-motion`
    (`^1.0.1`) is added as a runtime dependency; all 7 portals gain the animated group on their
    next bump for free. Consumers with no expandable groups are visually unaffected.

## 1.13.1

- **Fix (mobile drawer: a sub-item / leaf tap dismissed the drawer WITHOUT navigating).**
  On a phone-width viewport, tapping a leaf inside the open `MobileDrawer` closed the drawer
  but the route never changed — verified live on finreg via Playwright (real Chromium, real
  pointer events); every jsdom unit test stayed green because it wired `onClose` to an inert
  `jest.fn()` that never unmounts anything.
  - **Root cause.** `MobileDrawer` closes on a nav press via a NATIVE
    `panel.addEventListener('click', …)`. react-native-web fires a leaf's `onPress` from its
    `onClick`, dispatched through REACT's synthetic event system whose root listener sits on the
    app-root container — an ANCESTOR of the panel. So on a real click the native panel listener
    runs FIRST (during the DOM bubble), and closing synchronously there ran `setDrawerOpen(false)`,
    unmounting the panel and tearing the leaf out of the tree BEFORE React could dispatch its
    `onPress`. The navigation call never ran.
  - **Fix.** The drawer's close is now DEFERRED to the next macrotask (`setTimeout(…, 0)`), so
    React finishes dispatching the tapped leaf's `onPress` (the navigation) for the current click;
    the panel then unmounts on the following tick, after the route has already changed. Scrim tap
    and group-header toggle are unchanged.
  - **No API change.** All 7 portals get the fix on their next bump.

## 1.11.0

- **Feature (an expandable nav group now auto-opens when the current route is inside it).**
  `NavExpandableItem` used to mount every group collapsed (`useState(false)`), so on a fresh
  page load the group containing the active route was closed and its active leaf was hidden.
  A persistent-rail console that groups its nav by module (e.g. finreg-web's Payments / CRM /
  Accounting rail) needs the active module's group open on load, the others collapsed — with
  no way for the caller to express that, since expansion state was internal and uncontrolled.
  - The group now initializes its expansion from whether the current route is inside it:
    `useState(() => hasActiveDescendant(item, pathname))`, where `hasActiveDescendant` is true
    when the item's own route is active (`isRouteActive`) or any descendant's is (recursive, so
    it holds at any nesting depth). The group of the active route mounts EXPANDED (active leaf
    visible + highlighted); a group with no active descendant mounts COLLAPSED — exactly the
    prior behaviour.
  - **No API change.** `NavItem` is unchanged — the behaviour is derived from the `pathname`
    the component already receives. Consumers with no expandable groups, or whose groups hold no
    active route, are byte-for-byte unaffected; all 7 portals gain the auto-open on their next
    bump for free.
  - Guarded by `src/navExpandableItem.test.tsx` (RED-first): the two "mounts EXPANDED" assertions
    FAIL against the pre-change `useState(false)` (the active group's child is absent from the DOM)
    and pass after; the "mounts COLLAPSED" + "header always renders" assertions lock the
    backward-compatible half.

## 1.10.2

- **Fix (the mobile nav DRAWER was not clickable — a tap on a nav item did not navigate, it
  just closed the drawer / did nothing, in all seven portals below 768px).**
  `MobileDrawer` rendered a full-bleed scrim `Pressable` (`StyleSheet.absoluteFillObject`) and a
  panel `View` with `{ position: 'absolute', top: 0, bottom: 0, left: 0 }` — **no width and no
  zIndex**. Reproduced structurally: the panel's computed `width` was empty and its `zIndex` was
  `0`; the scrim's computed `left`/`right` were both `0px` at `zIndex` `0`. So the scrim FULLY
  overlapped the panel at the SAME stacking level, and on react-native-web the equal-zIndex scrim
  won the pointer (the estate's known "RN-web Pressable swallows a sibling/child press" trap):
  the tap closed the drawer and the nav-item's `TouchableOpacity` `onPress` (→ the app's router)
  never fired.
  - Fixed once, in the kit: the panel now has an explicit drawer **width** (`280px`), a solid
    surface background, and a `zIndex` **above** the scrim; the scrim is anchored BESIDE the panel
    (`left: DRAWER_WIDTH`) so its tap-to-close hit area never overlaps the panel. Nav-item taps
    reach the `Sidebar` leaves; taps beside the drawer still close it.
  - Guarded by `src/mobileDrawer.test.tsx` (RED-first): three structural assertions read the
    resolved geometry via `getComputedStyle` and FAIL against the pre-fix panel/scrim (no width,
    equal zIndex, overlapping scrim); two behavioural assertions lock the contract (nav-item tap
    navigates + closes; scrim tap only closes).
  - **No API change.** Consumers need no edit — bump the dependency and the drawer works.

## 1.10.1

- **Fix (a plain-string slot logged `console.error` on EVERY render, in all seven portals).**
  Every caller-supplied slot in the kit is typed `React.ReactNode`, which legitimately includes
  `string` — and callers pass exactly that (`brand: FM('app.name')`, a plain localized string).
  The slot was dropped straight into a `<View>`, and react-native-web refuses to render a bare
  text node there: `Unexpected text node: <s>. A text node cannot be a child of a <View>.`
  Nothing in the type system objected, so the defect only ever surfaced as browser console
  noise — which is why it survived to 1.10.0 across the whole fleet.
  - Fixed once, in the kit, via the new internal `renderTextSlot` helper: a `string`/`number`
    slot is wrapped in `<Text>` (routed through the theme's `colors.text`, so it honours dark
    mode rather than defaulting to black); an element slot passes through **untouched**, so
    every existing caller renders byte-identically. Array slots have each string member wrapped
    individually, keys preserved.
  - Applied to **all 12 slot render sites**, not just the reported one — `NavBar` (`brand`,
    `right`), `Topbar` (`left`, `notificationSlot`), `Sidebar` (`header`, `footer`),
    `CollapsedRail` (`header`, `footer`), `AppShell` (`header`, `nav`, `sidebar`,
    `collapsedSidebar`, `banner`) and `MobileDrawer` (`sidebar`).
  - Guarded by `src/rawTextSlots.test.tsx`, which spies on `console.error` and asserts silence.
    Unit tests that only assert "the slot rendered" pass against the bug; this one does not.
  - **No API change.** Consumers need no edit — the `<Text>` wrapper they were expected to
    remember is now the kit's job.

## 1.10.0

- **Fix (`layout` was not authoritative — `layout="side"` rendered a top bar anyway).**
  `wantsTopBar` carried an extra `(layout === 'side' && hasTopBar)` clause, so passing a
  `topBar` **config** with `layout="side"` mounted the bar regardless — making `'side'` and
  `'both'` render **pixel-identically** and `layout` unobservable. `layout` now decides which
  REGIONS exist; `topBar` is only the CONTENT of the top region. A `'side'` app that also wants
  a bar asks for `layout="both"`, which is what that combination always meant.
  - Found by `apps/ui-showcase`, which renders every orientation side by side — a product app
    picks ONE orientation and never sees the other, which is why this survived to 1.9.0.
  - **Not breaking in this fleet:** no app passed `layout="side"` + `topBar`. The four `'side'`
    portals (erevna, katalogos, zygos, agora) pass a `header` NODE, not a `topBar` config.
  - The `hasHeader` clause is **deliberately kept**: `header` is the documented escape hatch by
    which a `'side'` app supplies its own ready-made header (the structured `Topbar` with
    language / notification / user slots). It is an explicit "render THIS node", not a config
    whose presence is silently read as an orientation change.
- **a11y: `AccountState.accountHint`** — the account-name button's `accessibilityHint` was
  hardcoded to `displayName`, byte-identical to its `accessibilityLabel`, so a screen reader
  announced the name twice and never said what pressing it does — and the consumer had no way
  to correct it. The sibling `upgrade?: TopbarAction` on the same interface has always carried
  a `hint`; this closes that inconsistency. Falls back to `displayName`, so existing callers
  are unaffected.
- **a11y: `AccountState.accountTestID`** — the account-name button's testID was hardcoded, with
  no override, unlike `TopbarAction.testID?` / `NavItem.testID?` elsewhere in the package.
- **a11y: `Topbar.language.testID`** — the inline `language` prop type declared no `testID`, so
  the repo's testID+label+hint standard was **structurally unsatisfiable** for that one button
  while every other Topbar action (all `TopbarAction`) accepted one.

> Note for consumers: this react-native-web build does **not** forward `accessibilityHint` to
> the DOM at all — rendered buttons carry `aria-label` but no hint attribute. The hint is a
> NATIVE-only guarantee today. That is pre-existing and unchanged here, but it means DOM-level
> tests cannot distinguish a threaded hint from a dropped one; `topbarA11y.test.tsx` therefore
> pins the prop threading directly.

## 1.9.0

**`NavBar.contentMaxWidth` — a full-bleed bar with a centred content column.**
An optional content-column cap (px), forwarded via `NavShell.topBar.contentMaxWidth`.
When set, the OUTER `NavBar` container — which carries the bar background + bottom
border (from `barTheme` / theme surface + any `containerStyle`, e.g. the AML
radial-glow appbar) — stays FULL WIDTH (full-bleed), while the INNER row (brand +
links + right) is capped to `contentMaxWidth` and centred. This restores the v1-console
`.ui-appbar` (full-bleed) + `.ui-appbar-inner { max-width; margin: 0 auto }` shape:
previously an app had to put the cap on the container's `containerStyle`, which also
capped the dark background and left light gutters on wide viewports. Omit it and the
inner spans the full container (unchanged — byte-identical to before). The priority+
overflow "…" fit measures the capped inner's rendered width, so it keeps working
against the content column, not the full-bleed container. Purely additive.

## 1.8.0

The navigation shell becomes the ONE shell for every portal: a first-class
**top | side | both** layout, plus the capabilities harvested from every RN-web
portal's bespoke nav/shell so nothing is lost when they migrate. Purely additive —
`Sidebar`, `Topbar`, `NavBar`, `Nav`, `AppShell`, and the role helpers keep their
exact signatures; every new prop/component is optional.

**`NavShell` — top bar, side rail, or BOTH.** One component owns the whole page
composition, chosen by a single `layout` prop (`'top' | 'side' | 'both'`). It wires
a top bar into `AppShell`'s `header` slot and a side rail into its `sidebar` slot,
and inherits every `AppShell` capability (auth gate, loading/error/forbidden state
cards, width policy, banner, the responsive hamburger + overlay drawer). `'both'`
is the AML-console shape — a top bar (brand / global links + overflow "…" / user)
AND a side rail (sections) together — now first-class instead of hand-wired. The
top bar is built from a `topBar` config (a `NavBar`), the side rail from a
`sideRail` config (a `Sidebar`); an explicit `header` node overrides the built top
bar for apps that want a structured `Topbar`.

**Three-tier responsive rail (`AppShell.collapsedSidebar`).** Opts the `sidebar`
layout into the erevna/katalogos scheme: the full rail on desktop, an intermediate
persistent **collapsed (icon-only) rail** on a tablet band, and the overlay drawer
on a phone. Supply a `collapsedSidebar` node (and optionally `collapsedRailRange`,
default `{ min: 768, max: 1024 }`) — omit it and the layout stays the unchanged
two-tier scheme. New `CollapsedRail` component renders an icon-only rail from the
same `NavItem[]` (icon or first-letter glyph fallback, header/footer slots). New
`resolveRailMode` pure resolver + `RAIL_FULL_BREAKPOINT` / `DEFAULT_COLLAPSED_RAIL_MAX`.
`NavShell.sideRail.collapsed` builds it config-driven. New testID suffix
`-collapsed-sidebar`.

**`NavBar.barTheme` — dark bar over a light page.** An optional chrome-theme
override re-provisions the `@dloizides/ui-feedback` theme for the bar only (the AML
v1-console dark appbar over light content), re-passing the live `t` / `navigate` so
labels keep localizing. Forwarded via `NavShell.topBar.barTheme`. Style-only —
colours still flow through the theme. (Custom appbar backgrounds like AML's radial
glow stay expressible via `containerStyle`.)

**`PillNav` — coral cross-navigation (kefi).** A row of fully-rounded pills from a
`NavItem[]`, the active one wearing the accent (theme `palette.primary` = kefi's
coral). Folds in kefi's **visibility rule**: hides (renders `null`) unless there
are at least `minItems` entries (default 1; kefi passes 2).

**`DarkModeControl` — 3-way colour scheme (erevna/katalogos).** A controlled
Light / Dark / System control in two variants: `"segmented"` (a highlighted
segment row) and `"cycle"` (an icon+label button that advances to the next option
on press). Value-agnostic string options, pre-localized labels/hints.

**Refactor (no behaviour change).** `AppShell`'s page-state cards moved to
`appShellContent.tsx` (`useContentBody` / `ShellMessage` / `ShellState`) to keep
`AppShell` focused on layout; the shell's render tree is unchanged.

New exports: `NavShell` (+ `NavShellProps` / `NavShellLayout` / `NavShellTopBar` /
`NavShellSideRail` / `NavShellCollapsedRail`), `CollapsedRail` (+ `CollapsedRailProps`),
`PillNav` (+ `PillNavProps`), `DarkModeControl` (+ `DarkModeControlProps` /
`DarkModeOption` / `DarkModeVariant`), `resolveRailMode` (+ `RailMode` /
`CollapsedRailRange` / `ResolveRailModeArgs`), `RAIL_FULL_BREAKPOINT`,
`DEFAULT_COLLAPSED_RAIL_MAX`, `pillNavStyles`, `darkModeStyles`, `collapsedRailStyles`.

## 1.7.0

Two additive navigation capabilities on the horizontal bar, plus a unifying component.

**Priority+ overflow ("…" menu) on `NavBar`.** Above the `collapseBelow` breakpoint the
inline links sit on ONE non-wrapping row; when they don't all fit, a measure pass
(`useNavOverflow` + the pure `computeVisibleCount`) keeps the leading items that fit inline
and collapses the rest behind a **"More ▾"** trigger — the shared `@dloizides/ui-layout`
`ModalDropdown`, so the overflow menu is portalled, keyboard-navigable, and dismissed on
outside-click / Escape like every other menu. No item is ever cut off; the surplus simply
moves into the dropdown. When the active route lives in the overflow set the trigger wears
the accent pill so it stays visibly reachable. BELOW `collapseBelow` the whole set still
collapses into the responsive hamburger drawer (the two mechanisms never overlap). Two new
optional props localize the trigger: `overflowLabel` (visible + accessible, default `"More"`)
and `overflowHint`. New `NAV_TEST_IDS.navBarOverflow` (`navbar-overflow`).

**`Nav` — one component, two orientations.** `<Nav orientation="horizontal" />` renders the
`NavBar`; `<Nav orientation="vertical" />` renders the `Sidebar` — from the SAME `NavItem[]`,
so an app flips between a top bar and a side rail without swapping components. `orientation`
defaults to `"horizontal"`; the remaining props are a discriminated union of the delegate's
props (TypeScript enforces the right prop set per orientation). Vertical nav is the existing
`Sidebar` (stacked, vertically scrolling — no overflow menu needed).

**Backwards compatible.** Purely additive — `Sidebar`, `Topbar`, `NavBar`, `AppShell`, and
the role-gating helpers keep their exact signatures; the new overflow props are optional
(default `"More"` / `""`) so aml-v2 (the only `NavBar` consumer) and every `Sidebar`/`Topbar`
consumer (erevna / katalogos / kefi / agora) are untouched. New exports (`Nav`, `NavProps`,
`NavOrientation`, `NavOverflowMenu`, `NavOverflowMenuProps`, `NAV_LINK_GAP`) are additive.

## 1.6.1

Keep the expanded links on ONE row — the row must be `nowrap` so `flex-shrink` distributes
negative space (a `wrap` row drops the right slot onto a 2nd line instead of shrinking the
links). Renames the internal expanded links style to `linksRow` (grow + shrink-to-0,
`minWidth:0`, `overflow:hidden`) with a per-link `linkCell` (never shrinks, so each link keeps
its natural measured width). Brand + right stay pinned (`flexShrink:0`).

## 1.6.0

Keep the bar to ONE row — horizontal-scroll / clip the links instead of wrapping the bar
onto a second line when brand + links + right slot overflow the available width.

## 1.5.0

Make the `AppShell` back-office `sidebar` layout **responsive** — a mobile drawer.

The `sidebar` slot (1.4.0) always rendered a persistent fixed-width left rail in a
`flexDirection:'row'` body with no width breakpoint, so below ~768px the content column
starved (headings wrapped a character per line, inputs truncated) — the merchant admin was
unusable on a phone. Now `AppShell` reads `useWindowDimensions`:

- **Desktop (≥768px): IDENTICAL to 1.4.0** — the persistent rail + row layout, untouched.
  Purely additive; no consuming product's desktop layout changes.
- **Below 768px:** the rail is not rendered beside the content; instead a **hamburger**
  (in the header) opens the same `Sidebar` as an overlay **drawer** (a tap-to-close scrim +
  a left panel) and the content takes the full width. The drawer closes on a scrim tap and
  on a nav-item press (web: a bubbled click on a `role="button"` / link inside the panel).

Accessibility: the hamburger has a testID + `accessibilityLabel` + `accessibilityHint` and a
themed focus ring; the drawer panel is `role="dialog"` `aria-modal`; the scrim is a labelled
button. New optional prop `mobileMenu?: Partial<DrawerLabels>` supplies pre-localized
open/close labels (English defaults otherwise — this package has no i18n). New testID
suffixes: `-menu-toggle`, `-drawer`, `-drawer-scrim`.

**Backwards compatible.** No breaking prop changes — consumers pass the same `sidebar` slot.
Omit `sidebar` and the tree is byte-identical to 1.4.0 (erevna / katalogos / kefi untouched).
Only a `sidebar` consumer on a narrow viewport sees the drawer. Pinned by tests asserting the
rail is present on desktop and absent (hamburger present) on a narrow viewport.

## 1.4.0

Add an optional **`sidebar`** slot to `AppShell` — the back-office layout.

`AppShell` could only stack vertically (header → nav strip → banner → scrolling content),
so a product with a persistent LEFT RAIL (Agora's merchant admin: products / categories /
coupons / settings) had to hand-compose `Topbar` + `Sidebar` + a `ScrollView` row and give
up the shell's auth gate and loading/error/forbidden state cards. Supplying `sidebar` now
lays the body out as a ROW — the rail on the left, the width-disciplined scrolling content
column on the right — while every existing feature (gate, state cards, `width`,
`chromeAlignment`) keeps working.

The new region is exposed as `` `${testID}${APP_SHELL_SUFFIX.sidebar}` `` (`-sidebar`).

**Backwards compatible.** Omit `sidebar` and the render tree is byte-identical to 1.3.0 —
erevna / katalogos / kefi / aml-v2 are untouched. Pinned by a test asserting no `-sidebar`
region exists when the slot is omitted.

## 1.3.0

Give the `NavBar` the v1 `.gn-links` look + a full accessibility pass (and extend the
same a11y bar to `Sidebar`/`Topbar`).

**Look & hover (NavBar).** Each link is a rounded-pill target with the v1 hover
affordance — muted at rest, and on HOVER the text brightens to full contrast while a
subtle rounded-pill background appears; the ACTIVE link is a solid accent pill. All
theme-driven (rest = `textSecondary`, hover text = `text`, hover bg = `surfaceElevated`,
active/ring = `palette.primary`). Hover/focus are web-only (`onHoverIn/Out`, `onFocus/Blur`
via `Pressable`); native stays at the rest style. The hover cross-fade is cheap CSS and is
disabled under `prefers-reduced-motion: reduce` (new `useReducedMotion`). Backwards
compatible — the hover pill is the default (aml-v2 is the only `NavBar` consumer) and the
brand/right slots, responsive hamburger collapse, and `containerStyle` width behaviour are
unchanged.

**Accessibility.** NavBar toggle now carries `aria-controls` pointing at the collapsible
links region (id via `nativeID`), keeps `aria-expanded`, and gains a themed keyboard focus
ring; links keep `aria-current="page"` + `role="link"` and gain a focus ring; link/toggle
touch targets are now ≥44×44. `Sidebar` leaves gain `aria-current="page"` + a focus ring;
`Topbar` action buttons gain a focus ring (new internal `FocusableTouchable`). Focus rings
never remove the outline without a themed replacement.

## 1.2.0

Add `NavBar` — a config-driven **horizontal top navigation bar**, the top-bar
counterpart to `Sidebar`. It renders the same caller-supplied `NavItem[]` as inline
links across the top (brand left slot + inline links + free-form right slot), with
an active-link highlight (`aria-current="page"`) and a responsive collapse to a
hamburger toggle below a configurable breakpoint (`collapseBelow`, default `760`) —
mirroring the v1 AML console `.global-nav`. Theme-driven (UiProvider colours),
`role="navigation"` landmark, keyboard-operable links. Purely additive: `Sidebar`,
`Topbar`, `AppShell`, and the role-gating helpers are unchanged. New `NAV_TEST_IDS`
entries `navBarToggle` / `navBarLinks`.

## 1.0.0

Initial release. `Sidebar` (leaf + expandable items, active-route highlight, header/footer
slots), `Topbar` (logo/language/notification/user/account/logout slots), and the
`accessibleNavItems` / `roleRoutesToNavItems` role-gating helpers (reusing
`resolveAccessibleRoutes` from `@dloizides/auth-web`). Chrome + metrics ported verbatim from
the byte-identical erevna-web / katalogos-web nav twins; every colour reads from the
`@dloizides/ui-feedback` UiProvider theme.
