{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-shell",
  "type": "registry:ui",
  "title": "App shell",
  "description": "The frame around an app's pages: a 240px sidebar that runs the full height of the page with its links kept in view, and a main area. On a phone the sidebar goes, a glass top bar holds a menu button, and the nav opens in the bottom sheet.",
  "dependencies": [
    "@radix-ui/react-dialog"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/dialog.json",
    "https://littmd.vercel.app/r/framed.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/app-shell.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/app-shell 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * AppShell: the frame around an app's pages. A sidebar for moving between them and a main area for the page.\n * Compose it from AppShell, SidebarNav and SidebarLink.\n *\n * - Desktop (components.md, App shell): a 240px sidebar on surface-1 with a line hairline on its edge. The\n *   sidebar COLUMN is as tall as the page, so it reaches the bottom of a long page (a full-page screenshot, a\n *   tall table). Its content is sticky and as tall as the window (`--shell-height`, 100svh by default), so the\n *   wordmark and the links stay in view while the page scrolls. The page scrolls as one document: there is no\n *   fixed-height shell with an inner scroller, which would hide everything below the first screen.\n * - Wordmark: the first row of the sidebar, as tall as the header (--litt-header-h), body size, fg.\n * - Links: 36px rows at the small size and the control radius, 12px side padding. The current page is raised\n *   (R3): the press material's block (--litt-block, --litt-block-lift) in fg, as a switch's block and the selected segment are,\n *   and aria-current=\"page\"; the others are fg-muted, with an fg-6% fill and brighter text on hover\n *   (instant tier, color only). No dots, no accent, no weight change. On a coarse pointer a row is 44px and a pressed row\n *   fills like a hovered one. Focus is an inset 2px ring, as in a list.\n * - Main: its own landmark, with the page gutter at the sides and space-8 above and below.\n *\n * On compact screens (useDevice().compact, so <DeviceProvider mode=\"phone\"> previews it) there is no sidebar. A\n * glass top bar, sticky at the safe-area inset like the Header, holds the wordmark and an icon-only menu button\n * (44px, aria-label \"Open navigation\", aria-expanded and aria-controls from Radix Dialog). It opens the same nav\n * in DialogSheet, the bottom sheet Dialog and Popover share: 44px rows, a heading, a close button, drag to close,\n * Esc, a tap on the scrim. Picking a link closes it. The sheet never uses up the view's expressive moment.\n * Before the page has hydrated a phone shows the top bar and no sidebar, by CSS (max-sm), so there is no flash.\n *\n * Motion: none of its own. The sheet's is the Dialog's.\n * - Flat: no sidebar or footer rule; the current row is a grey bubble and other rows take one on hover.\n */\nimport { createContext, useContext, useEffect, useMemo, useState, type ComponentProps, type CSSProperties, type ElementType, type MouseEvent, type ReactNode } from \"react\";\nimport * as RadixDialog from \"@radix-ui/react-dialog\";\nimport { useDevice } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { DialogSheet } from \"./dialog\";\nimport { useFramed } from \"./framed\";\nimport { MenuIcon, XIcon } from \"./icons\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\n/* Whether a link is drawn in the phone sheet, and how to close it. */\nconst ShellContext = createContext<{ sheet: boolean; close: () => void }>({ sheet: false, close: () => {} });\n\nexport type AppShellProps = {\n  /** The brand: usually a link home around the studio's name or a logo (a logo needs an aria-label). It shows in the sidebar and in the phone's top bar, so do not give it an id. */\n  wordmark: ReactNode;\n  /** The navigation: one or more SidebarNav. It is the sidebar's body on a desktop and the sheet's body on a phone. */\n  nav: ReactNode;\n  /** Pinned under the nav, such as the signed-in person or a theme switch. It shows in the sidebar and under the nav in the sheet. */\n  footer?: ReactNode;\n  /** The menu button's accessible name on a phone. */\n  menuLabel?: string;\n  /** The sheet's heading on a phone. It also names the sheet for screen readers. */\n  sheetTitle?: string;\n  /** How tall the window is, for the sticky sidebar. 100svh is right for a page. Set a fixed height such as \"34rem\" when the shell sits in a scrolling frame. */\n  viewportHeight?: string;\n  /**\n   * The page goes in the main landmark. Set false when the shell is shown inside another page that has its own main\n   * landmark, such as a preview in a docs site: the page area is then a div, so the page keeps one main. It is false\n   * by default inside a Framed, and true otherwise.\n   */\n  mainLandmark?: boolean;\n  /** Extra classes for the shell, such as a background. */\n  className?: string;\n  /** The page. It goes in the main landmark, which sets the gutter and the vertical space. */\n  children: ReactNode;\n};\n\n/** The shell. It needs no router and no provider. */\nexport function AppShell({\n  wordmark,\n  nav,\n  footer,\n  menuLabel = \"Open navigation\",\n  sheetTitle = \"Navigation\",\n  viewportHeight = \"100svh\",\n  mainLandmark,\n  className = \"\",\n  children,\n}: AppShellProps) {\n  const framed = useFramed();\n  const PageArea = (mainLandmark ?? !framed) ? \"main\" : \"div\";\n  const { compact } = useDevice();\n  const [open, setOpen] = useState(false);\n  /* A window that grows past the compact width has no sheet to leave open. */\n  useEffect(() => {\n    if (!compact) setOpen(false);\n  }, [compact]);\n  const inSheet = useMemo(() => ({ sheet: true, close: () => setOpen(false) }), []);\n\n  return (\n    <RadixDialog.Root open={open} onOpenChange={setOpen}>\n      <div\n        className={\"flex min-h-(--shell-height) w-full \" + (compact ? \"flex-col \" : \"max-sm:flex-col \") + className}\n        style={{ \"--shell-height\": viewportHeight } as CSSProperties}\n      >\n        {!compact && (\n          <div className=\"w-60 shrink-0 border-r border-litt-line bg-litt-surface-1 in-[[data-material=flat]]:border-r-0 max-sm:hidden\">\n            <div className=\"sticky top-0 flex h-(--shell-height) flex-col\">\n              <div className={wordmarkRow + \" px-6\"}>{wordmark}</div>\n              <div className=\"grid min-h-0 flex-1 content-start gap-6 overflow-y-auto px-3 pb-6\">{nav}</div>\n              {footer && <div className=\"shrink-0 border-t border-litt-line in-[[data-material=flat]]:border-t-0 p-3\">{footer}</div>}\n            </div>\n          </div>\n        )}\n\n        {/* The top bar: shown on compact screens, and hidden from 640 up until the page knows which one it is. */}\n        <div className={compact ? \"\" : \"sm:hidden\"}>\n          <header className=\"litt-glass sticky top-[env(safe-area-inset-top,0px)] z-30 rounded-none\">\n            <div className=\"flex h-(--litt-header-h) items-center justify-between gap-4 px-litt-gutter\">\n              <div className={wordmarkRow}>{wordmark}</div>\n              <RadixDialog.Trigger {...asChildProps}>\n                <Button iconOnly variant=\"ghost\" aria-label={menuLabel}>\n                  <MenuIcon />\n                </Button>\n              </RadixDialog.Trigger>\n            </div>\n          </header>\n        </div>\n\n        <PageArea className=\"min-w-0 flex-1 px-litt-gutter py-8\">{children}</PageArea>\n      </div>\n\n      {compact && (\n        <DialogSheet open={open} onDismiss={() => setOpen(false)} described={false}>\n          <div className=\"grid content-start pt-6 pr-4 pl-6\" style={{ gridArea: \"head\" }}>\n            <RadixDialog.Title className=\"text-litt-h4 text-litt-fg text-balance\">{sheetTitle}</RadixDialog.Title>\n          </div>\n          <div className=\"self-start pt-6 pr-4\" style={{ gridArea: \"close\" }}>\n            <RadixDialog.Close {...asChildProps}>\n              <Button\n                iconOnly\n                size=\"sm\"\n                variant=\"ghost\"\n                aria-label=\"Close\"\n                className=\"-mt-1 pointer-coarse:-mt-2.5 pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n              >\n                <XIcon />\n              </Button>\n            </RadixDialog.Close>\n          </div>\n          <div className=\"min-h-0 overflow-y-auto overscroll-contain px-3 pt-6 pb-(--sheet-foot)\" style={{ gridArea: \"body\" }}>\n            <ShellContext value={inSheet}>\n              <div className=\"grid content-start gap-6\">{nav}</div>\n              {footer && <div className=\"mt-6 border-t border-litt-line in-[[data-material=flat]]:border-t-0 pt-3\">{footer}</div>}\n            </ShellContext>\n          </div>\n        </DialogSheet>\n      )}\n    </RadixDialog.Root>\n  );\n}\n\nconst wordmarkRow =\n  \"flex h-(--litt-header-h) shrink-0 items-center text-litt-body font-litt-title text-litt-fg [&_a]:rounded-litt-control [&_a:focus-visible]:outline-2 \" +\n  \"[&_a:focus-visible]:outline-offset-4 [&_a:focus-visible]:outline-litt-focus pointer-coarse:[&_a]:inline-flex \" +\n  \"pointer-coarse:[&_a]:h-litt-control-md pointer-coarse:[&_a]:items-center\";\n\n/* -------------------------------------------------------------------------- */\n/* SidebarNav, SidebarLink                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport type SidebarNavProps = {\n  /** The landmark's name, such as \"Main\". Give each nav its own when there are several. */\n  label: string;\n  /** A small title over the group, such as \"Workspace\". Leave it out for the first group. */\n  heading?: string;\n  /** SidebarLinks. */\n  children: ReactNode;\n};\n\n/** A group of links in a nav landmark. */\nexport function SidebarNav({ label, heading, children }: SidebarNavProps) {\n  return (\n    <nav aria-label={label}>\n      {heading && <p className=\"px-3 pb-2 text-litt-caption text-litt-fg-subtle litt-legend\">{heading}</p>}\n      <ul className=\"grid gap-1\">{children}</ul>\n    </nav>\n  );\n}\n\n/* The links sit 4px apart; an invisible 2px past each edge covers the gap, so a pointer between two rows is always on\n   one and the hover never flickers off and on (no dead gaps). */\nconst row =\n  \"relative flex w-full items-center gap-3 rounded-litt-control px-3 whitespace-nowrap select-none after:absolute after:inset-x-0 after:-inset-y-0.5 \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-(--litt-dur-instant) ease-litt \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\nexport type SidebarLinkProps = Omit<ComponentProps<\"a\">, \"ref\"> & {\n  /** Marks the page you are on: sets aria-current=\"page\" and fills the row. Set it on at most one link. */\n  current?: boolean;\n  /** A 16px icon before the label. It is decorative, so the label must say everything. */\n  icon?: ReactNode;\n  /** The element to draw, for a router's link: `as={Link}`. It receives the same props as an anchor. */\n  as?: ElementType;\n  /** The label. Keep it to one or two short words. */\n  children: ReactNode;\n};\n\n/** One link in the nav. In the phone sheet it is 44px tall, and picking it closes the sheet. */\nexport function SidebarLink({ current = false, icon, as: Tag = \"a\", className = \"\", children, onClick, ...rest }: SidebarLinkProps) {\n  const { sheet, close } = useContext(ShellContext);\n  return (\n    <li>\n      <Tag\n        aria-current={current ? \"page\" : undefined}\n        className={\n          [\n            row,\n            sheet ? \"h-litt-control-md text-litt-body\" : \"h-9 text-litt-small pointer-coarse:h-litt-control-md\",\n            current\n              ? \"bg-litt-block text-litt-fg shadow-(--litt-block-lift) forced-colors:border\"\n              : \"text-litt-fg-muted hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] in-[[data-material=flat]]:hover:bg-litt-surface-3 hover:text-litt-fg \" +\n                \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] pointer-coarse:active:text-litt-fg\",\n            className,\n          ].join(\" \")\n        }\n        onClick={(e: MouseEvent<HTMLAnchorElement>) => {\n          onClick?.(e);\n          if (sheet) close();\n        }}\n        {...rest}\n      >\n        {icon && <span className=\"grid shrink-0 place-items-center text-litt-fg-subtle [&>svg]:size-4\">{icon}</span>}\n        <span className=\"litt-trim\">{children}</span>\n      </Tag>\n    </li>\n  );\n}\n",
      "target": "components/litt/app-shell.tsx"
    }
  ],
  "docs": "Import AppShell, SidebarNav and SidebarLink from components/litt/app-shell. Pass a wordmark, a nav and the page. Mark the current link with current. It needs no router: pass as={Link} to a SidebarLink to use yours.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
