{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-panes",
  "type": "registry:ui",
  "title": "AppPanes",
  "description": "The three-phase content row below a floating header: optional left sidebar, main region, optional right rail, each gated on the layout variants.",
  "categories": [
    "navigation",
    "layout"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/scroll-indicator.json",
    "https://whiskeyjack.net/r/use-layout-gate.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/app-panes.tsx",
      "type": "registry:ui",
      "target": "components/ui/app-panes.tsx",
      "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ScrollIndicator } from \"@/components/ui/scroll-indicator\";\nimport { useLayoutGate } from \"@/hooks/use-layout-gate\";\n\n/**\n * The three-phase content row that sits below a floating `AppHeader`.\n *\n * The phases are keyed to the `wide`/`xlwide` layout gates rather than plain\n * width breakpoints, so a portrait tablet keeps the tabbed layout and a\n * landscape one deploys the sidebar:\n *\n * - **below `wide`** -- a single centered column: just the main region.\n * - **`wide`..`xlwide`** -- the left sidebar appears and main shrinks to fill\n *   the rest with no right gap.\n * - **`xlwide`+** -- the right rail appears and main centers its capped content\n *   between the two.\n *\n * Both rails are **portal hosts**: the Layout owns the region, the page owns\n * what goes in it. A page reads the node from `useSidebarHost()` /\n * `useRailHost()` and `createPortal`s its content there, which keeps tab state\n * with the page that owns it.\n *\n * A rail whose `filled` is false collapses to zero width rather than\n * unmounting, so the portal host survives for a page that fills it a moment\n * later -- unmounting it produces a null host on the render that needed it.\n * `filled: 'auto'` lets a pane decide that for itself by watching its host for\n * children, for a Layout that would otherwise have to enumerate its pages.\n */\n\nconst SidebarHostContext = React.createContext<HTMLElement | null>(null);\nconst RailHostContext = React.createContext<HTMLElement | null>(null);\n\n/**\n * Resolves a pane's `filled`. A boolean passes through; `'auto'` watches the\n * host for children, which is how a Layout that does not know its pages' plans\n * still collapses an empty pane instead of holding the column open.\n *\n * **What this asks of a page**: whatever it portals here has to be there on its\n * FIRST render. A page that paints a loading placeholder first portals nothing,\n * so the column collapses to `w-0`, `main` starts at the screen edge, and\n * everything shifts right when the real render lands -- an arrival flash that\n * reads as a horizontal jump, and only at the widths where the pane deploys.\n * See \"Arriving at a page\" in `.claude/rules/web-apps.md`.\n */\nfunction usePaneFilled(host: HTMLElement | null, filled: boolean | \"auto\"): boolean {\n  const [observed, setObserved] = React.useState(false);\n\n  // A LAYOUT effect, because this decides whether a whole column exists. As a\n  // passive effect it ran after paint, so returning to a page painted once with\n  // the pane collapsed and the content full-width, then shifted when the page's\n  // portal filled the host -- a visible jump on every arrival, and only in the\n  // layouts that have the pane at all.\n  React.useLayoutEffect(() => {\n    if (filled !== \"auto\" || !host) {\n      setObserved(false);\n      return;\n    }\n    const sync = () => setObserved(host.childElementCount > 0);\n    sync();\n    const observer = new MutationObserver(sync);\n    observer.observe(host, { childList: true });\n    return () => observer.disconnect();\n  }, [host, filled]);\n\n  return filled === \"auto\" ? observed : filled;\n}\n\n/**\n * The left sidebar's DOM node, or **null below the `wide` gate**.\n *\n * The gate is read in JS as well as CSS precisely so this can be null there.\n * The `<aside>` itself stays mounted at every width -- an ancestor's\n * `display: none` never unmounts a node, and unmounting it deliberately would\n * produce a null host on exactly the render that needed it -- so a hook that\n * simply handed back the ref would be non-null on a phone, and a page asking\n * \"is there a sidebar to portal into?\" would get yes and portal its filters\n * into something nobody can see.\n */\nexport const useSidebarHost = () => React.useContext(SidebarHostContext);\n/** The right rail's DOM node, or **null below the `xlwide` gate**. See `useSidebarHost`. */\nexport const useRailHost = () => React.useContext(RailHostContext);\n\nexport interface AppPaneConfig {\n  /**\n   * Whether the page has content for this pane. False collapses it to zero\n   * width, keeping the host mounted.\n   *\n   * `'auto'` lets the pane answer for itself by watching its own host for\n   * children. Prefer it when the Layout would otherwise have to enumerate which\n   * pages fill which pane -- the Layout cannot read the host, since `AppPanes`\n   * owns that node and only hands it down. Pass a boolean when the Layout\n   * already knows (Icon Stack collapses both panes on a page with no source,\n   * before any page has rendered into them).\n   */\n  filled: boolean | \"auto\";\n  /**\n   * Tailwind width classes. Defaults suit a nav rail (`w-64 2xl:w-72`) and a\n   * content rail (`w-72 2xl:w-80`); override when a pane's content genuinely\n   * needs different room -- Icon Stack's preview rail runs wider so previews\n   * are not cramped.\n   */\n  width?: string;\n  /** Accessible label for the region (caller-translated). */\n  \"aria-label\"?: string;\n}\n\nexport interface AppPanesProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Left sidebar, gated on `wide`. Omit for a layout with no sidebar. */\n  sidebar?: AppPaneConfig;\n  /** Right rail, gated on `xlwide`. Omit for a layout with no rail. */\n  rail?: AppPaneConfig;\n  /**\n   * Distance from the top of the row to the first item, clearing the floating\n   * header. Default `5.5rem` puts the first rail item on the same line as the\n   * main region's first card for a 64px header.\n   */\n  railPaddingTop?: string;\n  /** `topOffset` for each rail's bounded ScrollIndicator. Default 68. */\n  indicatorTopOffset?: number;\n  /** The main region -- normally an `AppMain`. */\n  children: React.ReactNode;\n}\n\nexport const AppPanes = React.forwardRef<HTMLDivElement, AppPanesProps>(\n  function AppPanes(\n    {\n      sidebar,\n      rail,\n      railPaddingTop = \"5.5rem\",\n      indicatorTopOffset = 68,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) {\n    const [sidebarHost, setSidebarHost] = React.useState<HTMLElement | null>(null);\n    const [railHost, setRailHost] = React.useState<HTMLElement | null>(null);\n    const sidebarRef = React.useRef<HTMLElement | null>(null);\n    const railRef = React.useRef<HTMLElement | null>(null);\n    const sidebarFilled = usePaneFilled(sidebarHost, sidebar?.filled ?? false);\n    const railFilled = usePaneFilled(railHost, rail?.filled ?? false);\n\n    // The panes are gated in CSS (`hidden wide:block`), which hides them without\n    // unmounting the host. The same gates are read here so the CONTEXTS can be\n    // null below them: a page branching on `useRailHost()` is asking whether\n    // there is a rail on screen, and the ref alone answers yes on a phone.\n    // SSR-safe -- both are false with no window, which is the narrow layout.\n    const sidebarDeployed = useLayoutGate(\"wide\");\n    const railDeployed = useLayoutGate(\"xlwide\");\n\n    return (\n      <SidebarHostContext.Provider value={sidebarDeployed ? sidebarHost : null}>\n        <RailHostContext.Provider value={railDeployed ? railHost : null}>\n          <div\n            ref={ref}\n            className={cn(\n              // Side env() padding clears the notch in phone landscape, 0 elsewhere.\n              \"flex-1 flex min-h-0 pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]\",\n              className,\n            )}\n            {...props}\n          >\n            {sidebar && (\n              <div\n                className={cn(\n                  \"hidden wide:block relative shrink-0\",\n                  // Both widths are definite, so the pane eases open and shut\n                  // instead of snapping. Deploying a sidebar reflows the main\n                  // region beside it, and an instant jump there reads as the\n                  // content having been replaced rather than moved.\n                  \"transition-[width] duration-300 ease-out motion-reduce:transition-none\",\n                  sidebarFilled\n                    ? (sidebar.width ?? \"w-64 2xl:w-72\")\n                    : \"w-0 overflow-hidden\",\n                )}\n              >\n                <aside\n                  ref={(el) => {\n                    sidebarRef.current = el;\n                    setSidebarHost(el);\n                  }}\n                  aria-label={sidebar[\"aria-label\"]}\n                  className=\"h-full overflow-y-auto px-6 pb-8 scrollbar-hide\"\n                  style={{ paddingTop: railPaddingTop }}\n                />\n                {sidebarFilled && (\n                  <ScrollIndicator scrollRef={sidebarRef} topOffset={indicatorTopOffset} />\n                )}\n              </div>\n            )}\n\n            {children}\n\n            {rail && (\n              <div\n                className={cn(\n                  \"hidden xlwide:block relative shrink-0\",\n                  \"transition-[width] duration-300 ease-out motion-reduce:transition-none\",\n                  railFilled ? (rail.width ?? \"w-72 2xl:w-80\") : \"w-0 overflow-hidden\",\n                )}\n              >\n                <aside\n                  ref={(el) => {\n                    railRef.current = el;\n                    setRailHost(el);\n                  }}\n                  aria-label={rail[\"aria-label\"]}\n                  className=\"h-full overflow-y-auto px-6 pb-8 scrollbar-hide\"\n                  style={{ paddingTop: railPaddingTop }}\n                />\n                {railFilled && (\n                  <ScrollIndicator scrollRef={railRef} topOffset={indicatorTopOffset} />\n                )}\n              </div>\n            )}\n          </div>\n        </RailHostContext.Provider>\n      </SidebarHostContext.Provider>\n    );\n  },\n);\n\nAppPanes.displayName = \"AppPanes\";\n"
    }
  ],
  "docs": "Sits inside AppShell, below AppHeader, wrapping AppMain. Both rails are portal hosts: the layout owns the region, the page owns its content via useSidebarHost() / useRailHost() and createPortal, which keeps tab state with the page. Gate on `wide`/`xlwide` rather than lg:/xl: so a portrait tablet keeps the tabbed layout and a landscape one deploys the sidebar. A rail with filled=false collapses to zero width rather than unmounting, so the host survives for a page that fills it a moment later. Use filled='auto' when the layout would otherwise have to enumerate which pages fill which pane: the pane watches its own host for children and collapses itself, which the layout cannot do because AppPanes owns that node. Layout stays in CSS wherever it can -- render both a TabBar and SidebarTabs and let the variants hide one; useLayoutGate is for the rare case one expensive component has to move between the two regions.",
  "meta": {
    "group": "navigation",
    "related": [
      "app-shell",
      "scroll-indicator",
      "sidebar-tabs",
      "use-layout-gate"
    ],
    "exports": [
      "AppPanes",
      "useSidebarHost",
      "useRailHost"
    ],
    "siteSlug": "app-panes"
  }
}
