{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toc-minimap",
  "title": "TOC Minimap",
  "description": "A scrubbing table-of-contents rail with scroll spy, section previews, keyboard and screen-reader support, and a mobile scrub bar with an expandable section list. No dependencies beyond Tailwind and the cn utility.",
  "files": [
    {
      "path": "registry/toc-minimap/toc-minimap.tsx",
      "content": "'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type Dispatch,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type SetStateAction,\n} from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface TocMinimapItem {\n  id: string;\n  text: string;\n  // Heading depth; items deeper than level 2 are indented in the mobile list panel.\n  level?: number;\n  // Optional snippet shown under the section title in the tooltip.\n  preview?: string | null;\n}\n\nexport interface TocMinimapProps {\n  items: TocMinimapItem[];\n  // Viewport-top inset in px treated as covered (e.g. a sticky header) by the scroll spy.\n  scrollOffset?: number;\n  // Runs the scroll spy and jumps against this scrollable element in place of the window.\n  containerSelector?: string;\n  // Replaces the default scroll-into-view and hash update when a section is chosen.\n  onSelect?: (item: TocMinimapItem) => void;\n  // Desktop rail wrapper.\n  className?: string;\n  // Mobile rail wrapper.\n  mobileClassName?: string;\n  tooltipClassName?: string;\n  panelClassName?: string;\n}\n\nconst TOC_MINIMAP_ITEM_SPACING = 8;\nconst TOC_MINIMAP_MAX_HEIGHT_CSS = 'calc(100vh - 18rem)';\nconst TOC_MINIMAP_MOBILE_ITEM_SPACING = 10;\nconst TOC_MINIMAP_MOBILE_MAX_WIDTH_CSS = 'calc(100vw - 7rem)';\n// Pointer movement under this many pixels between down and up counts as a tap, not a scrub.\nconst TOC_MINIMAP_TAP_THRESHOLD_PX = 8;\n\n// Shared by the vertical (desktop) and horizontal (mobile) rails: natural size grows with item count, capped by the viewport-relative CSS max.\nexport function resolveTocMinimapNaturalSizeStyle(\n  itemCount: number,\n  spacing: number,\n  maxSizeCss: string\n): string {\n  const naturalSize = Math.max(1, (itemCount - 1) * spacing);\n  return `min(${naturalSize}px, ${maxSizeCss})`;\n}\n\nfunction resolveTocMinimapHeightStyle(itemCount: number): string {\n  return resolveTocMinimapNaturalSizeStyle(\n    itemCount,\n    TOC_MINIMAP_ITEM_SPACING,\n    TOC_MINIMAP_MAX_HEIGHT_CSS\n  );\n}\n\nfunction resolveTocMinimapMobileWidthStyle(itemCount: number): string {\n  return resolveTocMinimapNaturalSizeStyle(\n    itemCount,\n    TOC_MINIMAP_MOBILE_ITEM_SPACING,\n    TOC_MINIMAP_MOBILE_MAX_WIDTH_CSS\n  );\n}\n\nexport function resolveTocMinimapTopPercent(\n  index: number,\n  itemCount: number\n): number {\n  if (itemCount <= 1) return 0;\n  return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100;\n}\n\n// Shared by the vertical (desktop) and horizontal (mobile) rails: maps a pointer position along the rail's axis to the nearest item index.\nexport function resolveTocMinimapIndexFromPointerPos(input: {\n  itemCount: number;\n  railStart: number;\n  railSize: number;\n  pointerPos: number;\n}): number | null {\n  if (input.itemCount <= 0 || input.railSize <= 0) return null;\n  if (input.itemCount === 1) return 0;\n  const progress = Math.max(\n    0,\n    Math.min(1, (input.pointerPos - input.railStart) / input.railSize)\n  );\n  return Math.max(\n    0,\n    Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))\n  );\n}\n\n// Shared by the desktop and mobile active-index state: steps the active index by delta, falling back to the current visible section when nothing is active yet, clamped to the item range.\nfunction moveTocMinimapActiveIndex(\n  setIndex: Dispatch<SetStateAction<number | null>>,\n  itemCount: number,\n  currentIndex: number,\n  delta: number\n): void {\n  setIndex((current) => {\n    const base = current ?? Math.max(currentIndex, 0);\n    return Math.max(0, Math.min(itemCount - 1, base + delta));\n  });\n}\n\n// True on devices with a real hover pointer. Gates the horizontal rail between hover scrubbing (mouse) and drag scrubbing with a tap popup (touch).\nfunction useFinePointer(): boolean {\n  const [finePointer, setFinePointer] = useState(false);\n\n  useEffect(() => {\n    const mq = window.matchMedia('(hover: hover) and (pointer: fine)');\n    const update = () => setFinePointer(mq.matches);\n    update();\n    mq.addEventListener('change', update);\n    return () => mq.removeEventListener('change', update);\n  }, []);\n\n  return finePointer;\n}\n\nexport function TocMinimap({\n  items,\n  scrollOffset = 80,\n  containerSelector,\n  onSelect,\n  className,\n  mobileClassName,\n  tooltipClassName,\n  panelClassName,\n}: TocMinimapProps) {\n  const [activeIndex, setActiveIndex] = useState<number | null>(null);\n  // Separate active index for the mobile scrub rail so desktop hover and mobile touch cannot interfere.\n  const [mobileActiveIndex, setMobileActiveIndex] = useState<number | null>(\n    null\n  );\n  const [visibleIndexes, setVisibleIndexes] = useState<ReadonlySet<number>>(\n    new Set()\n  );\n  // Whether the mobile tap-to-expand section list panel is open.\n  const [mobileExpanded, setMobileExpanded] = useState(false);\n  // Tracks the pointerId captured by the mobile rail so stray pointermove events from other pointers are ignored.\n  const capturedPointerId = useRef<number | null>(null);\n  // Pointerdown position on the mobile rail, used to tell a tap from a scrub at pointerup.\n  const mobilePointerDownPos = useRef<{ x: number; y: number } | null>(null);\n  const finePointer = useFinePointer();\n\n  const selectItem = useCallback(\n    (item: TocMinimapItem) => {\n      if (onSelect) {\n        onSelect(item);\n        return;\n      }\n      // getElementById resolves the first element with a given id, so duplicate heading ids scroll to the first occurrence, matching native anchor behavior.\n      const el = document.getElementById(item.id);\n      if (!el) return;\n      const behavior: ScrollBehavior = window.matchMedia(\n        '(prefers-reduced-motion: reduce)'\n      ).matches\n        ? 'auto'\n        : 'smooth';\n      const container = containerSelector\n        ? document.querySelector(containerSelector)\n        : null;\n      if (container) {\n        // Scrolls only the container and skips the hash update, since the target is embedded content.\n        const delta =\n          el.getBoundingClientRect().top -\n          container.getBoundingClientRect().top;\n        container.scrollTo({ top: container.scrollTop + delta, behavior });\n        return;\n      }\n      el.scrollIntoView({ behavior, block: 'start' });\n      history.replaceState(null, '', `#${item.id}`);\n    },\n    [onSelect, containerSelector]\n  );\n\n  // A section spans from its heading to the next one; every span overlapping the view is highlighted.\n  useEffect(() => {\n    if (items.length < 2) return;\n\n    const container = containerSelector\n      ? document.querySelector(containerSelector)\n      : null;\n    const headings = items.map((item) => document.getElementById(item.id));\n    let frame = 0;\n\n    const update = () => {\n      frame = 0;\n      const containerRect = container?.getBoundingClientRect();\n      const viewTop = (containerRect?.top ?? 0) + scrollOffset;\n      const viewBottom = containerRect?.bottom ?? window.innerHeight;\n      const tops = headings.map((el) =>\n        el ? el.getBoundingClientRect().top : null\n      );\n      const next = new Set<number>();\n      for (let i = 0; i < tops.length; i += 1) {\n        const top = tops[i];\n        if (top === null) continue;\n        let end = Number.POSITIVE_INFINITY;\n        for (let j = i + 1; j < tops.length; j += 1) {\n          const candidate = tops[j];\n          if (candidate !== null) {\n            end = candidate;\n            break;\n          }\n        }\n        if (top < viewBottom && end > viewTop) next.add(i);\n      }\n      setVisibleIndexes((prev) => {\n        if (prev.size === next.size && [...next].every((i) => prev.has(i))) {\n          return prev;\n        }\n        return next;\n      });\n    };\n\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n\n    update();\n    const scrollTarget = container ?? window;\n    scrollTarget.addEventListener('scroll', onScroll, { passive: true });\n    window.addEventListener('resize', onScroll);\n    return () => {\n      if (frame) cancelAnimationFrame(frame);\n      scrollTarget.removeEventListener('scroll', onScroll);\n      window.removeEventListener('resize', onScroll);\n    };\n  }, [items, scrollOffset, containerSelector]);\n\n  // Escape closes the panel regardless of which element inside it has focus.\n  useEffect(() => {\n    if (!mobileExpanded) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') setMobileExpanded(false);\n    };\n    window.addEventListener('keydown', onKeyDown);\n    return () => window.removeEventListener('keydown', onKeyDown);\n  }, [mobileExpanded]);\n\n  const currentIndex =\n    visibleIndexes.size > 0 ? Math.min(...visibleIndexes) : -1;\n\n  const resolvedActiveIndex =\n    activeIndex !== null && activeIndex < items.length ? activeIndex : null;\n  const activeItem =\n    resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null);\n  // Announced by the desktop rail's slider semantics; falls back to the section currently in view.\n  const announcedIndex = resolvedActiveIndex ?? Math.max(currentIndex, 0);\n  const activeTopPercent =\n    resolvedActiveIndex === null\n      ? 0\n      : resolveTocMinimapTopPercent(resolvedActiveIndex, items.length);\n  const activeTooltipTranslate =\n    resolvedActiveIndex === null\n      ? '-50%'\n      : resolvedActiveIndex === 0\n        ? '0%'\n        : resolvedActiveIndex === items.length - 1\n          ? '-100%'\n          : '-50%';\n\n  const resolveActiveIndexFromPointer = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      const rect = event.currentTarget.getBoundingClientRect();\n      return resolveTocMinimapIndexFromPointerPos({\n        itemCount: items.length,\n        railStart: rect.top,\n        railSize: rect.height,\n        pointerPos: event.clientY,\n      });\n    },\n    [items.length]\n  );\n\n  const moveActiveIndex = useCallback(\n    (delta: number) =>\n      moveTocMinimapActiveIndex(\n        setActiveIndex,\n        items.length,\n        currentIndex,\n        delta\n      ),\n    [items.length, currentIndex]\n  );\n\n  const resolvedMobileActiveIndex =\n    mobileActiveIndex !== null && mobileActiveIndex < items.length\n      ? mobileActiveIndex\n      : null;\n  const mobileActiveItem =\n    resolvedMobileActiveIndex === null\n      ? null\n      : (items[resolvedMobileActiveIndex] ?? null);\n  const mobileActiveLeftPercent =\n    resolvedMobileActiveIndex === null\n      ? 0\n      : resolveTocMinimapTopPercent(resolvedMobileActiveIndex, items.length);\n  const mobileActiveTooltipTranslate =\n    resolvedMobileActiveIndex === null\n      ? '-50%'\n      : resolvedMobileActiveIndex === 0\n        ? '0%'\n        : resolvedMobileActiveIndex === items.length - 1\n          ? '-100%'\n          : '-50%';\n\n  // Pointer events extend mouse events, so this serves both the touch scrub handlers and the fine-pointer hover handlers.\n  const resolveMobileActiveIndexFromPointer = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      const rect = event.currentTarget.getBoundingClientRect();\n      return resolveTocMinimapIndexFromPointerPos({\n        itemCount: items.length,\n        railStart: rect.left,\n        railSize: rect.width,\n        pointerPos: event.clientX,\n      });\n    },\n    [items.length]\n  );\n\n  const moveMobileActiveIndex = useCallback(\n    (delta: number) =>\n      moveTocMinimapActiveIndex(\n        setMobileActiveIndex,\n        items.length,\n        currentIndex,\n        delta\n      ),\n    [items.length, currentIndex]\n  );\n\n  const onMobileRailPointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLButtonElement>) => {\n      event.preventDefault();\n      event.currentTarget.setPointerCapture(event.pointerId);\n      capturedPointerId.current = event.pointerId;\n      mobilePointerDownPos.current = { x: event.clientX, y: event.clientY };\n      // Skip the scrub preview while the panel is open; a drag there is not a scrub gesture.\n      if (!mobileExpanded) {\n        setMobileActiveIndex(resolveMobileActiveIndexFromPointer(event));\n      }\n    },\n    [resolveMobileActiveIndexFromPointer, mobileExpanded]\n  );\n\n  const onMobileRailPointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLButtonElement>) => {\n      if (capturedPointerId.current !== event.pointerId) return;\n      if (mobileExpanded) return;\n      setMobileActiveIndex(resolveMobileActiveIndexFromPointer(event));\n    },\n    [resolveMobileActiveIndexFromPointer, mobileExpanded]\n  );\n\n  const onMobileRailPointerUp = useCallback(\n    (event: ReactPointerEvent<HTMLButtonElement>) => {\n      if (capturedPointerId.current !== event.pointerId) return;\n      capturedPointerId.current = null;\n      const downPos = mobilePointerDownPos.current;\n      mobilePointerDownPos.current = null;\n      const distance = downPos\n        ? Math.hypot(event.clientX - downPos.x, event.clientY - downPos.y)\n        : Number.POSITIVE_INFINITY;\n      // A tap toggles the expanded list instead of jumping to a section.\n      if (distance < TOC_MINIMAP_TAP_THRESHOLD_PX) {\n        setMobileActiveIndex(null);\n        setMobileExpanded((current) => !current);\n        return;\n      }\n      const nextItem =\n        resolvedMobileActiveIndex === null\n          ? null\n          : (items[resolvedMobileActiveIndex] ?? null);\n      if (nextItem) selectItem(nextItem);\n      setMobileActiveIndex(null);\n    },\n    [resolvedMobileActiveIndex, items, selectItem]\n  );\n\n  const onMobileRailPointerCancel = useCallback(() => {\n    capturedPointerId.current = null;\n    mobilePointerDownPos.current = null;\n    setMobileActiveIndex(null);\n  }, []);\n\n  // Fine-pointer click on the horizontal rail jumps like the vertical rail; the popup list stays a touch and keyboard affordance.\n  const onMobileRailClick = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      const nextIndex = resolveMobileActiveIndexFromPointer(event);\n      const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null);\n      if (nextItem) selectItem(nextItem);\n      setMobileExpanded(false);\n      event.currentTarget.blur();\n    },\n    [resolveMobileActiveIndexFromPointer, items, selectItem]\n  );\n\n  if (items.length < 2) return null;\n\n  return (\n    <>\n      <div\n        className={cn(\n          'fixed top-1/2 left-0 z-40 hidden w-18 -translate-y-1/2 [@media(pointer:fine)]:lg:block',\n          className\n        )}\n        data-toc-minimap\n      >\n        <div className=\"relative h-full w-full select-none\">\n          <button\n            aria-label=\"Table of contents\"\n            aria-orientation=\"vertical\"\n            aria-valuemax={items.length - 1}\n            aria-valuemin={0}\n            aria-valuenow={announcedIndex}\n            aria-valuetext={items[announcedIndex]?.text}\n            className=\"focus-visible:ring-ring/70 pointer-events-auto absolute top-1/2 left-3 w-10 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:ring-2 focus-visible:outline-none\"\n            onBlur={() => setActiveIndex(null)}\n            onClick={(event) => {\n              const nextIndex = resolveActiveIndexFromPointer(event);\n              const nextItem =\n                nextIndex === null ? null : (items[nextIndex] ?? null);\n              if (nextItem) selectItem(nextItem);\n              event.currentTarget.blur();\n            }}\n            onFocus={() =>\n              setActiveIndex((current) => current ?? Math.max(currentIndex, 0))\n            }\n            onKeyDown={(event) => {\n              if (event.key === 'ArrowDown') {\n                event.preventDefault();\n                moveActiveIndex(1);\n              } else if (event.key === 'ArrowUp') {\n                event.preventDefault();\n                moveActiveIndex(-1);\n              } else if (event.key === 'Home') {\n                event.preventDefault();\n                setActiveIndex(0);\n              } else if (event.key === 'End') {\n                event.preventDefault();\n                setActiveIndex(items.length - 1);\n              } else if (event.key === 'Enter' || event.key === ' ') {\n                event.preventDefault();\n                if (activeItem) selectItem(activeItem);\n              }\n            }}\n            onMouseDown={(event) => event.preventDefault()}\n            onMouseLeave={() => setActiveIndex(null)}\n            onMouseMove={(event) =>\n              setActiveIndex(resolveActiveIndexFromPointer(event))\n            }\n            role=\"slider\"\n            style={{ height: resolveTocMinimapHeightStyle(items.length) }}\n            type=\"button\"\n          >\n            <div className=\"bg-border/15 absolute top-0 left-3 h-full w-px\" />\n            {items.map((item, index) => {\n              const top = `${resolveTocMinimapTopPercent(index, items.length)}%`;\n              const activeDistance =\n                resolvedActiveIndex === null\n                  ? null\n                  : Math.abs(index - resolvedActiveIndex);\n              const isCurrent = visibleIndexes.has(index);\n              return (\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    'pointer-events-none absolute left-0 h-0.5 w-6 origin-left -translate-y-1/2 rounded-full transition-[background-color,transform] duration-150 motion-reduce:transition-none',\n                    isCurrent\n                      ? 'bg-foreground/90'\n                      : activeDistance === 0\n                        ? 'bg-muted-foreground/75'\n                        : 'bg-muted-foreground/35',\n                    activeDistance === 0\n                      ? 'scale-x-100'\n                      : activeDistance === 1\n                        ? 'scale-x-[0.67]'\n                        : 'scale-x-[0.42]'\n                  )}\n                  // Index suffix avoids duplicate React keys when heading text repeats and ids collide.\n                  key={`${item.id}-${index}`}\n                  style={{ top }}\n                />\n              );\n            })}\n            {activeItem ? (\n              <span\n                className={cn(\n                  'border-border/70 bg-background/95 text-foreground pointer-events-none absolute left-8 w-80 rounded-xl border p-3 text-left shadow-xl backdrop-blur transition-[top,transform] duration-150 ease-out motion-reduce:transition-none',\n                  tooltipClassName\n                )}\n                style={{\n                  top: `${activeTopPercent}%`,\n                  transform: `translateY(${activeTooltipTranslate})`,\n                }}\n              >\n                <span className=\"block max-w-full overflow-hidden text-sm leading-tight text-ellipsis whitespace-nowrap\">\n                  {activeItem.text}\n                </span>\n                {activeItem.preview ? (\n                  <span\n                    className=\"text-muted-foreground mt-1 block text-sm leading-normal\"\n                    style={{\n                      display: '-webkit-box',\n                      WebkitBoxOrient: 'vertical',\n                      WebkitLineClamp: 3,\n                      overflow: 'hidden',\n                    }}\n                  >\n                    {activeItem.preview}\n                  </span>\n                ) : null}\n              </span>\n            ) : null}\n          </button>\n        </div>\n      </div>\n      {mobileExpanded ? (\n        <button\n          aria-label=\"Close table of contents\"\n          className=\"fixed inset-0 z-40 cursor-default bg-transparent [@media(pointer:fine)]:lg:hidden\"\n          onClick={() => setMobileExpanded(false)}\n          type=\"button\"\n        />\n      ) : null}\n      <div\n        className={cn(\n          'fixed inset-x-0 z-40 flex flex-col items-center [@media(pointer:fine)]:lg:hidden',\n          mobileClassName\n        )}\n        data-toc-minimap-mobile\n        style={{ bottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}\n      >\n        {mobileExpanded ? (\n          <div\n            className={cn(\n              'border-border/70 bg-background/95 mb-2 max-h-[55vh] w-72 max-w-[85vw] overflow-y-auto rounded-xl border shadow-xl backdrop-blur',\n              panelClassName\n            )}\n            role=\"menu\"\n          >\n            {items.map((item, index) => {\n              const isCurrent = visibleIndexes.has(index);\n              return (\n                <button\n                  className={cn(\n                    'hover:bg-muted/60 active:bg-muted/60 block w-full px-3 py-2 text-left text-sm leading-tight transition-colors',\n                    (item.level ?? 2) >= 3 && 'pl-6',\n                    isCurrent\n                      ? 'text-foreground font-medium'\n                      : 'text-muted-foreground'\n                  )}\n                  // Index suffix avoids duplicate React keys when heading text repeats and ids collide.\n                  key={`${item.id}-${index}-mobile-menu`}\n                  onClick={() => {\n                    selectItem(item);\n                    setMobileExpanded(false);\n                  }}\n                  role=\"menuitem\"\n                  type=\"button\"\n                >\n                  <span className=\"flex items-center gap-2\">\n                    {isCurrent ? (\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"bg-foreground/70 h-1.5 w-1.5 shrink-0 rounded-full\"\n                      />\n                    ) : null}\n                    <span className=\"block overflow-hidden text-ellipsis whitespace-nowrap\">\n                      {item.text}\n                    </span>\n                  </span>\n                </button>\n              );\n            })}\n          </div>\n        ) : null}\n        <div className=\"border-border/50 bg-background/85 rounded-full border px-4 py-2.5 shadow-lg backdrop-blur\">\n          <button\n            aria-expanded={mobileExpanded}\n            aria-haspopup=\"menu\"\n            aria-label=\"Table of contents\"\n            className=\"focus-visible:ring-ring/70 relative block cursor-pointer touch-none bg-transparent focus-visible:ring-2 focus-visible:outline-none\"\n            onBlur={() => setMobileActiveIndex(null)}\n            onFocus={() =>\n              setMobileActiveIndex((current) =>\n                current === null ? Math.max(currentIndex, 0) : current\n              )\n            }\n            onKeyDown={(event) => {\n              if (event.key === 'ArrowRight') {\n                event.preventDefault();\n                moveMobileActiveIndex(1);\n              } else if (event.key === 'ArrowLeft') {\n                event.preventDefault();\n                moveMobileActiveIndex(-1);\n              } else if (event.key === 'Home') {\n                event.preventDefault();\n                setMobileActiveIndex(0);\n              } else if (event.key === 'End') {\n                event.preventDefault();\n                setMobileActiveIndex(items.length - 1);\n              } else if (event.key === 'Enter' || event.key === ' ') {\n                event.preventDefault();\n                // An arrow-selected item still jumps; otherwise Enter/Space opens the list panel.\n                if (mobileActiveItem) {\n                  selectItem(mobileActiveItem);\n                } else if (!mobileExpanded) {\n                  // Opening the panel hides the scrub preview.\n                  setMobileActiveIndex(null);\n                  setMobileExpanded(true);\n                }\n              }\n            }}\n            {...(finePointer\n              ? {\n                  onClick: onMobileRailClick,\n                  onMouseDown: (event: ReactMouseEvent<HTMLButtonElement>) =>\n                    event.preventDefault(),\n                  onMouseLeave: () => setMobileActiveIndex(null),\n                  onMouseMove: (event: ReactMouseEvent<HTMLButtonElement>) => {\n                    if (!mobileExpanded) {\n                      setMobileActiveIndex(\n                        resolveMobileActiveIndexFromPointer(event)\n                      );\n                    }\n                  },\n                }\n              : {\n                  onPointerCancel: onMobileRailPointerCancel,\n                  onPointerDown: onMobileRailPointerDown,\n                  onPointerMove: onMobileRailPointerMove,\n                  onPointerUp: onMobileRailPointerUp,\n                })}\n            style={{\n              width: resolveTocMinimapMobileWidthStyle(items.length),\n              height: '24px',\n            }}\n            type=\"button\"\n          >\n            <div className=\"bg-border/15 absolute top-1/2 left-0 h-px w-full -translate-y-1/2\" />\n            {items.map((item, index) => {\n              const left = `${resolveTocMinimapTopPercent(index, items.length)}%`;\n              const activeDistance =\n                resolvedMobileActiveIndex === null\n                  ? null\n                  : Math.abs(index - resolvedMobileActiveIndex);\n              const isCurrent = visibleIndexes.has(index);\n              return (\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    'pointer-events-none absolute top-1/2 h-5 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-[background-color,transform] duration-150 motion-reduce:transition-none',\n                    isCurrent\n                      ? 'bg-foreground/90'\n                      : activeDistance === 0\n                        ? 'bg-muted-foreground/75'\n                        : 'bg-muted-foreground/35',\n                    activeDistance === 0\n                      ? 'scale-y-100'\n                      : activeDistance === 1\n                        ? 'scale-y-[0.8]'\n                        : activeDistance === 2\n                          ? 'scale-y-[0.6]'\n                          : 'scale-y-[0.4]'\n                  )}\n                  // Index suffix avoids duplicate React keys when heading text repeats and ids collide.\n                  key={`${item.id}-${index}-mobile`}\n                  style={{ left }}\n                />\n              );\n            })}\n            {!mobileExpanded && mobileActiveItem ? (\n              <span\n                className={cn(\n                  'border-border/70 bg-background/95 text-foreground pointer-events-none absolute w-72 max-w-[80vw] rounded-xl border p-3 text-left shadow-xl backdrop-blur transition-[left,transform] duration-150 ease-out motion-reduce:transition-none',\n                  tooltipClassName\n                )}\n                style={{\n                  left: `${mobileActiveLeftPercent}%`,\n                  bottom: 'calc(100% + 0.75rem)',\n                  transform: `translateX(${mobileActiveTooltipTranslate})`,\n                }}\n              >\n                <span className=\"block max-w-full overflow-hidden text-sm leading-tight text-ellipsis whitespace-nowrap\">\n                  {mobileActiveItem.text}\n                </span>\n                {mobileActiveItem.preview ? (\n                  <span\n                    className=\"text-muted-foreground mt-1 block text-sm leading-normal\"\n                    style={{\n                      display: '-webkit-box',\n                      WebkitBoxOrient: 'vertical',\n                      WebkitLineClamp: 3,\n                      overflow: 'hidden',\n                    }}\n                  >\n                    {mobileActiveItem.preview}\n                  </span>\n                ) : null}\n              </span>\n            ) : null}\n          </button>\n        </div>\n      </div>\n    </>\n  );\n}\n\n// Builds minimap items from rendered headings, for apps without build-time TOC extraction. Headings need ids (e.g. via rehype-slug).\nexport function useTocItems(\n  containerSelector = 'article',\n  headingSelector = 'h2[id], h3[id]'\n): TocMinimapItem[] {\n  const [items, setItems] = useState<TocMinimapItem[]>([]);\n\n  // Deferred a frame so the DOM read happens after paint instead of forcing a cascading render during mount.\n  useEffect(() => {\n    const frame = requestAnimationFrame(() => {\n      const root = document.querySelector(containerSelector);\n      const headings = root\n        ? Array.from(root.querySelectorAll<HTMLElement>(headingSelector))\n        : [];\n      setItems(\n        headings\n          .map((heading) => ({\n            id: heading.id,\n            text: heading.textContent?.trim() ?? '',\n            level: Number(heading.tagName.slice(1)) || 2,\n            preview: resolveHeadingPreview(heading),\n          }))\n          .filter((item) => item.id && item.text)\n      );\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [containerSelector, headingSelector]);\n\n  return items;\n}\n\n// First following paragraph's text, collapsed and trimmed to a tooltip-sized snippet.\nfunction resolveHeadingPreview(heading: HTMLElement): string | null {\n  for (\n    let node = heading.nextElementSibling;\n    node;\n    node = node.nextElementSibling\n  ) {\n    if (/^H[1-6]$/.test(node.tagName)) break;\n    if (node.tagName !== 'P') continue;\n    const text = node.textContent?.replace(/\\s+/g, ' ').trim() ?? '';\n    if (text) return text.slice(0, 160);\n  }\n  return null;\n}\n\nexport default TocMinimap;\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}