{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chapter-scrubber",
  "type": "registry:ui",
  "title": "Chapter Scrubber",
  "description": "A vertical rail of uniform ticks that magnify toward the cursor like a dock — the lines nearest the pointer rise on a spring-driven wave and a preview card describes the crest chapter, inspired by the OpenAI Codex chapters minimap.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ruixenui/chapter-scrubber.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Chapter {\n  /** Stable, unique identifier for the chapter. */\n  id: string;\n  /** Bold heading shown at the top of the preview card. */\n  title: string;\n  /** Supporting copy shown under the title (clamped to three lines). */\n  description?: React.ReactNode;\n  /** Small muted label rendered above the title (e.g. a timestamp or step no.). */\n  meta?: React.ReactNode;\n}\n\nexport interface ChapterScrubberProps {\n  /** Chapters rendered top-to-bottom, one uniform tick each. */\n  chapters: Chapter[];\n  /** Which side the preview card opens toward. Auto-flips near a viewport edge. Default `\"right\"`. */\n  side?: \"left\" | \"right\";\n  /** Length a tick reaches at the crest of the magnification, in pixels. Default `56`. */\n  peakLength?: number;\n  /** Resting length of every tick, in pixels. Keep it small. Default `14`. */\n  restLength?: number;\n  /** Height of each row in pixels; the gap between ticks. Smaller = denser. Default `10`. */\n  rowHeight?: number;\n  /** Radius of the magnification wave, in rows — how far the rise reaches from the pointer. Default `4`. */\n  radius?: number;\n  /** Marks one chapter as the persistent \"current\" position (e.g. where an agent is now). */\n  currentIndex?: number;\n  /** Fires when the active (hovered/focused) chapter changes. */\n  onActiveChange?: (chapter: Chapter | null, index: number) => void;\n  /** Fires when a chapter is chosen via click, Enter or Space. */\n  onSelect?: (chapter: Chapter, index: number) => void;\n  /** Accessible name for the rail. Default `\"Chapters\"`. */\n  label?: string;\n  className?: string;\n}\n\nconst CARD_WIDTH = 260;\nconst GAP = 20;\n// Tight, near-critically-damped spring: tracks the cursor with almost no lag\n// and never overshoots — the wave feels attached to the pointer.\nconst POINTER_SPRING = { stiffness: 700, damping: 52, mass: 0.5 };\n// Softer spring for the rise/settle so the wave swells and relaxes gracefully.\nconst STRENGTH_SPRING = { stiffness: 260, damping: 30, mass: 0.6 };\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\n// Raised-cosine bump: 1 at the crest, 0 beyond the radius, with zero slope at\n// both ends so the wave has no seams — the source of the buttery falloff.\nfunction bump(distance: number, radius: number) {\n  if (distance >= radius) return 0;\n  return 0.5 * (1 + Math.cos(Math.PI * (distance / radius)));\n}\n\ninterface TickProps {\n  index: number;\n  pointer: MotionValue<number>;\n  strength: MotionValue<number>;\n  radius: number;\n  restLength: number;\n  peakLength: number;\n  isCurrent: boolean;\n}\n\nconst Tick = React.memo(function Tick({\n  index,\n  pointer,\n  strength,\n  radius,\n  restLength,\n  peakLength,\n  isCurrent,\n}: TickProps) {\n  const width = useTransform(() => {\n    const rise = strength.get() * bump(Math.abs(index - pointer.get()), radius);\n    return restLength + rise * (peakLength - restLength);\n  });\n  const opacity = useTransform(() => {\n    const rise = strength.get() * bump(Math.abs(index - pointer.get()), radius);\n    const base = isCurrent ? 0.55 : 0.22;\n    return base + rise * (1 - base);\n  });\n  const scaleY = useTransform(() => {\n    const rise = strength.get() * bump(Math.abs(index - pointer.get()), radius);\n    // Only a slight thickening at the crest (2px -> ~2.8px); the length change\n    // carries the rise, thickness is a quiet secondary cue.\n    return 1 + rise * 0.4;\n  });\n\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      style={{ width, opacity, scaleY }}\n      className={cn(\n        \"block h-[2px] rounded-full\",\n        isCurrent ? \"bg-primary\" : \"bg-foreground\",\n      )}\n    />\n  );\n});\n\nexport function ChapterScrubber({\n  chapters,\n  side = \"right\",\n  peakLength = 56,\n  restLength = 14,\n  rowHeight = 10,\n  radius = 4,\n  currentIndex,\n  onActiveChange,\n  onSelect,\n  label = \"Chapters\",\n  className,\n}: ChapterScrubberProps) {\n  const prefersReducedMotion = useReducedMotion();\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const buttonsRef = React.useRef<Array<HTMLButtonElement | null>>([]);\n  // Namespaced so option ids stay unique across instances and don't depend on\n  // chapter.id being a valid, collision-free DOM id.\n  const baseId = React.useId();\n  const optionId = (index: number) => `${baseId}-opt-${index}`;\n\n  const rawPointer = useMotionValue(0);\n  const rawStrength = useMotionValue(0);\n  const springPointer = useSpring(rawPointer, POINTER_SPRING);\n  const springStrength = useSpring(rawStrength, STRENGTH_SPRING);\n  // Reduced motion: drop the temporal easing but keep the spatial wave, so the\n  // rise is instant rather than sprung.\n  const pointer = prefersReducedMotion ? rawPointer : springPointer;\n  const strength = prefersReducedMotion ? rawStrength : springStrength;\n\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [engaged, setEngaged] = React.useState(false);\n  const [flipped, setFlipped] = React.useState(false);\n  const [cardHeight, setCardHeight] = React.useState(0);\n  const hoveringRef = React.useRef(false);\n  const focusedRef = React.useRef<number | null>(null);\n  const activeRef = React.useRef(0);\n\n  const commitActive = React.useCallback((index: number) => {\n    if (index !== activeRef.current) {\n      activeRef.current = index;\n      setActiveIndex(index);\n    }\n  }, []);\n\n  const last = chapters.length - 1;\n\n  React.useEffect(() => {\n    onActiveChange?.(\n      engaged ? chapters[activeIndex] : null,\n      engaged ? activeIndex : -1,\n    );\n  }, [engaged, activeIndex, chapters, onActiveChange]);\n\n  // Measure the card so its vertical travel can be clamped to the rail.\n  React.useEffect(() => {\n    if (cardRef.current) setCardHeight(cardRef.current.offsetHeight);\n  }, [activeIndex]);\n\n  // Flip toward the roomier side if the card would spill past the viewport.\n  React.useEffect(() => {\n    if (!engaged) return;\n    const el = containerRef.current;\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    const vw = el.ownerDocument.defaultView?.innerWidth ?? 0;\n    const need = CARD_WIDTH + GAP + 8;\n    let useRight = side === \"right\";\n    if (useRight && vw - rect.right < need && rect.left >= need)\n      useRight = false;\n    if (!useRight && rect.left < need && vw - rect.right >= need)\n      useRight = true;\n    setFlipped(useRight !== (side === \"right\"));\n  }, [engaged, activeIndex, side]);\n\n  const resolvedSide =\n    side === \"right\"\n      ? flipped\n        ? \"left\"\n        : \"right\"\n      : flipped\n        ? \"right\"\n        : \"left\";\n\n  const totalHeight = chapters.length * rowHeight;\n  // Exactly one tick is tabbable at a time (roving tabindex).\n  const rovingIndex = engaged ? activeIndex : (currentIndex ?? 0);\n\n  const cardTop = useTransform(pointer, (p) => {\n    const half = cardHeight / 2;\n    const center = clamp(\n      (p + 0.5) * rowHeight,\n      half,\n      Math.max(half, totalHeight - half),\n    );\n    return center - half;\n  });\n  const cardScale = useTransform(strength, [0, 1], [0.97, 1]);\n  const cardX = useTransform(\n    strength,\n    [0, 1],\n    [resolvedSide === \"right\" ? -6 : 6, 0],\n  );\n\n  const engageAt = (pointerRow: number, activeAt: number) => {\n    rawPointer.set(pointerRow);\n    rawStrength.set(1);\n    commitActive(clamp(activeAt, 0, last));\n    if (!engaged) setEngaged(true);\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const el = listRef.current;\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    const row = (event.clientY - rect.top) / rowHeight - 0.5;\n    hoveringRef.current = true;\n    engageAt(clamp(row, -0.5, last + 0.5), Math.round(row));\n  };\n\n  const handlePointerLeave = () => {\n    hoveringRef.current = false;\n    if (focusedRef.current != null) {\n      rawPointer.set(focusedRef.current);\n    } else {\n      rawStrength.set(0);\n      setEngaged(false);\n    }\n  };\n\n  const handleBlur = (event: React.FocusEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n      focusedRef.current = null;\n      if (!hoveringRef.current) {\n        rawStrength.set(0);\n        setEngaged(false);\n      }\n    }\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    let next = focusedRef.current ?? activeRef.current;\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowRight\":\n        next = Math.min(last, next + 1);\n        break;\n      case \"ArrowUp\":\n      case \"ArrowLeft\":\n        next = Math.max(0, next - 1);\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = last;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    buttonsRef.current[next]?.focus();\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      style={{ width: peakLength }}\n      className={cn(\"relative\", className)}\n    >\n      <div\n        ref={listRef}\n        role=\"listbox\"\n        aria-label={label}\n        aria-orientation=\"vertical\"\n        aria-activedescendant={engaged ? optionId(activeIndex) : undefined}\n        className=\"flex w-full flex-col\"\n        onPointerMove={handlePointerMove}\n        onPointerLeave={handlePointerLeave}\n        onKeyDown={handleKeyDown}\n        onBlur={handleBlur}\n      >\n        {chapters.map((chapter, index) => {\n          const isCurrent = index === currentIndex;\n          const descText =\n            typeof chapter.description === \"string\"\n              ? `. ${chapter.description}`\n              : \"\";\n          return (\n            <button\n              ref={(el) => {\n                buttonsRef.current[index] = el;\n              }}\n              key={chapter.id}\n              id={optionId(index)}\n              type=\"button\"\n              role=\"option\"\n              aria-selected={isCurrent}\n              aria-label={`${chapter.title}${descText}`}\n              tabIndex={index === rovingIndex ? 0 : -1}\n              onFocus={() => {\n                focusedRef.current = index;\n                engageAt(index, index);\n              }}\n              onClick={() => onSelect?.(chapter, index)}\n              style={{ height: rowHeight }}\n              className={cn(\n                \"flex w-full items-center rounded-sm outline-none\",\n                \"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n                resolvedSide === \"left\" ? \"justify-end\" : \"justify-start\",\n              )}\n            >\n              <Tick\n                index={index}\n                pointer={pointer}\n                strength={strength}\n                radius={radius}\n                restLength={restLength}\n                peakLength={peakLength}\n                isCurrent={isCurrent}\n              />\n            </button>\n          );\n        })}\n      </div>\n\n      {chapters[activeIndex] ? (\n        <motion.div\n          ref={cardRef}\n          aria-hidden=\"true\"\n          style={{\n            top: cardTop,\n            x: cardX,\n            scale: cardScale,\n            opacity: strength,\n            ...(resolvedSide === \"right\"\n              ? { left: peakLength + GAP }\n              : { right: peakLength + GAP }),\n          }}\n          className={cn(\n            \"pointer-events-none absolute z-10 w-[260px] rounded-2xl border border-border bg-popover px-4 py-3.5 text-popover-foreground\",\n            \"shadow-[0_2px_6px_-2px_rgba(0,0,0,0.08),0_16px_36px_-12px_rgba(0,0,0,0.22)]\",\n            resolvedSide === \"right\" ? \"origin-left\" : \"origin-right\",\n          )}\n        >\n          {chapters[activeIndex].meta ? (\n            <div className=\"mb-1 text-xs font-medium tabular-nums text-muted-foreground\">\n              {chapters[activeIndex].meta}\n            </div>\n          ) : null}\n          <div className=\"truncate text-sm font-semibold leading-snug tracking-[-0.01em]\">\n            {chapters[activeIndex].title}\n          </div>\n          {chapters[activeIndex].description ? (\n            <p className=\"mt-1 line-clamp-3 text-sm leading-relaxed text-muted-foreground\">\n              {chapters[activeIndex].description}\n            </p>\n          ) : null}\n        </motion.div>\n      ) : null}\n    </div>\n  );\n}\n\nexport default ChapterScrubber;\n",
      "type": "registry:ui",
      "target": "components/ruixen/chapter-scrubber.tsx"
    }
  ]
}