{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-shell",
  "type": "registry:ui",
  "title": "AppShell",
  "description": "The app layout skeleton: a fixed-height shell or a document scroller, with a frosted header, nav pills, and the main scroll region.",
  "categories": [
    "navigation",
    "layout"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/merge-refs.json",
    "https://whiskeyjack.net/r/scroll-indicator.json",
    "https://whiskeyjack.net/r/use-nav-reset.json",
    "https://whiskeyjack.net/r/use-reduced-motion.json",
    "https://whiskeyjack.net/r/use-scroll-region.json",
    "https://whiskeyjack.net/r/use-sliding-indicator.json",
    "https://whiskeyjack.net/r/use-travel-direction.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/app-shell.tsx",
      "type": "registry:ui",
      "target": "components/ui/app-shell.tsx",
      "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ScrollIndicator } from \"@/components/ui/scroll-indicator\";\nimport { ScrollRegionProvider, type ScrollRegion } from \"@/hooks/use-scroll-region\";\nimport { NavResetProvider, useNavDoubleActivate } from \"@/hooks/use-nav-reset\";\nimport { useSlidingIndicator } from \"@/hooks/use-sliding-indicator\";\nimport { useTravelDirection } from \"@/hooks/use-travel-direction\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useMergedRefs } from \"@/lib/merge-refs\";\n\n/**\n * The app shell skeleton shared by every Whiskeyjack app: a root container,\n * the frosted desktop header (floating or sticky), the standard header nav\n * pills, and the main scroll region. Composable slots rather than a\n * monolith -- app-specific chrome (Tauri window controls, sidebar rails,\n * drawers) stays in the app and nests between these pieces.\n *\n * Two scroll models (see `.claude/rules/web-apps.md`, \"Layout shell\"):\n * - `shell`: fixed-height column (`h-dvh overflow-hidden`); ONLY the AppMain\n *   scroller moves. Pair with AppHeader `floating` + AppMain `self`.\n * - `document`: the page itself scrolls (content sites, simple apps). Pair\n *   with AppHeader `sticky` + AppMain `document`.\n */\n\n// -- AppShell ----------------------------------------------------------------\n\nexport interface AppShellProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** 'shell': fixed h-dvh column, main scrolls. 'document': page scrolls. */\n  scroll?: \"shell\" | \"document\";\n}\n\nexport const AppShell = React.forwardRef<HTMLDivElement, AppShellProps>(\n  ({ scroll = \"shell\", className, children, ...props }, ref) => (\n    // The nav-reset slot wraps everything, so the Layout's nav chrome and the\n    // page that registers its start are both below it.\n    <NavResetProvider>\n      <div\n        ref={ref}\n        className={cn(\n          \"flex flex-col\",\n          scroll === \"shell\"\n            ? \"h-dvh overflow-hidden relative\"\n            : \"min-h-screen pb-24 md:pb-0\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    </NavResetProvider>\n  ),\n);\nAppShell.displayName = \"AppShell\";\n\n// -- AppHeader ---------------------------------------------------------------\n\nexport interface AppHeaderProps extends React.HTMLAttributes<HTMLElement> {\n  /**\n   * 'floating': absolute over the scroller(s) -- content scrolls behind the\n   * frost; scrollers reserve the header height (md:pt-14 / md:pt-16).\n   * 'sticky': in-flow sticky bar for document-scroll apps.\n   */\n  variant?: \"floating\" | \"sticky\";\n  /**\n   * 'contained': centered title-bar (max-w 800). 'full': edge-to-edge.\n   * 'none': no width/padding classes on the row -- supply them via\n   * `rowClassName` (Tauri headers with window-control clearance vars).\n   */\n  width?: \"contained\" | \"full\" | \"none\";\n  /** Header row height in Tailwind units. 14 = 56px, 16 = 64px. */\n  height?: 14 | 16;\n  /** Right side of the bar (action pill, toggle, ...). */\n  toolbar?: React.ReactNode;\n  /**\n   * Absolutely-positioned chrome inside the header but outside the content\n   * row -- the Tauri extension point (window-control containers pinned to the\n   * window edges while the row clears them via the --wc-* vars).\n   */\n  chrome?: React.ReactNode;\n  /** Extra classes for the inner content row (merged after the width preset). */\n  rowClassName?: string;\n}\n\nexport const AppHeader = React.forwardRef<HTMLElement, AppHeaderProps>(\n  (\n    {\n      variant = \"floating\",\n      width = \"contained\",\n      height = 16,\n      toolbar,\n      chrome,\n      rowClassName,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) => (\n    <header\n      ref={ref}\n      className={cn(\n        \"hidden md:block z-50 wj-frost-blur border-b border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]\",\n        variant === \"floating\" ? \"absolute top-0 inset-x-0\" : \"sticky top-0\",\n        className,\n      )}\n      style={{ background: \"var(--header-bg)\" }}\n      {...props}\n    >\n      {chrome}\n      <div\n        className={cn(\n          \"flex items-center justify-between gap-4\",\n          height === 16 ? \"h-16\" : \"h-14\",\n          width === \"contained\" && \"max-w-[800px] mx-auto w-full px-4\",\n          width === \"full\" && \"w-full px-4 lg:px-6\",\n          rowClassName,\n        )}\n      >\n        {children}\n        {toolbar}\n      </div>\n    </header>\n  ),\n);\nAppHeader.displayName = \"AppHeader\";\n\n// -- HeaderNav ---------------------------------------------------------------\n\nexport interface HeaderNavItem {\n  key: string;\n  /** Passed to the link component's `to` prop. */\n  to: string;\n  label: React.ReactNode;\n  active: boolean;\n  renderIcon: (args: { active: boolean; size: number }) => React.ReactNode;\n}\n\nexport interface HeaderNavProps extends React.HTMLAttributes<HTMLElement> {\n  items: HeaderNavItem[];\n  /** Router link component (e.g. react-router's Link) -- same injection as MobileBottomNav. */\n  linkComponent: React.ElementType;\n  /**\n   * The app's scroll region, for double-clicking the active item to return to\n   * the top (then to the page's start -- see `useRegisterNavReset`). Pass the\n   * same ref given to `AppMain`; omit it for a document-scroll app. Taken as a\n   * ref for the reason `useScrollReset` does: this nav renders above the scroll\n   * region `AppMain` publishes, so it cannot read it from context.\n   */\n  scrollRef?: React.RefObject<HTMLElement | null>;\n}\n\nexport const HeaderNav = React.forwardRef<HTMLElement, HeaderNavProps>(function HeaderNav(\n  { items, linkComponent: LinkComp, scrollRef, className, ...props },\n  ref,\n) {\n  const onActivate = useNavDoubleActivate(scrollRef);\n  const navRef = React.useRef<HTMLElement>(null);\n  const mergedRef = useMergedRefs(navRef, ref);\n  const reduced = useReducedMotion();\n  // The highlight travels between items instead of appearing on the new one,\n  // the horizontal read of the SidebarTabs rail. Desktop only by construction:\n  // MobileBottomNav is its own component and shares nothing here.\n  const activeKey = items.find((i) => i.active)?.key;\n  const indicator = useSlidingIndicator(navRef, activeKey, {\n    attribute: \"data-nav-key\",\n    changeKey: items.length,\n  });\n  // Which edge leads depends on the direction of travel, so `.wj-nav-highlight`\n  // names it -- the same stretch the SegmentedControl pill takes.\n  const direction = useTravelDirection(items.findIndex((i) => i.active));\n  return (\n    <nav ref={mergedRef} className={cn(\"relative flex items-center gap-1\", className)} {...props}>\n      {indicator && (\n        <span\n          aria-hidden\n          data-dir={direction}\n          className={cn(\n            // inset-y-0 is the item height: every item carries the same\n            // min-height and padding, so the nav is exactly as tall as one.\n            \"pointer-events-none absolute inset-y-0 z-0 rounded-lg\",\n            \"bg-[var(--wj-nav-active)] shadow-bevel\",\n            // Positioned by both edges, so `.wj-nav-highlight` can give each its\n            // own easing and the highlight stretches through the travel.\n            !reduced && \"wj-nav-highlight\",\n          )}\n          style={{\n            left: indicator.offset,\n            right: indicator.extent - (indicator.offset + indicator.size),\n          }}\n        />\n      )}\n      {items.map((item) => (\n        <LinkComp\n          key={item.key}\n          to={item.to}\n          data-nav-key={item.key}\n          onClick={() => onActivate(item.active)}\n          aria-current={item.active ? \"page\" : undefined}\n          className={cn(\n            // touch-manipulation: the active item takes a double-click/tap\n            // (useNavDoubleActivate), which Safari would read as zoom on a\n            // zoomable page. Pinch-zoom is unaffected.\n            // z-10 so the label rides above the travelling highlight, and\n            // wj-nav-handoff fades this item's own fill out as that\n            // highlight crosses to reach it.\n            \"wj-nav-handoff relative z-10 flex items-center gap-2 min-h-control-md px-3 py-2 rounded-lg text-sm font-medium transition-colors wj-focus-ring touch-manipulation\",\n            item.active\n              ? \"text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]\"\n              : \"text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)] hover:bg-[var(--wj-nav-active)]\",\n          )}\n        >\n          {item.renderIcon({ active: item.active, size: 20 })}\n          <span>{item.label}</span>\n        </LinkComp>\n      ))}\n    </nav>\n  );\n});\nHeaderNav.displayName = \"HeaderNav\";\n\n// -- AppMain -----------------------------------------------------------------\n\nexport interface AppMainProps extends React.HTMLAttributes<HTMLElement> {\n  /**\n   * 'self': the main element is the scroller (shell model) -- hides its native\n   * scrollbar, ships a bounded ScrollIndicator, and carries `overscroll-none`\n   * so a touch overscroll cannot rubber-band the shell or trigger the\n   * browser's pull-to-refresh (a caller's own `overscroll-*` class wins).\n   * 'document': plain flex-1 region, the page scrolls.\n   */\n  scroll?: \"self\" | \"document\";\n  /**\n   * ScrollIndicator top offset (header height + ~4px: 60 for h-14, 68 for\n   * h-16). Only used with scroll='self'.\n   */\n  indicatorTopOffset?: number;\n  /**\n   * Hide the native scrollbar at every width (default). Pass false when the\n   * app gates the hiding itself (Chip Away hides it md+ only via CSS, keeping\n   * the native overlay indicator on phones).\n   */\n  hideScrollbar?: boolean;\n  /**\n   * Hide the bounded ScrollIndicator below `md` (default `true`), where a\n   * touch viewport's own overlay scrollbar is the better affordance.\n   *\n   * Pass `false` for a real desktop WINDOW, which can be resized narrower than\n   * `md` while still being a desktop: the default would hand it back the native\n   * scrollbar, which then runs the full height behind a floating header --\n   * exactly what the indicator exists to avoid. Width is not a proxy for\n   * platform once the app ships as a resizable window.\n   */\n  indicatorHideBelowMd?: boolean;\n}\n\nexport const AppMain = React.forwardRef<HTMLElement, AppMainProps>(\n  (\n    {\n      scroll = \"self\",\n      indicatorTopOffset = 68,\n      hideScrollbar = true,\n      indicatorHideBelowMd = true,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const innerRef = React.useRef<HTMLElement | null>(null);\n    const setRefs = (el: HTMLElement | null) => {\n      innerRef.current = el;\n      if (typeof ref === \"function\") ref(el);\n      else if (ref) ref.current = el;\n    };\n\n    // Which element \"scroll to the top\" means is a choice this component just\n    // made on the app's behalf, so it is also this component's job to publish\n    // the answer -- a page has no way to know, and `window.scrollTo` is wrong\n    // here without saying so. See `useScrollReset`.\n    const region = React.useMemo<ScrollRegion>(\n      () => ({\n        scrollToTop: () => {\n          if (scroll === \"document\") window.scrollTo(0, 0);\n          else if (innerRef.current) innerRef.current.scrollTop = 0;\n        },\n        getElement: () => (scroll === \"document\" ? null : innerRef.current),\n      }),\n      [scroll],\n    );\n\n    if (scroll === \"document\") {\n      return (\n        <main\n          ref={setRefs}\n          data-app-main\n          tabIndex={-1}\n          className={cn(\"flex-1 focus:outline-none\", className)}\n          {...props}\n        >\n          <ScrollRegionProvider value={region}>{children}</ScrollRegionProvider>\n        </main>\n      );\n    }\n\n    return (\n      <>\n        <main\n          ref={setRefs}\n          // Stable hook for chrome that must reserve space at the top of the\n          // content region from outside React -- the Tauri pack's sub-md\n          // window-control strip is the case. Same idiom as TabBar's\n          // `data-tab-bar`; the attribute is API, so it does not move.\n          data-app-main\n          tabIndex={-1}\n          className={cn(\n            \"flex-1 min-w-0 overflow-y-auto overscroll-none pb-24 md:pb-0 focus:outline-none\",\n            hideScrollbar && \"scrollbar-hide\",\n            className,\n          )}\n          {...props}\n        >\n          <ScrollRegionProvider value={region}>{children}</ScrollRegionProvider>\n        </main>\n        <ScrollIndicator\n          scrollRef={innerRef}\n          topOffset={indicatorTopOffset}\n          hideBelowMd={indicatorHideBelowMd}\n        />\n      </>\n    );\n  },\n);\nAppMain.displayName = \"AppMain\";\n"
    }
  ],
  "docs": "AppShell is the root. Use scroll=\"shell\" for a fixed h-dvh column where only AppMain scrolls, or scroll=\"document\" for a content site. AppHeader floats over the scrollers (variant=\"floating\") or sits sticky in flow; a floating header means each scroller reserves md:pt-14 or md:pt-16 for its height. AppMain with scroll=\"self\" is the scroller and carries overscroll-none itself; pass your own overscroll-* class to change it. Tauri apps hang window controls off AppHeader's `chrome` slot.",
  "meta": {
    "group": "navigation",
    "related": [
      "scroll-indicator",
      "mobile-bottom-nav",
      "use-route-focus"
    ],
    "exports": [
      "AppShell",
      "AppHeader",
      "HeaderNav",
      "AppMain"
    ],
    "siteSlug": "app-shell"
  }
}
