{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "site-header",
  "title": "site-header",
  "description": "The site header: a full-width bar that contracts into a floating frosted pill over the first 160px of scroll, with slots for the brand, centre links and actions, a menu button and a phone menu sheet. SiteHeaderMenu lives in its own file, so an app can load it on demand.",
  "dependencies": [
    "@radix-ui/react-slot@^1",
    "class-variance-authority@^0.7"
  ],
  "registryDependencies": [
    "@dwellir/dwellir-theme",
    "@dwellir/utils",
    "@dwellir/button",
    "@dwellir/sheet",
    "@dwellir/rune-icons"
  ],
  "files": [
    {
      "path": "components/brand/site-header.tsx",
      "content": "'use client';\n\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport {\n  type ComponentProps,\n  createContext,\n  type ReactNode,\n  type RefObject,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n} from 'react';\nimport { RuneMenu, RuneMoon, RuneSun } from '@/components/brand/rune-icons';\nimport { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\n\n/** Scroll distance in px over which the full-width bar becomes the pill. */\nconst SITE_HEADER_MORPH_RANGE = 160;\n\n/**\n * Marks the header with data-nav-top at the top of the page, where the clear\n * bar drops its blur. Where the browser has scroll-driven animations and the\n * range is the default, it marks the header data-nav-scroll, and CSS follows\n * the scroll on its own. Elsewhere this writes how far the page has scrolled\n * into the first `range` px, from 0 to 1, to the target's `--nav-morph` on\n * every scroll event, without a React render.\n * With `fixed`, it writes that value once and does not follow the scroll.\n * The caller owns the ref, so the hook never hands back a value to mutate.\n */\nfunction useScrollMorph(\n  target: RefObject<HTMLElement | null>,\n  range = SITE_HEADER_MORPH_RANGE,\n  fixed?: number,\n) {\n  useEffect(() => {\n    const node = target.current;\n    const write = (progress: number, timeline: boolean) => {\n      if (!timeline) node?.style.setProperty('--nav-morph', progress.toFixed(3));\n      /* At the top the pane is clear, so nav-bar drops its blur, which costs a frame to draw. */\n      node?.toggleAttribute('data-nav-top', progress === 0);\n    };\n    if (fixed !== undefined) {\n      write(fixed, false);\n      return;\n    }\n    const timeline =\n      range === SITE_HEADER_MORPH_RANGE && CSS.supports('animation-timeline', 'scroll()');\n    /* Hands the morph to CSS; set here, not in the server HTML, so pages do not inline its rule. */\n    node?.toggleAttribute('data-nav-scroll', timeline);\n    let last = -1;\n    const update = () => {\n      const progress = Math.min(1, Math.max(0, window.scrollY / range));\n      if (progress === last) return;\n      last = progress;\n      write(progress, timeline);\n    };\n    update();\n    window.addEventListener('scroll', update, { passive: true });\n    return () => {\n      window.removeEventListener('scroll', update);\n      node?.removeAttribute('data-nav-scroll');\n    };\n  }, [target, range, fixed]);\n}\n\n/** How far the scroll morph has moved an element along x. */\nconst movedBy = (element: HTMLElement) =>\n  Number.parseFloat(getComputedStyle(element).translate) || 0;\n\n/**\n * Writes the free room beside the brand's content in the full bar, past the\n * grid gap, on resize only. The brand link spans its column, and nav-bar\n * trims its end by what the pill takes, so it never reaches the next control.\n * Beside centre links it is `--nav-room`. Beside the actions it is\n * `--nav-slack`, and both sides close in; where the pill leaves less room,\n * nav-bar also stops the actions at the brand, as a bar laid out at the\n * pill's width would. Each edge is read without the\n * morph's translate, in fractional pixels: rounded offsets would add room\n * that a tight bar lacks.\n */\nfunction useNavSlack(bar: RefObject<HTMLElement | null>) {\n  useEffect(() => {\n    const nav = bar.current;\n    if (!nav || typeof ResizeObserver === 'undefined') return;\n    const measure = () => {\n      const [brand, next, ...rest] = [...nav.children].filter(\n        (child): child is HTMLElement => child instanceof HTMLElement && child.offsetWidth > 0,\n      );\n      const name = rest.length > 0 ? '--nav-room' : '--nav-slack';\n      nav.style.removeProperty(rest.length > 0 ? '--nav-slack' : '--nav-room');\n      if (!brand || !next) return;\n      const content = document.createRange();\n      content.selectNodeContents(brand);\n      const gap = Number.parseFloat(getComputedStyle(nav).columnGap) || 0;\n      const room =\n        next.getBoundingClientRect().left -\n        movedBy(next) -\n        (content.getBoundingClientRect().right - movedBy(brand)) -\n        gap;\n      nav.style.setProperty(name, `${Math.max(0, room)}px`);\n    };\n    const observer = new ResizeObserver(measure);\n    observer.observe(nav);\n    for (const child of nav.children) observer.observe(child);\n    return () => observer.disconnect();\n  }, [bar]);\n}\n\ntype SiteHeaderCollapse = 'sm' | 'md' | 'lg';\n\n/* The width under which the menu button shows. Apps hide their own wide-only actions at the same width. */\nconst CollapseContext = createContext<SiteHeaderCollapse>('lg');\n\nconst siteHeaderVariants = cva('nav-shell', {\n  variants: {\n    /* sticky: follows the page and morphs. static: in the flow, as on /design-system. */\n    position: { sticky: '', static: 'relative!' },\n  },\n  defaultVariants: { position: 'sticky' },\n});\n\n/*\n * From 1024px the bar widens. With centre links it centres them between the\n * brand and the actions, from the width where the menu button hides:\n * lg:nav-bar-wide carries that at 1024px, so the website's default adds no class.\n */\nconst siteHeaderBarVariants = cva('nav-bar items-center gap-4 text-foreground lg:nav-bar-wide', {\n  variants: {\n    collapse: { sm: 'sm:nav-bar-centre', md: 'md:nav-bar-centre', lg: '' },\n  },\n  defaultVariants: { collapse: 'lg' },\n});\n\ntype SiteHeaderProps = Omit<ComponentProps<'header'>, 'children'> &\n  VariantProps<typeof siteHeaderVariants> & {\n    /** The logo link, first in the bar. */\n    brand: ReactNode;\n    /** Links centred in the bar from the `collapse` width, such as the website's menus. */\n    children?: ReactNode;\n    /** The bar's end: theme switch, account links and the menu button. */\n    actions?: ReactNode;\n    /** The width under which SiteHeaderMenuButton shows. */\n    collapse?: SiteHeaderCollapse;\n    /** Names the bar's nav landmark. */\n    navLabel?: string;\n    /** Scroll distance in px over which the bar becomes the pill. */\n    morphRange?: number;\n    /** Holds the morph at one value from 0 (full bar) to 1 (pill), for demos. */\n    morph?: number;\n  };\n\n/**\n * The site header: an open, full-width bar at the top of the page that\n * contracts into a floating frosted pill over the first 160px of scroll, with\n * the same brand, links and actions. The bar's shape follows the scroll in CSS\n * (nav-shell and nav-bar) with transforms and opacity, so React does not\n * render per frame and the browser runs no layout.\n */\nfunction SiteHeader({\n  brand,\n  children,\n  actions,\n  collapse = 'lg',\n  navLabel = 'Primary',\n  morphRange,\n  morph,\n  position,\n  className,\n  ref,\n  ...props\n}: SiteHeaderProps) {\n  const shell = useRef<HTMLElement>(null);\n  const bar = useRef<HTMLElement>(null);\n  useScrollMorph(shell, morphRange, morph);\n  useNavSlack(bar);\n  /* A caller's ref gets the same element as the morph. */\n  useImperativeHandle(ref, () => shell.current as HTMLElement, []);\n  return (\n    <CollapseContext.Provider value={collapse}>\n      <header\n        ref={shell}\n        data-slot=\"site-header\"\n        data-position={position === 'static' ? 'static' : undefined}\n        className={cn(siteHeaderVariants({ position }), className)}\n        {...props}\n      >\n        <nav ref={bar} aria-label={navLabel} className={siteHeaderBarVariants({ collapse })}>\n          {brand}\n          {children}\n          <div className=\"flex items-center justify-end gap-1\">{actions}</div>\n        </nav>\n      </header>\n    </CollapseContext.Provider>\n  );\n}\n\n/** The brand link, first in the bar: pass the link, with its logo inside, as the child. A finger gets a 44px row. */\nfunction SiteHeaderBrand({ className, ...props }: ComponentProps<typeof Slot>) {\n  return (\n    <Slot\n      className={cn(\n        'flex shrink-0 items-center rounded-sm outline-none pointer-coarse:touch-min-height focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nconst menuButtonVariants = cva('', {\n  variants: {\n    collapse: { sm: 'sm:hidden', md: 'md:hidden', lg: 'lg:hidden' },\n  },\n});\n\n/**\n * Opens the phone menu (SiteHeaderMenu). It shows under the header's\n * `collapse` width. The app owns the open state, so it can load the menu on\n * demand.\n */\nfunction SiteHeaderMenuButton({\n  className,\n  children,\n  'aria-label': ariaLabel = 'Open menu',\n  ...props\n}: ComponentProps<typeof Button>) {\n  const collapse = useContext(CollapseContext);\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      aria-label={ariaLabel}\n      aria-haspopup=\"dialog\"\n      className={cn(menuButtonVariants({ collapse }), className)}\n      {...props}\n    >\n      {children ?? <RuneMenu aria-hidden=\"true\" className=\"size-5\" />}\n    </Button>\n  );\n}\n\n/**\n * A fixed 40px slot for the theme switch. The colour mode is known only in the\n * browser, so the switch renders after hydration inside a slot that holds its\n * place, and the bar does not shift. It passes the bar's gap on, so the switch\n * takes a 44px tap.\n */\nfunction SiteHeaderThemeSlot({ className, ...props }: ComponentProps<'span'>) {\n  return (\n    <span\n      className={cn('grid size-10 shrink-0 place-items-center touch-gap-through', className)}\n      {...props}\n    />\n  );\n}\n\ntype SiteHeaderThemeButtonProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {\n  /** The current colour mode. */\n  mode: 'light' | 'dark';\n  /** Switches to the other mode. */\n  onToggle: () => void;\n};\n\n/** Switches between light and dark. Pass an icon as the child to replace the sun and the moon. */\nfunction SiteHeaderThemeButton({ mode, onToggle, children, ...props }: SiteHeaderThemeButtonProps) {\n  const next = mode === 'dark' ? 'light' : 'dark';\n  return (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon\"\n      aria-label={`Switch to ${next} mode`}\n      onClick={onToggle}\n      {...props}\n    >\n      {children ??\n        (mode === 'dark' ? (\n          <RuneSun aria-hidden=\"true\" className=\"size-4.5\" />\n        ) : (\n          <RuneMoon aria-hidden=\"true\" className=\"size-4.5\" />\n        ))}\n    </Button>\n  );\n}\n\nexport {\n  SITE_HEADER_MORPH_RANGE,\n  SiteHeader,\n  SiteHeaderBrand,\n  type SiteHeaderCollapse,\n  SiteHeaderMenuButton,\n  SiteHeaderThemeButton,\n  SiteHeaderThemeSlot,\n  siteHeaderBarVariants,\n  siteHeaderVariants,\n  useScrollMorph,\n};\n",
      "type": "registry:component",
      "target": "components/brand/site-header.tsx"
    },
    {
      "path": "components/brand/site-header-menu.tsx",
      "content": "'use client';\n\nimport { cva } from 'class-variance-authority';\nimport type { MouseEvent, ReactNode, RefObject } from 'react';\nimport {\n  Sheet,\n  SheetBody,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from '@/components/ui/sheet';\n\n/* A top-level row of the menu: a page link, or a button such as Subscribe. */\nconst siteHeaderMenuRowVariants = cva(\n  'flex w-full items-center rounded-lg py-5 text-left font-display text-lg font-bold transition-colors duration-200 outline-none hover:text-accent-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid data-active:text-accent-ink',\n);\n\ntype SiteHeaderMenuProps = {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  /** The menu button in the bar: focus goes back to it when the sheet closes. */\n  trigger?: RefObject<HTMLButtonElement | null>;\n  /** The id the menu button's aria-controls names. */\n  id?: string;\n  title?: ReactNode;\n  /** Read by screen readers only, as the sheet's description. */\n  description: ReactNode;\n  /** The menu's rows: page links, groups, and app actions. */\n  children: ReactNode;\n  /** The sheet's foot: the theme switch and the account actions. */\n  footer?: ReactNode;\n  className?: string;\n};\n\n/**\n * The site header's menu under its collapse width: a sheet from the right with\n * the bar's links, then the theme switch and the account actions in its foot.\n * A press on any link closes it, since a link to the current page or a new tab\n * fires no route change. Keep this file out of the first load: import it when\n * the reader reaches for the menu button.\n */\nfunction SiteHeaderMenu({\n  open,\n  onOpenChange,\n  trigger,\n  id,\n  title = 'Menu',\n  description,\n  children,\n  footer,\n  className,\n}: SiteHeaderMenuProps) {\n  const closeOnLink = (event: MouseEvent) => {\n    if (event.target instanceof Element && event.target.closest('a')) onOpenChange(false);\n  };\n\n  return (\n    <Sheet open={open} onOpenChange={onOpenChange}>\n      <SheetContent\n        id={id}\n        className={className}\n        onClick={closeOnLink}\n        onCloseAutoFocus={\n          trigger\n            ? (event) => {\n                event.preventDefault();\n                trigger.current?.focus();\n              }\n            : undefined\n        }\n      >\n        <SheetHeader>\n          <SheetTitle>{title}</SheetTitle>\n          <SheetDescription className=\"sr-only\">{description}</SheetDescription>\n        </SheetHeader>\n        {/* The rows scroll; the title and the account actions stay in view. */}\n        <SheetBody>\n          <nav aria-label=\"Main menu\" data-slot=\"site-header-menu-nav\">\n            {children}\n          </nav>\n        </SheetBody>\n        {footer ? <SheetFooter className=\"flex-row items-center\">{footer}</SheetFooter> : null}\n      </SheetContent>\n    </Sheet>\n  );\n}\n\nexport { SiteHeaderMenu, siteHeaderMenuRowVariants };\n",
      "type": "registry:component",
      "target": "components/brand/site-header-menu.tsx"
    }
  ],
  "type": "registry:component"
}