import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
import { useBotPresets } from '../../features/bots/catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import {
  getCanonicalAliasOrRoomId,
  getCanonicalAliasRoomId,
  isRoomAlias,
} from '../../utils/matrix';
import { getChannelsSpacePath } from '../../pages/pathUtils';
import { SpaceProvider } from '../../hooks/useSpace';
import { Direct } from '../../pages/client/direct';
import { Channels, ChannelsRootNav, useActiveSpace } from '../../pages/client/channels';
import { Bots } from '../../pages/client/bots';
import { ChannelsModeProvider } from '../../hooks/useChannelsMode';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import { MobilePagerPaneProvider, MobilePagerTab } from './MobilePagerPaneContext';
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
import { PagerRefreshSingleton } from './PagerRefreshSingleton';
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
import * as css from './style.css';

// Aliased to the context's `MobilePagerTab` so the per-pane Segment's
// `selectTabInstant(target)` callback (exposed via MobilePagerPaneInfo)
// stays type-aligned with the pager's own `tabs` array.
type Tab = MobilePagerTab;

// URL-safe wrapper around decodeURIComponent — matches the same helper
// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias`
// param before we decide whether to mount the pager or defer to the
// existing route tree's JoinBeforeNavigate fallback.
const safeDecode = (raw: string): string | undefined => {
  try {
    return decodeURIComponent(raw);
  } catch {
    return undefined;
  }
};

type PaneSlotProps = {
  isActive: boolean;
  children: ReactNode;
};

// Wraps a pane's DOM box and toggles the `inert` HTMLElement property
// based on the active flag, plus mirrors it into `aria-hidden`.
//
// `inert` removes the off-screen pane subtree from focus order, click
// handling, and the accessibility tree — important so assistive tech
// (and stray keyboard focus on devices with hardware keyboards) can't
// reach controls that are visually translateX'd out of the viewport.
// `aria-hidden` is the long-supported half of the same intent and
// alone covers most screen readers. Both are applied for full
// portability across AT/browser combinations.
//
// `inert` is assigned via ref (not as a JSX attr) because React 18.2's
// HTMLAttributes typing doesn't include it. The underlying DOM
// property is supported by Chromium 102+ / Safari 15.5+ which covers
// Capacitor's WebView baseline.
function PaneSlot({ isActive, children }: PaneSlotProps) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (ref.current) ref.current.inert = !isActive;
  }, [isActive]);
  return (
    <div ref={ref} className={css.pane} aria-hidden={!isActive || undefined}>
      {children}
    </div>
  );
}

// Mobile + Capacitor horizontal swipe pager. Mounts all three listing
// surfaces once, slides between them via CSS transform.
//
// Visual layout decomposes into a STATIC overlay header at the top
// (segments + action icons, painted by `MobileTabsPagerHeader`) and a
// translating strip below it. Each pane's StreamHeader still renders
// its own tabs row but `visibility: hidden` in pager mode — kept in
// DOM only so the curtain's TABS_ROW_PX-based snap geometry is
// preserved. Action icons in the static header proxy through
// `mobilePagerCurtainAtom`, written by whichever pane is active.
//
// Channels tab specifics: the pane content depends on whether the
// user has at least one joined orphan space. If yes, we render
// `<Channels>` (workspace listing) keyed to the active space; if no,
// `<ChannelsRootNav>` paints the empty-state CTA. `commitTo('channels')`
// navigates to /channels/!{spaceId}/ when an active space is known so
// the swipe never bounces through /channels/ + the ChannelsLanding
// `<Navigate>` redirect (which previously cut the slide animation
// short and made the gesture feel jerky).
//
// Inter-pane gap: `PANE_GAP_PX` is inserted between adjacent panes
// via inline `gap` on the strip. The pagerRoot's SurfaceVariant
// backdrop shows through the gap during a swipe, matching the user
// request for a light-blue divider colour identical to the header.
//
// Invalid space URL fall-through: if the URL is `/channels/:alias/`
// but `:alias` doesn't resolve to a joined orphan space (deep-link
// to a workspace the user isn't in, or a typo), the pager bails out
// and renders `<Outlet/>` instead. That delegates to the existing
// `/channels/!space/` route element whose `RouteSpaceProvider` shows
// `JoinBeforeNavigate`. Without this guard, `useActiveSpace` would
// silently fall back to the persisted-or-first-orphan space and the
// pager would show a DIFFERENT workspace than the URL claims —
// confusing the user and breaking deep-link semantics.
type MobileTabsPagerProps = {
  // When true the pager is mounted as the inert BASE layer behind a chat
  // overlay (see MobileTabsLayout). It must (a) suppress its own swipe
  // gesture and (b) never fall through to `<Outlet/>`, which would render
  // the chat a second time behind itself.
  asBackdrop?: boolean;
};

export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
  const mx = useMatrixClient();
  const navigate = useNavigate();
  const bots = useBotPresets();

  // Resolve the active tab from the URL's SECTION, not only the listing
  // root. At a listing root (/direct/, /channels/, /channels/!space/,
  // /bots/) the pager is the live foreground; at a DETAIL url under a
  // section (/direct/!room, /channels/!space/!room/, /bots/:botId) the
  // pager is mounted as the inert BACKDROP behind a chat overlay
  // (MobileTabsLayout `asBackdrop`) and must show that section's list
  // behind the chat. So Channels/Bots use `end:false` section matches;
  // Channels keeps the exact landing match too. `channelsSpaceMatch`
  // (end:false) yields `params.spaceIdOrAlias` for both /channels/!space/
  // and any /channels/!space/!room/..., which `urlSpaceId` below resolves so
  // the correct workspace renders behind a channel room.
  const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
  const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: false });
  const channelsSpaceRoot = !!channelsSpaceMatch;
  const channelsActive = channelsRoot || channelsSpaceRoot;
  const botsRoot = !!useMatch({ path: BOTS_PATH, end: false });

  // Active space resolution mirrors ChannelsLanding: URL > localStorage
  // > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom`
  // through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every
  // entry it returns is BOTH (a) a Space the user has joined and (b)
  // an orphan (no parent Space). Both resolvers below constrain their
  // result to that orphan set. Net invariant: if `activeSpaceId` is
  // defined, `mx.getRoom(activeSpaceId)` is a Space the user is
  // currently a member of — which is exactly the precondition
  // `RouteSpaceProvider` would otherwise enforce via
  // `joinedSpaces.includes(space.roomId)` before mounting Channels.
  // That's why the pager can mount Channels with a plain
  // `<SpaceProvider value={...}>` and skip RouteSpaceProvider's
  // JoinBeforeNavigate fallback path safely — for VALID URL spaces.
  // Invalid URL spaces hit the early-return below.
  const roomToParents = useAtomValue(roomToParentsAtom);
  const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);

  // Resolve the URL `:spaceIdOrAlias` to a joined-orphan space id. Read from
  // the `useMatch` param (channelsSpaceMatch), which is populated even when
  // the pager is the inert BACKDROP behind a chat — unlike `useActiveSpace`'s
  // `useParams()`, which is empty there because the pager sits OUTSIDE the
  // detail route's element tree. So a deep-link / push straight into a room of
  // a not-yet-visited workspace still renders THAT workspace behind the chat,
  // matching where the back-swipe lands (`getRouteSectionParent`).
  const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias;
  const urlSpaceId = useMemo(() => {
    if (!urlSpaceParam) return undefined;
    const decoded = safeDecode(urlSpaceParam);
    if (!decoded) return undefined;
    const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded;
    return resolved !== undefined && orphanSpaceIds.includes(resolved) ? resolved : undefined;
  }, [mx, urlSpaceParam, orphanSpaceIds]);

  // URL space wins (correct in the backdrop); fall back to the persisted /
  // first-orphan resolver for listing roots where there's no room param.
  const fallbackSpaceId = useActiveSpace(orphanSpaceIds);
  const activeSpaceId = urlSpaceId ?? fallbackSpaceId;
  const activeSpace = activeSpaceId ? mx.getRoom(activeSpaceId) : null;

  // A URL space present in the param but not resolving to a joined orphan is
  // an invalid deep-link (unjoined / unknown) → defer to the route tree's
  // JoinBeforeNavigate via the early-return below (unless we're the backdrop).
  const urlSpaceIsValid = !urlSpaceParam || urlSpaceId !== undefined;

  const showBots = bots.length > 0 || botsRoot;

  const tabs = useMemo<Tab[]>(() => {
    const list: Tab[] = ['direct', 'channels'];
    if (showBots) list.push('bots');
    return list;
  }, [showBots]);

  const urlActiveIdx = useMemo(() => {
    if (botsRoot) {
      const i = tabs.indexOf('bots');
      return i >= 0 ? i : 0;
    }
    if (channelsActive) {
      const i = tabs.indexOf('channels');
      return i >= 0 ? i : 0;
    }
    const i = tabs.indexOf('direct');
    return i >= 0 ? i : 0;
  }, [tabs, channelsActive, botsRoot]);

  const [dragPx, setDragPxState] = useState(0);
  const [dragging, setDraggingState] = useState(false);
  // Tap-driven commits set this for one frame to suppress the strip's
  // CSS transform transition. The strip jumps to the new tab without
  // the 280ms slide animation that swipe commits intentionally play.
  // Cleared in a rAF effect below so subsequent state changes resume
  // with normal transitions.
  const [instantSwitch, setInstantSwitch] = useState(false);
  // Stored as a Tab NAME, not an index. The `tabs` array's length and
  // composition can change at runtime (showBots flipping when the user
  // navigates onto/off /bots/, or when a bot-config refresh adds a
  // bot) — a stored index could end up pointing at the wrong tab or
  // off the end of the array. A stable name lets us re-derive the
  // index on every render via `tabs.indexOf(...)`.
  //
  // Load-bearing: react-router-dom v6's `useNavigate` does NOT auto-
  // wrap in `React.startTransition` and its router-state update
  // (`useSyncExternalStore`-backed) is asynchronous relative to React
  // 18's auto-batching of setState in the same event handler. Without
  // this pending lock, the commit path `setDragPx(0); setDragging
  // (false); navigate(...)` can land in two renders — first with
  // dragPx=0 at the OLD urlActiveIdx (strip snaps back to source tab),
  // then with the NEW urlActiveIdx (strip animates to target). That
  // two-stage flicker is exactly the "jerk on release" the user
  // reported. See
  // https://github.com/remix-run/react-router/issues/11003 for the
  // upstream non-batching discussion. Do NOT remove without measuring.
  // Cleared once the URL catches up (or after a safety timeout — see
  // the effect below).
  const [pendingTargetTab, setPendingTargetTab] = useState<Tab | null>(null);

  const setDragPx = useCallback((px: number, drag: boolean) => {
    setDragPxState(px);
    setDraggingState(drag);
  }, []);

  const destinationFor = useCallback(
    (tab: Tab): string => {
      if (tab === 'direct') return DIRECT_PATH;
      if (tab === 'bots') return BOTS_PATH;
      if (activeSpaceId) {
        const alias = getCanonicalAliasOrRoomId(mx, activeSpaceId);
        return getChannelsSpacePath(alias);
      }
      return CHANNELS_PATH;
    },
    [mx, activeSpaceId]
  );

  // Shared commit core. `instant=true` flips `instantSwitch` for one
  // frame so the strip's transform jump from old tab to new lands
  // without the swipe-finish CSS transition — used by tap paths
  // (static header Segments + per-pane Segments). `instant=false`
  // preserves the animation — used by the swipe gesture commit so
  // the strip smoothly completes the user's drag.
  const commitToInternal = useCallback(
    (idx: number, instant: boolean) => {
      const target = tabs[idx];
      if (!target) return;
      setDragPxState(0);
      setDraggingState(false);
      if (instant) setInstantSwitch(true);
      setPendingTargetTab(target);
      navigate(destinationFor(target), { replace: true });
    },
    [tabs, navigate, destinationFor]
  );

  const commitToSwipe = useCallback(
    (idx: number) => commitToInternal(idx, false),
    [commitToInternal]
  );

  // Tap entry by tab name. Exposed to per-pane Segments via
  // `MobilePagerPaneInfo.selectTabInstant` so the invisible per-pane
  // segment buttons (which capture taps when the static header isn't
  // z-elevated) route through the same instant commit path as the
  // static header's own segment buttons.
  const selectTabInstant = useCallback(
    (target: Tab) => {
      const i = tabs.indexOf(target);
      if (i >= 0) commitToInternal(i, true);
    },
    [tabs, commitToInternal]
  );

  const onSelectDirect = useCallback(() => selectTabInstant('direct'), [selectTabInstant]);
  const onSelectChannels = useCallback(() => selectTabInstant('channels'), [selectTabInstant]);
  const onSelectBots = useCallback(() => selectTabInstant('bots'), [selectTabInstant]);

  // Clear `instantSwitch` on the next paint frame so subsequent
  // transform changes use the normal animated transition again. rAF
  // (not setTimeout 0) so we cancel cleanly if a new commit re-arms
  // the flag before the frame fires.
  useEffect(() => {
    if (!instantSwitch) return undefined;
    const id = requestAnimationFrame(() => setInstantSwitch(false));
    return () => cancelAnimationFrame(id);
  }, [instantSwitch]);

  const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1;

  useEffect(() => {
    if (pendingTargetTab === null) return undefined;
    // Tab disappeared from the array mid-animation (e.g. /bots/ deep-
    // link held the Bots tab visible, the user committed to Direct,
    // and during the slide the bot config became empty so showBots
    // flipped to false). The stored target no longer maps to any
    // index — clear immediately so visualIdx falls back to urlActive.
    if (pendingTargetIdx === -1) {
      setPendingTargetTab(null);
      return undefined;
    }
    if (pendingTargetIdx === urlActiveIdx) {
      setPendingTargetTab(null);
      return undefined;
    }
    const id = window.setTimeout(() => setPendingTargetTab(null), PAGER_TRANSITION_MS + 100);
    return () => window.clearTimeout(id);
  }, [pendingTargetTab, pendingTargetIdx, urlActiveIdx]);

  const visualIdx = pendingTargetIdx >= 0 ? pendingTargetIdx : urlActiveIdx;
  const visualDragPx = pendingTargetTab !== null ? 0 : dragPx;

  // Suppress the pager gesture while ANY of:
  //   1. A horseshoe sheet is open (Settings or workspace switcher).
  //      A horizontal swipe on the sheet body, or on the still-
  //      visible listing above the sheet, would steer the pager into
  //      a sibling tab and unmount the sheet's host.
  //   2. A commit-slide animation is in flight (pendingTargetTab set).
  //      Starting a new gesture during the 280ms transition would
  //      either jump (because visualDragPx is forced to 0) or commit
  //      relative to a stale urlActiveIdx — same UX hazard React
  //      Navigation's TabView avoids by locking gestures during
  //      transitions.
  const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
  const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
  const gestureDisabled =
    settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null || asBackdrop;

  const rootRef = useRef<HTMLDivElement>(null);
  useMobileTabsPagerGesture({
    rootRef,
    activeIdx: urlActiveIdx,
    tabsCount: tabs.length,
    disabled: gestureDisabled,
    setDragPx,
    // Swipe commits keep the slide animation — the strip glides from
    // the drag-released position to the target tab. Tap commits use
    // `selectTabInstant` (passed down via per-pane context + the
    // static header's `onSelectXxx` props) which sets `instantSwitch`
    // for one frame to skip the transition.
    commitTo: commitToSwipe,
  });

  // Gap-aware strip transform. Each adjacent pane is offset by an
  // extra `PANE_GAP_PX` so a swipe past the gap zone exposes the
  // pagerRoot backdrop colour, matching the static header tone.
  // Memoised so the inline object identity is stable when dragPx
  // doesn't change — avoids extra child re-renders when other state
  // updates (e.g. atom subscription) tick the parent.
  //
  // `counterTx` is the exact negation of the strip's X — handed to the
  // strip-hosted refresh singleton as a PROP so its counter-transform
  // is a direct inline `transform` on a composited element, transitioned
  // with the IDENTICAL timing string below. Both transitions then run on
  // the COMPOSITOR in lockstep. (The previous design rode a registered
  // `--vojo-strip-tx` @property transition — custom-property
  // interpolation runs on the MAIN thread, and the `navigate()` commit
  // at slide start reliably blocks it for the first frames of every
  // swipe commit: the mascot visibly rode the strip, then snapped back.
  // Same-commit inline styles + compositor transitions remove that
  // failure mode entirely.)
  const stripTx = `calc(${-visualIdx * 100}vw - ${visualIdx * PANE_GAP_PX}px + ${visualDragPx}px)`;
  const counterTx = `calc(${visualIdx * 100}vw + ${visualIdx * PANE_GAP_PX}px - ${visualDragPx}px)`;
  // Transition suppressed while a finger drag is in flight (the strip
  // follows the finger 1:1) AND for the single frame after a tap-driven
  // commit (`instantSwitch`) so segment taps snap to the target tab
  // without the 280ms slide. Swipe commits leave `instantSwitch` false
  // so the slide-to-target animation plays. Shared verbatim with the
  // refresh singleton so the two transforms can never desync.
  const stripTransition =
    dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`;
  const stripStyle = useMemo<React.CSSProperties>(
    () => ({
      width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`,
      transform: `translate3d(${stripTx}, 0, 0)`,
      transition: stripTransition,
      gap: `${PANE_GAP_PX}px`,
    }),
    [tabs.length, stripTx, stripTransition]
  );

  // True from the moment a finger drag RELEASES (commit OR spring-back)
  // until the strip's ~PAGER_TRANSITION_MS slide has settled. Feeds the
  // refresh singleton's `pagerAnimating` gate: `pendingTargetTab` clears
  // as soon as the URL catches up (~1 frame after navigate), and a
  // spring-back release never sets it at all — yet both play a ≈280ms
  // slide during which a lingering mascot-card sliver could sweep past
  // the transparent inter-pane gap. Timer-based (not transitionend) so a
  // missed event can't strand the flag. Tap commits (instantSwitch) jump
  // without sliding and never pass through `dragging`, so they need no
  // hold.
  const [stripSettling, setStripSettling] = useState(false);
  const wasDraggingRef = useRef(false);
  useEffect(() => {
    if (dragging) {
      wasDraggingRef.current = true;
      setStripSettling(false);
      return undefined;
    }
    if (!wasDraggingRef.current) return undefined;
    wasDraggingRef.current = false;
    setStripSettling(true);
    const id = window.setTimeout(() => setStripSettling(false), PAGER_TRANSITION_MS + 100);
    return () => window.clearTimeout(id);
  }, [dragging]);

  // Strip-in-motion signal for the refresh singleton (gates only its
  // post-close card linger — see `pagerAnimating` on the component).
  const stripAnimating = dragging || pendingTargetTab !== null || stripSettling;

  // Per-pane context values memoised separately so each pane's
  // `useMobilePagerPane()` consumer (the inner StreamHeader) only
  // re-runs when ITS isActive flag toggles, not every time the parent
  // re-renders (e.g. on every touchmove during a drag). Without this,
  // a fresh `{ isActive: bool }` object per render would tick every
  // pane's context subscription at 60Hz during a swipe.
  const directIdx = useMemo(() => tabs.indexOf('direct'), [tabs]);
  const channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]);
  const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]);
  const directPaneInfo = useMemo(
    () => ({ isActive: urlActiveIdx === directIdx, selectTabInstant }),
    [urlActiveIdx, directIdx, selectTabInstant]
  );
  const channelsPaneInfo = useMemo(
    () => ({ isActive: urlActiveIdx === channelsIdx, selectTabInstant }),
    [urlActiveIdx, channelsIdx, selectTabInstant]
  );
  const botsPaneInfo = useMemo(
    () => ({ isActive: urlActiveIdx === botsIdx, selectTabInstant }),
    [urlActiveIdx, botsIdx, selectTabInstant]
  );

  // The static header doesn't need useMatch of its own — `urlActiveIdx`
  // is already the authoritative source of truth for which tab is
  // active. Map it back to a Tab name and pass down.
  const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct';

  // Memoised PANE ELEMENTS — referentially stable children let React
  // skip reconciling the three listing subtrees on every parent
  // re-render. Load-bearing for swipe smoothness: the drag updates
  // `dragPx` state on every touchmove, re-rendering this component at
  // input frequency (60–120 Hz on Android), and without stable
  // children each frame would also re-render Direct + Channels + Bots
  // (virtualized DM list included) in the same frame that has to
  // commit the strip transform.
  const directPane = useMemo(() => <Direct />, []);
  const channelsPane = useMemo(
    () => (
      <ChannelsModeProvider value>
        {activeSpace ? (
          <SpaceProvider key={activeSpace.roomId} value={activeSpace}>
            <Channels />
          </SpaceProvider>
        ) : (
          <ChannelsRootNav />
        )}
      </ChannelsModeProvider>
    ),
    [activeSpace]
  );
  const botsPane = useMemo(() => <Bots />, []);

  // Invalid URL space — defer to the existing route tree which
  // handles unjoined / unknown spaces via JoinBeforeNavigate. All
  // hooks above must run unconditionally for rules-of-hooks
  // compliance; this early-return is the first conditional render.
  // Suppressed when serving as a backdrop: there `<Outlet/>` is the chat
  // we're rendering BEHIND, so falling through would mount it twice. The
  // activeSpace resolver below degrades to a persisted/first-orphan space
  // (or ChannelsRootNav) which is a fine, non-interactive backdrop.
  if (channelsSpaceRoot && !urlSpaceIsValid && !asBackdrop) {
    return <Outlet />;
  }

  return (
    <div ref={rootRef} className={css.pagerRoot}>
      <MobileTabsPagerHeader
        showBots={showBots}
        activeTab={activeTab}
        onSelectDirect={onSelectDirect}
        onSelectChannels={onSelectChannels}
        onSelectBots={onSelectBots}
      />
      {/* `data-pager-pane="true"` flags everything inside the strip so
          per-pane background paints (PageNav-inner surface,
          MobileSettings / ChannelsWorkspace appBody, StreamHeader
          stage + header) collapse to transparent in pager mode. With
          both the strip and the static header at z-auto in pagerRoot,
          DOM order puts the strip on top — and with the strip's bg
          layers transparent, the static header tabs show through every
          pixel the curtain isn't covering. See `pagerStaticHeader` in
          style.css.ts for the full overlay contract. */}
      <div className={css.strip} style={stripStyle} data-pager-pane="true">
        {/* ONE screen-static pull-to-refresh chrome for the whole app —
            mascot card + radar rings — hosted INSIDE the strip's
            transform subtree (tile-memory contract) and counter-
            translated by `counterTx` (the strip X negated, same-commit
            inline style + identical compositor transition) so it never
            rides a pane. DOM-FIRST child: as the first positioned z:auto
            participant of the strip's stacking context it paints below
            every pane's positioned content — curtains and sheets cover
            it with no z-index machinery. See
            style.css.ts::pagerRefreshSingleton for the full contract.
            `pagerAnimating` gates only the post-close card linger (the
            gap sweep), never the live reveal. */}
        <PagerRefreshSingleton
          pagerAnimating={stripAnimating}
          counterTx={counterTx}
          transition={stripTransition}
        />
        <MobilePagerPaneProvider value={directPaneInfo}>
          <PaneSlot isActive={directPaneInfo.isActive}>{directPane}</PaneSlot>
        </MobilePagerPaneProvider>
        <MobilePagerPaneProvider value={channelsPaneInfo}>
          <PaneSlot isActive={channelsPaneInfo.isActive}>{channelsPane}</PaneSlot>
        </MobilePagerPaneProvider>
        {showBots && (
          <MobilePagerPaneProvider value={botsPaneInfo}>
            <PaneSlot isActive={botsPaneInfo.isActive}>{botsPane}</PaneSlot>
          </MobilePagerPaneProvider>
        )}
      </div>
    </div>
  );
}
