{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "scroll-spy",
  "title": "ScrollSpy",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpy.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, provide, ref, toRef, useTemplateRef, watch } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport type { ScrollSpyItem } from '.'\nimport {\n  SCROLL_SPY_CONTEXT_KEY,\n  type ScrollSpyTurn,\n  type ScrollSpyVariant,\n  type ScrollSpyIndicatorMode,\n  type ScrollSpyPosition,\n  type ScrollSpyRailPosition,\n  type ScrollSpyLineWidth,\n  type ScrollSpyColor,\n  type RegisteredItem,\n} from './context'\nimport ScrollSpyTitle from './ScrollSpyTitle.vue'\nimport ScrollSpyList from './ScrollSpyList.vue'\nimport ScrollSpyIndicator from './ScrollSpyIndicator.vue'\nimport ScrollSpyItemComp from './ScrollSpyItem.vue'\nimport ScrollSpyLink from './ScrollSpyLink.vue'\nimport ScrollSpyStepper from './ScrollSpyStepper.vue'\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue?: string\n    items?: ScrollSpyItem[]\n    title?: string\n    offsetTop?: number\n    bounds?: number\n    scrollContainer?: HTMLElement | string | null\n    affix?: boolean\n    variant?: ScrollSpyVariant\n    turn?: ScrollSpyTurn\n    indicator?: ScrollSpyIndicatorMode\n    keepScrolled?: boolean\n    highlightParent?: boolean\n    lineWidth?: ScrollSpyLineWidth\n    color?: ScrollSpyColor\n    position?: ScrollSpyPosition\n    railPosition?: ScrollSpyRailPosition\n    class?: HTMLAttributes['class']\n  }>(),\n  {\n    modelValue: '',\n    items: () => [],\n    title: undefined,\n    offsetTop: 0,\n    bounds: 5,\n    scrollContainer: null,\n    affix: false,\n    variant: undefined,\n    turn: undefined,\n    indicator: 'line',\n    keepScrolled: false,\n    highlightParent: true,\n    lineWidth: 'default',\n    color: 'primary',\n    position: 'right',\n    railPosition: undefined,\n  },\n)\n\nconst emits = defineEmits<{\n  (e: 'update:modelValue', value: string): void\n  (e: 'change', value: string): void\n  (e: 'progress', value: number): void\n}>()\n\nconst resolvedPosition = computed<ScrollSpyPosition>(() => props.position ?? 'right')\nconst resolvedRailPosition = computed<ScrollSpyRailPosition>(() => {\n  if (props.railPosition) return props.railPosition\n  return props.position === 'left' ? 'right' : 'left'\n})\n\nconst resolvedTurn = computed<ScrollSpyTurn>(() => {\n  if (props.turn) return props.turn\n  if (props.variant === 'angle' || props.variant === 'rounded') return 'rounded'\n  if (props.variant === 'sharp') return 'sharp'\n  if (props.variant === 'line' || props.variant === 'default') return 'straight'\n  return 'straight'\n})\n\nconst resolvedVariant = computed<ScrollSpyVariant>(() => {\n  if (props.variant) return props.variant\n  if (props.turn === 'sharp') return 'angle'\n  if (props.turn === 'rounded') return 'rounded'\n  if (props.position === 'top' || props.position === 'bottom') return 'stepper'\n  return 'line'\n})\n\nconst resolvedIndicator = computed<ScrollSpyIndicatorMode>(() => props.indicator ?? 'line')\n\nconst internalActive = ref(props.modelValue || (props.items[0]?.href ?? ''))\nconst activeValue = computed({\n  get: () => props.modelValue || internalActive.value,\n  set: (val: string) => {\n    if (internalActive.value !== val) {\n      internalActive.value = val\n      emits('update:modelValue', val)\n      emits('change', val)\n    }\n  },\n})\n\nfunction flattenItems(rawItems: ScrollSpyItem[]): RegisteredItem[] {\n  const result: RegisteredItem[] = []\n  function walk(list: ScrollSpyItem[], depth = 1, parentVal: string | null = null) {\n    for (const it of list) {\n      result.push({\n        value: it.href,\n        depth: it.depth ?? depth,\n        el: null,\n        title: it.title,\n        parentValue: parentVal,\n      })\n      if (it.children && it.children.length > 0) {\n        walk(it.children, depth + 1, it.href)\n      }\n    }\n  }\n  walk(rawItems)\n  return result\n}\n\nconst registeredItems = ref<RegisteredItem[]>(props.items && props.items.length > 0 ? flattenItems(props.items) : [])\nconst listEl = ref<HTMLElement | null>(null)\nconst resolvedContainer = ref<HTMLElement | Window | null>(null)\nconst readingProgress = ref(0)\nconst scrollProgressSmooth = ref(true)\n\nfunction resolveContainer(): HTMLElement | Window | null {\n  if (typeof window === 'undefined') return null\n  const sc = props.scrollContainer\n  if (!sc) return window\n  if (typeof sc === 'function') {\n    return (sc as () => HTMLElement | Window | null)() ?? window\n  }\n  if (typeof sc === 'string') {\n    return (document.querySelector(sc) as HTMLElement) ?? window\n  }\n  return sc as HTMLElement | Window\n}\n\nfunction registerItem(item: RegisteredItem) {\n  const existingIdx = registeredItems.value.findIndex((i) => i.value === item.value)\n  if (existingIdx >= 0) {\n    registeredItems.value[existingIdx] = {\n      ...registeredItems.value[existingIdx]!,\n      ...item,\n      title: item.title ?? registeredItems.value[existingIdx]!.title,\n    }\n  } else {\n    registeredItems.value.push(item)\n  }\n  if (!activeValue.value && registeredItems.value.length > 0) {\n    activeValue.value = registeredItems.value[0]!.value\n  }\n}\n\nwatch(\n  () => props.items,\n  (newItems) => {\n    if (newItems && newItems.length > 0) {\n      const flattened = flattenItems(newItems)\n      const existingMap = new Map(registeredItems.value.map((i) => [i.value, i]))\n      registeredItems.value = flattened.map((item) => {\n        const existing = existingMap.get(item.value)\n        return {\n          ...item,\n          el: existing?.el ?? null,\n          title: item.title ?? existing?.title,\n        }\n      })\n      if (!activeValue.value && registeredItems.value.length > 0) {\n        activeValue.value = registeredItems.value[0]!.value\n      }\n    }\n  },\n  { deep: true },\n)\n\nfunction unregisterItem(value: string) {\n  registeredItems.value = registeredItems.value.filter((i) => i.value !== value)\n}\n\nfunction setActiveValue(value: string) {\n  activeValue.value = value\n}\n\nfunction scrollToHref(href: string) {\n  setActiveValue(href)\n  const container = resolvedContainer.value || resolveContainer()\n  const offset = props.offsetTop\n  const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches\n  const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto'\n\n  if (container && container !== window) {\n    const cEl = container as HTMLElement\n    const target = cEl.querySelector(href) as HTMLElement | null\n    if (target) {\n      const cRect = cEl.getBoundingClientRect()\n      const tRect = target.getBoundingClientRect()\n      const top = cEl.scrollTop + (tRect.top - cRect.top) - offset\n      cEl.scrollTo({ top, behavior })\n    }\n  } else {\n    const target = document.querySelector(href) as HTMLElement | null\n    if (target) {\n      const top = window.scrollY + target.getBoundingClientRect().top - offset\n      window.scrollTo({ top, behavior })\n    }\n  }\n\n  if (typeof history !== 'undefined') {\n    history.replaceState(null, '', href)\n  }\n}\n\nfunction goToPrev() {\n  const list = registeredItems.value\n  const active = activeValue.value\n  const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, ''))\n  if (idx > 0 && list[idx - 1]) {\n    scrollToHref(list[idx - 1]!.value)\n  }\n}\n\nfunction goToNext() {\n  const list = registeredItems.value\n  const active = activeValue.value\n  const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, ''))\n  if (idx >= 0 && idx < list.length - 1 && list[idx + 1]) {\n    scrollToHref(list[idx + 1]!.value)\n  }\n}\n\nconst activeIndex = computed(() => {\n  const current = activeValue.value\n  if (!current) return -1\n  const clean = current.replace(/^#/, '')\n  return registeredItems.value.findIndex((i) => i.value === current || i.value.replace(/^#/, '') === clean)\n})\n\nfunction isItemActive(value: string): boolean {\n  if (!value || !activeValue.value) return false\n  const cleanVal = value.replace(/^#/, '')\n  const cleanActive = activeValue.value.replace(/^#/, '')\n  return cleanVal === cleanActive\n}\n\nfunction isItemParentActive(value: string): boolean {\n  if (!props.highlightParent || !value || !activeValue.value) return false\n  const cleanVal = value.replace(/^#/, '')\n  const cleanActive = activeValue.value.replace(/^#/, '')\n  if (cleanVal === cleanActive) return false\n\n  const activeItem = registeredItems.value.find(\n    (i) => i.value === activeValue.value || i.value.replace(/^#/, '') === cleanActive,\n  )\n  let parent = activeItem?.parentValue\n  while (parent) {\n    if (parent === value || parent.replace(/^#/, '') === cleanVal) return true\n    const pItem = registeredItems.value.find(\n      (i) => i.value === parent || i.value.replace(/^#/, '') === parent!.replace(/^#/, ''),\n    )\n    parent = pItem?.parentValue\n  }\n  return false\n}\n\nfunction isItemScrolled(value: string): boolean {\n  if (!props.keepScrolled || activeIndex.value < 0) return false\n  const cleanVal = value.replace(/^#/, '')\n  const idx = registeredItems.value.findIndex((i) => i.value === value || i.value.replace(/^#/, '') === cleanVal)\n  return idx >= 0 && idx <= activeIndex.value\n}\n\nconst resolvedLineWidth = computed<number>(() => {\n  const lw = props.lineWidth\n  if (typeof lw === 'number') return Math.max(1, lw)\n  if (lw === 'thin') return 1.5\n  if (lw === 'thick') return 3.5\n  return 2.5\n})\n\nprovide(SCROLL_SPY_CONTEXT_KEY, {\n  activeValue,\n  setActiveValue,\n  scrollProgress: readingProgress,\n  registerItem,\n  unregisterItem,\n  variant: resolvedVariant,\n  turn: resolvedTurn,\n  indicator: resolvedIndicator,\n  keepScrolled: toRef(props, 'keepScrolled'),\n  highlightParent: toRef(props, 'highlightParent'),\n  lineWidth: toRef(props, 'lineWidth'),\n  resolvedLineWidth,\n  color: toRef(props, 'color'),\n  position: resolvedPosition,\n  railPosition: resolvedRailPosition,\n  scrollProgressSmooth,\n  scrollContainer: resolvedContainer,\n  offsetTop: toRef(props, 'offsetTop'),\n  items: registeredItems,\n  getListEl: () => listEl.value,\n  setListEl: (el) => {\n    listEl.value = el\n  },\n  scrollToHref,\n  goToPrev,\n  goToNext,\n  isItemActive,\n  isItemParentActive,\n  isItemScrolled,\n})\n\n// Scroll Spy tracking for both local container and window, plus reading progress\nfunction recomputeActive() {\n  const container = resolvedContainer.value\n  if (!container || registeredItems.value.length === 0) return\n\n  const isWin = container === window\n  let currentScroll = 0\n  let maxScroll = 0\n\n  if (isWin) {\n    currentScroll = window.scrollY\n    maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight)\n  } else {\n    const cEl = container as HTMLElement\n    currentScroll = cEl.scrollTop\n    maxScroll = Math.max(1, cEl.scrollHeight - cEl.clientHeight)\n  }\n\n  const prog = Math.min(1, Math.max(0, currentScroll / maxScroll))\n  readingProgress.value = prog\n  emits('progress', prog)\n\n  const containerTop = isWin ? 0 : (container as HTMLElement).getBoundingClientRect().top\n  const triggerThreshold = containerTop + props.offsetTop + props.bounds + 40\n\n  let currentTarget = ''\n\n  for (const item of registeredItems.value) {\n    const selector = item.value.startsWith('#') ? item.value : `#${item.value}`\n    const target = isWin ? document.querySelector(selector) : (container as HTMLElement).querySelector(selector)\n\n    if (!target) continue\n    const targetRect = target.getBoundingClientRect()\n    if (targetRect.top <= triggerThreshold) {\n      currentTarget = item.value\n    } else if (currentTarget) {\n      break\n    }\n  }\n\n  const atBottom = isWin\n    ? window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 6\n    : (container as HTMLElement).scrollTop + (container as HTMLElement).clientHeight >=\n      (container as HTMLElement).scrollHeight - 6\n\n  if (atBottom && registeredItems.value.length > 0) {\n    currentTarget = registeredItems.value[registeredItems.value.length - 1]!.value\n  }\n\n  if (!currentTarget && registeredItems.value.length > 0) {\n    currentTarget = registeredItems.value[0]!.value\n  }\n\n  if (currentTarget && currentTarget !== activeValue.value) {\n    activeValue.value = currentTarget\n  }\n}\n\nlet rafScroll = 0\nfunction onScroll() {\n  if (typeof cancelAnimationFrame !== 'undefined') {\n    cancelAnimationFrame(rafScroll)\n  }\n  if (typeof requestAnimationFrame !== 'undefined') {\n    rafScroll = requestAnimationFrame(() => {\n      recomputeActive()\n    })\n  } else {\n    recomputeActive()\n  }\n}\n\nfunction bindScrollListener() {\n  unbindScrollListener()\n  resolvedContainer.value = resolveContainer()\n  const c = resolvedContainer.value\n  if (c && typeof (c as EventTarget).addEventListener === 'function') {\n    ;(c as EventTarget).addEventListener('scroll', onScroll, { passive: true })\n  }\n}\n\nfunction unbindScrollListener() {\n  if (typeof cancelAnimationFrame !== 'undefined') {\n    cancelAnimationFrame(rafScroll)\n  }\n  const c = resolvedContainer.value\n  if (c && typeof (c as EventTarget).removeEventListener === 'function') {\n    ;(c as EventTarget).removeEventListener('scroll', onScroll)\n  }\n}\n\nlet mountTimer: ReturnType<typeof setTimeout> | null = null\n\nonMounted(() => {\n  bindScrollListener()\n  recomputeActive()\n  mountTimer = setTimeout(() => {\n    bindScrollListener()\n    recomputeActive()\n  }, 100)\n})\n\nonBeforeUnmount(() => {\n  if (mountTimer) {\n    clearTimeout(mountTimer)\n    mountTimer = null\n  }\n  unbindScrollListener()\n})\n\nwatch(\n  () => props.scrollContainer,\n  () => {\n    bindScrollListener()\n    recomputeActive()\n  },\n)\n\nconst navRef = useTemplateRef<HTMLElement>('navRef')\n</script>\n\n<template>\n  <nav\n    ref=\"navRef\"\n    data-uipkge\n    data-slot=\"scroll-spy\"\n    :data-position=\"resolvedPosition\"\n    :data-rail-position=\"resolvedRailPosition\"\n    :data-variant=\"resolvedVariant\"\n    :data-turn=\"resolvedTurn\"\n    :data-indicator=\"resolvedIndicator\"\n    :data-keep-scrolled=\"keepScrolled ? 'true' : undefined\"\n    :data-highlight-parent=\"highlightParent ? 'true' : undefined\"\n    :data-line-width=\"lineWidth\"\n    :data-color=\"color\"\n    aria-label=\"Scroll spy navigation\"\n    :class=\"\n      cn(\n        'relative flex text-sm',\n        resolvedPosition === 'top' || resolvedPosition === 'bottom' ? 'w-full flex-col' : 'flex-col',\n        affix && (resolvedPosition === 'bottom' ? 'sticky bottom-4' : 'sticky'),\n        props.class,\n      )\n    \"\n    :style=\"affix && resolvedPosition !== 'bottom' ? { top: `${offsetTop}px` } : undefined\"\n  >\n    <template v-if=\"items && items.length > 0\">\n      <!-- Top Sticky Stepper Mode -->\n      <ScrollSpyStepper v-if=\"resolvedPosition === 'top'\" />\n\n      <!-- Standard Left or Right Vertical Rail -->\n      <template v-else-if=\"resolvedPosition !== 'bottom'\">\n        <ScrollSpyTitle v-if=\"title\">{{ title }}</ScrollSpyTitle>\n        <ScrollSpyList>\n          <ScrollSpyIndicator />\n          <template v-for=\"(item, itemIdx) in items\" :key=\"item.href\">\n            <ScrollSpyItemComp\n              :value=\"item.href\"\n              :depth=\"item.depth ?? 1\"\n              :class=\"itemIdx > 0 && items[itemIdx - 1]?.children?.length ? 'mt-2' : undefined\"\n            >\n              <ScrollSpyLink :href=\"item.href\" :title=\"item.title\" />\n            </ScrollSpyItemComp>\n            <template v-for=\"(child, childIdx) in item.children ?? []\" :key=\"child.href\">\n              <ScrollSpyItemComp\n                :value=\"child.href\"\n                :depth=\"child.depth ?? 2\"\n                :class=\"\n                  childIdx === 0 || (childIdx > 0 && item.children?.[childIdx - 1]?.children?.length)\n                    ? 'mt-2'\n                    : undefined\n                \"\n              >\n                <ScrollSpyLink :href=\"child.href\" :title=\"child.title\" />\n              </ScrollSpyItemComp>\n              <template v-for=\"(grandchild, gIdx) in child.children ?? []\" :key=\"grandchild.href\">\n                <ScrollSpyItemComp\n                  :value=\"grandchild.href\"\n                  :depth=\"grandchild.depth ?? 3\"\n                  :class=\"gIdx === 0 ? 'mt-2' : undefined\"\n                >\n                  <ScrollSpyLink :href=\"grandchild.href\" :title=\"grandchild.title\" />\n                </ScrollSpyItemComp>\n              </template>\n            </template>\n          </template>\n        </ScrollSpyList>\n      </template>\n\n      <!-- Bottom Floating Stepper Capsule Mode -->\n      <ScrollSpyStepper v-if=\"resolvedPosition === 'bottom'\" />\n    </template>\n    <slot v-else />\n  </nav>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpy.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpyIndicator.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { SCROLL_SPY_CONTEXT_KEY, resolveScrollSpyColor, type ScrollSpyColor } from './context'\n\nconst props = defineProps<{\n  class?: HTMLAttributes['class']\n  color?: ScrollSpyColor\n}>()\n\nconst ctx = inject(SCROLL_SPY_CONTEXT_KEY, null)\nif (!ctx) throw new Error('ScrollSpyIndicator must be used inside <ScrollSpy>.')\n\nconst resolvedColor = computed(() => props.color ?? ctx.color.value ?? 'primary')\nconst handleColor = computed(() => resolveScrollSpyColor(resolvedColor.value))\n\ninterface Marker {\n  value: string\n  depth: number\n  x: number\n  y: number\n  top: number\n  bottom: number\n}\n\nconst measurePathRef = useTemplateRef<SVGPathElement>('measurePathRef')\nconst trackPath = ref('')\nconst pathLength = ref(0)\nconst activeStart = ref(0)\nconst activeEnd = ref(0)\nconst straightHighlight = ref({ top: 0, height: 0, visible: false })\nconst canAnimate = ref(false)\nconst activeMarker = ref<Marker | null>(null)\n\nfunction depthX(relDepth: number, isRightRail: boolean, listWidth: number): number {\n  if (!isRightRail) {\n    if (relDepth <= 1) return 1\n    if (relDepth === 2) return 13\n    if (relDepth === 3) return 21\n    return 21 + (relDepth - 3) * 8\n  }\n  // Right side rail\n  const base = listWidth - 1\n  if (relDepth <= 1) return base\n  if (relDepth === 2) return base - 12\n  if (relDepth === 3) return base - 20\n  return base - 20 - (relDepth - 3) * 8\n}\n\nfunction collectMarkers(): Marker[] {\n  const list = ctx?.getListEl()\n  if (!list || !ctx) return []\n  const listRect = list.getBoundingClientRect()\n  const validItems = ctx.items.value.filter((i) => i.el && i.el.isConnected)\n  if (validItems.length === 0) return []\n\n  const isRightRail = ctx.position.value === 'left' && ctx.railPosition.value === 'right'\n  const listWidth = listRect.width || 180\n\n  const minDepth = Math.min(...validItems.map((i) => i.depth))\n  const markers: Marker[] = []\n\n  for (const item of validItems) {\n    if (!item.el) continue\n    const rect = item.el.getBoundingClientRect()\n    const relDepth = Math.max(1, item.depth - minDepth + 1)\n    const top = rect.top - listRect.top\n    const bottom = rect.bottom - listRect.top\n    markers.push({\n      value: item.value,\n      depth: item.depth,\n      x: depthX(relDepth, isRightRail, listWidth),\n      y: top + rect.height / 2,\n      top,\n      bottom,\n    })\n  }\n\n  return markers.sort((a, b) => a.y - b.y)\n}\n\nfunction buildCircuitPath(markers: Marker[], endIndex: number, rounded: boolean, edge: 'top' | 'bottom'): string {\n  if (markers.length === 0 || endIndex < 0) return ''\n\n  const end = Math.min(endIndex, markers.length - 1)\n  const first = markers[0]!\n  const parts: string[] = [`M ${first.x} ${first.top}`]\n\n  for (let i = 0; i <= end; i++) {\n    const curr = markers[i]!\n\n    if (i === end) {\n      const targetY = edge === 'top' ? curr.top : curr.bottom\n      parts.push(`L ${curr.x} ${targetY}`)\n      break\n    }\n\n    const next = markers[i + 1]\n    if (!next) {\n      parts.push(`L ${curr.x} ${curr.bottom}`)\n      break\n    }\n\n    // Always draw down the full height of curr at curr.x first\n    parts.push(`L ${curr.x} ${curr.bottom}`)\n\n    if (curr.x === next.x) {\n      parts.push(`L ${curr.x} ${next.top}`)\n      continue\n    }\n\n    // Smooth monotonic depth transition strictly bounded within [curr.bottom, next.top]\n    const gap = Math.max(0, next.top - curr.bottom)\n    const absDx = Math.abs(next.x - curr.x)\n    const transitionH = Math.min(gap, absDx)\n\n    if (transitionH <= 1) {\n      parts.push(`L ${next.x} ${next.top}`)\n      continue\n    }\n\n    const y1 = curr.bottom + (gap - transitionH) / 2\n    let y2 = y1 + transitionH\n    if (next.top - y2 <= 0.5) {\n      y2 = next.top\n    }\n\n    // 1. Straight rail down to y1 at curr.x\n    if (y1 - curr.bottom > 0.5) {\n      parts.push(`L ${curr.x} ${y1}`)\n    }\n\n    // 2. Transition from (curr.x, y1) to (next.x, y2)\n    if (rounded) {\n      const midY = (y1 + y2) / 2\n      parts.push(`C ${curr.x} ${midY}, ${next.x} ${midY}, ${next.x} ${y2}`)\n    } else {\n      parts.push(`L ${next.x} ${y2}`)\n    }\n\n    // 3. Connect to next.top if next.top > y2\n    if (next.top - y2 > 0.5) {\n      parts.push(`L ${next.x} ${next.top}`)\n    }\n  }\n\n  return parts.join(' ')\n}\n\nfunction measurePathLength(pathD: string): number {\n  const el = measurePathRef.value\n  if (!el || !pathD) return 0\n  el.setAttribute('d', pathD)\n  if (typeof el.getTotalLength === 'function') {\n    try {\n      return el.getTotalLength()\n    } catch {\n      // ignore\n    }\n  }\n  return 100\n}\n\nasync function updateGeometry() {\n  if (!ctx) return\n  const markers = collectMarkers()\n  const activeValue = ctx.activeValue.value\n  const activeIndex = markers.findIndex(\n    (m) => m.value === activeValue || m.value.replace(/^#/, '') === activeValue.replace(/^#/, ''),\n  )\n  const turn = ctx.turn.value\n  const indicator = ctx.indicator.value\n  const isRightRail = ctx.position.value === 'left' && ctx.railPosition.value === 'right'\n\n  if (markers.length === 0) {\n    trackPath.value = ''\n    pathLength.value = 0\n    activeStart.value = 0\n    activeEnd.value = 0\n    activeMarker.value = null\n    straightHighlight.value = { top: 0, height: 0, visible: false }\n    return\n  }\n\n  const first = markers[0]!\n  const last = markers[markers.length - 1]!\n\n  if (turn === 'straight') {\n    const railX = isRightRail ? first.x : 1\n    trackPath.value = `M ${railX} ${first.top} L ${railX} ${last.bottom}`\n    pathLength.value = 0\n    activeStart.value = 0\n    activeEnd.value = 0\n\n    if (indicator === 'progress') {\n      const list = ctx.getListEl()\n      const totalHeight = list ? list.clientHeight : last.bottom - first.top\n      straightHighlight.value = {\n        top: first.top,\n        height: Math.max(0, totalHeight * ctx.scrollProgress.value),\n        visible: true,\n      }\n      activeMarker.value = null\n      enableAnimation()\n      return\n    }\n\n    if (activeIndex < 0) {\n      straightHighlight.value = { top: 0, height: 0, visible: false }\n      activeMarker.value = null\n      return\n    }\n\n    const active = markers[activeIndex]!\n    activeMarker.value = active\n\n    const isFillMode = indicator === 'fill' || (ctx?.keepScrolled.value ?? false)\n\n    if (isFillMode) {\n      straightHighlight.value = {\n        top: first.top,\n        height: Math.max(active.bottom - first.top, 14),\n        visible: true,\n      }\n    } else {\n      straightHighlight.value = {\n        top: active.top,\n        height: Math.max(active.bottom - active.top, 14),\n        visible: true,\n      }\n    }\n    enableAnimation()\n    return\n  }\n\n  // Circuit Mode: sharp 45° angle or rounded curves\n  const rounded = turn === 'rounded'\n  const fullPath = buildCircuitPath(markers, markers.length - 1, rounded, 'bottom')\n  trackPath.value = fullPath\n  straightHighlight.value = { top: 0, height: 0, visible: false }\n\n  await nextTick()\n\n  const total = measurePathLength(fullPath)\n  pathLength.value = total\n\n  if (total === 0) {\n    activeStart.value = 0\n    activeEnd.value = 0\n    activeMarker.value = null\n    return\n  }\n\n  if (indicator === 'progress') {\n    activeStart.value = 0\n    activeEnd.value = total * ctx.scrollProgress.value\n    activeMarker.value = null\n    enableAnimation()\n    return\n  }\n\n  if (activeIndex < 0) {\n    activeStart.value = 0\n    activeEnd.value = 0\n    activeMarker.value = null\n    return\n  }\n\n  const active = markers[activeIndex]!\n  activeMarker.value = active\n\n  const isFillMode = indicator === 'fill' || (ctx?.keepScrolled.value ?? false)\n  const startLength = isFillMode ? 0 : measurePathLength(buildCircuitPath(markers, activeIndex, rounded, 'top'))\n  const endLength = measurePathLength(buildCircuitPath(markers, activeIndex, rounded, 'bottom'))\n\n  activeStart.value = startLength\n  activeEnd.value = endLength\n  enableAnimation()\n}\n\nfunction enableAnimation() {\n  if (!canAnimate.value) {\n    if (typeof requestAnimationFrame !== 'undefined') {\n      requestAnimationFrame(() => {\n        canAnimate.value = true\n      })\n    } else {\n      canAnimate.value = true\n    }\n  }\n}\n\nlet rafId = 0\nfunction scheduleUpdate() {\n  if (typeof window === 'undefined') return\n  if (typeof cancelAnimationFrame !== 'undefined') {\n    cancelAnimationFrame(rafId)\n  }\n  if (typeof requestAnimationFrame !== 'undefined') {\n    rafId = requestAnimationFrame(() => {\n      updateGeometry()\n    })\n  } else {\n    updateGeometry()\n  }\n}\n\nonMounted(() => {\n  scheduleUpdate()\n  window.addEventListener('resize', scheduleUpdate)\n})\n\nonBeforeUnmount(() => {\n  if (typeof cancelAnimationFrame !== 'undefined') {\n    cancelAnimationFrame(rafId)\n  }\n  if (typeof window !== 'undefined') {\n    window.removeEventListener('resize', scheduleUpdate)\n  }\n})\n\nwatch(\n  () => [\n    ctx?.activeValue.value,\n    ctx?.items.value,\n    ctx?.turn.value,\n    ctx?.indicator.value,\n    ctx?.keepScrolled.value,\n    ctx?.resolvedLineWidth.value,\n    ctx?.position.value,\n    ctx?.railPosition.value,\n    ctx?.scrollProgress.value,\n  ],\n  () => {\n    scheduleUpdate()\n  },\n  { deep: true },\n)\n</script>\n\n<template>\n  <div\n    data-slot=\"scroll-spy-indicator\"\n    :class=\"cn('pointer-events-none absolute inset-0 overflow-visible', props.class)\"\n    aria-hidden=\"true\"\n  >\n    <svg v-if=\"ctx?.turn.value !== 'straight'\" class=\"absolute inset-0 size-full overflow-visible\" fill=\"none\">\n      <path ref=\"measurePathRef\" class=\"invisible\" fill=\"none\" />\n      <path\n        :d=\"trackPath\"\n        class=\"stroke-border\"\n        :stroke-width=\"ctx?.resolvedLineWidth.value ?? 2.5\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n      />\n      <path\n        v-if=\"trackPath && activeEnd > 0\"\n        :d=\"trackPath\"\n        :class=\"handleColor.strokeClass\"\n        :stroke-width=\"ctx?.resolvedLineWidth.value ?? 2.5\"\n        stroke-linecap=\"butt\"\n        stroke-linejoin=\"round\"\n        :style=\"{\n          stroke: handleColor.customColor,\n          strokeDasharray: `${Math.max(activeEnd - activeStart, 0)} ${Math.max(pathLength, 1)}`,\n          strokeDashoffset: -activeStart,\n          transition:\n            canAnimate && ctx?.indicator.value !== 'progress'\n              ? 'stroke-dashoffset 260ms cubic-bezier(0.16, 1, 0.3, 1), stroke-dasharray 260ms cubic-bezier(0.16, 1, 0.3, 1)'\n              : 'none',\n        }\"\n      />\n    </svg>\n\n    <div\n      v-if=\"\n        ctx?.turn.value === 'straight' &&\n        straightHighlight.visible &&\n        (ctx?.indicator.value !== 'segment' || ctx?.keepScrolled.value)\n      \"\n      :class=\"cn('absolute z-10 rounded-none', handleColor.bgClass)\"\n      :style=\"{\n        top: `${straightHighlight.top}px`,\n        height: `${straightHighlight.height}px`,\n        width: `${ctx?.resolvedLineWidth.value ?? 2.5}px`,\n        backgroundColor: handleColor.customColor,\n        left:\n          ctx?.position.value === 'left' && ctx?.railPosition.value === 'right'\n            ? undefined\n            : `-${ctx?.resolvedLineWidth.value ?? 2.5}px`,\n        right:\n          ctx?.position.value === 'left' && ctx?.railPosition.value === 'right'\n            ? `-${ctx?.resolvedLineWidth.value ?? 2.5}px`\n            : undefined,\n        transition:\n          canAnimate && ctx?.indicator.value !== 'progress'\n            ? 'top 260ms cubic-bezier(0.16, 1, 0.3, 1), height 260ms cubic-bezier(0.16, 1, 0.3, 1), opacity 200ms ease-out'\n            : 'none',\n      }\"\n    />\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpyIndicator.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpyItem.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, inject, onBeforeUnmount, onMounted, provide, ref, useTemplateRef, watch } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { SCROLL_SPY_CONTEXT_KEY, SCROLL_SPY_ITEM_DEPTH_KEY } from './context'\n\nconst props = withDefaults(\n  defineProps<{\n    value: string\n    title?: string\n    depth?: number\n    class?: HTMLAttributes['class']\n  }>(),\n  {\n    title: undefined,\n    depth: undefined,\n  },\n)\n\nconst ctx = inject(SCROLL_SPY_CONTEXT_KEY, null)\nconst parentDepth = inject(SCROLL_SPY_ITEM_DEPTH_KEY, ref(0))\nconst computedDepth = computed(() => props.depth ?? parentDepth.value + 1)\nprovide(SCROLL_SPY_ITEM_DEPTH_KEY, computedDepth)\n\nconst itemRef = useTemplateRef<HTMLLIElement>('itemRef')\n\nfunction register() {\n  if (!ctx) return\n  ctx.registerItem({\n    value: props.value,\n    title: props.title,\n    depth: computedDepth.value,\n    el: itemRef.value,\n  })\n}\n\nonMounted(() => {\n  register()\n})\n\nonBeforeUnmount(() => {\n  ctx?.unregisterItem(props.value)\n})\n\nwatch(\n  () => [props.value, computedDepth.value] as const,\n  ([newVal], [oldVal]) => {\n    if (oldVal && oldVal !== newVal) {\n      ctx?.unregisterItem(oldVal)\n    }\n    register()\n  },\n)\n</script>\n\n<template>\n  <li\n    ref=\"itemRef\"\n    data-slot=\"scroll-spy-item\"\n    :data-depth=\"computedDepth\"\n    :class=\"cn('relative flex flex-col', props.class)\"\n  >\n    <slot />\n  </li>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpyItem.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpyLink.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, inject, ref } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { SCROLL_SPY_CONTEXT_KEY, SCROLL_SPY_ITEM_DEPTH_KEY, resolveScrollSpyColor } from './context'\n\nconst props = defineProps<{\n  href: string\n  title?: string\n  depth?: number\n  class?: HTMLAttributes['class']\n}>()\n\nconst ctx = inject(SCROLL_SPY_CONTEXT_KEY, null)\nconst itemDepth = inject(SCROLL_SPY_ITEM_DEPTH_KEY, ref(1))\nconst selfDepth = computed(() => props.depth ?? itemDepth.value)\n\nconst isActive = computed(() => {\n  if (!ctx) return false\n  return ctx.isItemActive(props.href)\n})\n\nconst isParentActive = computed(() => {\n  if (!ctx) return false\n  return ctx.isItemParentActive(props.href)\n})\n\nconst isScrolled = computed(() => {\n  if (!ctx) return false\n  return ctx.isItemScrolled(props.href)\n})\n\nconst isCircuit = computed(() => ctx?.turn.value !== 'straight')\nconst isLeftWithRightRail = computed(() => ctx?.position.value === 'left' && ctx?.railPosition.value === 'right')\nconst hasIndicatorBar = computed(\n  () => ctx?.turn.value === 'straight' && (ctx?.indicator.value !== 'segment' || ctx?.keepScrolled.value),\n)\n\nconst handleColor = computed(() => resolveScrollSpyColor(ctx?.color.value ?? 'primary'))\n\nconst borderActiveClass = computed(() => {\n  if (hasIndicatorBar.value) {\n    if (isActive.value || isParentActive.value) return 'text-foreground font-medium'\n    if (isScrolled.value) return 'text-foreground/85'\n    return 'text-muted-foreground hover:text-foreground'\n  }\n  if (isActive.value) return cn(handleColor.value.borderClass, 'text-foreground font-medium')\n  if (isParentActive.value) return 'border-border/50 text-foreground font-medium'\n  if (isScrolled.value) return 'border-border/70 text-foreground/85'\n  return 'text-muted-foreground hover:border-foreground/40 hover:text-foreground'\n})\n\nfunction onClick(e: MouseEvent) {\n  e.preventDefault()\n  if (!ctx) return\n  ctx.scrollToHref(props.href)\n}\n</script>\n\n<template>\n  <a\n    :href=\"href\"\n    data-slot=\"scroll-spy-link\"\n    :aria-current=\"isActive ? 'location' : undefined\"\n    :data-active=\"isActive ? 'true' : 'false'\"\n    :data-parent-active=\"isParentActive ? 'true' : 'false'\"\n    :data-scrolled=\"isScrolled ? 'true' : 'false'\"\n    :data-depth=\"selfDepth\"\n    :style=\"\n      !isCircuit && !hasIndicatorBar && (isActive || isParentActive || isScrolled)\n        ? {\n            borderLeftWidth: isLeftWithRightRail ? undefined : `${ctx?.resolvedLineWidth.value ?? 2.5}px`,\n            borderRightWidth: isLeftWithRightRail ? `${ctx?.resolvedLineWidth.value ?? 2.5}px` : undefined,\n            marginLeft: isLeftWithRightRail ? undefined : `-${ctx?.resolvedLineWidth.value ?? 2.5}px`,\n            marginRight: isLeftWithRightRail ? `-${ctx?.resolvedLineWidth.value ?? 2.5}px` : undefined,\n            borderColor: isActive ? handleColor.customColor : undefined,\n          }\n        : undefined\n    \"\n    :class=\"\n      cn(\n        'group block rounded-none leading-snug no-underline transition-[color,border-color,background-color,opacity,border-width,margin] duration-200 ease-out',\n        'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',\n        isCircuit\n          ? [\n              'py-1',\n              isLeftWithRightRail\n                ? [\n                    'text-right',\n                    selfDepth <= 1 && 'pr-4 pl-2 text-sm',\n                    selfDepth === 2 && 'pr-7 pl-2 text-xs',\n                    selfDepth === 3 && 'pr-10 pl-2 text-xs',\n                    selfDepth >= 4 && 'pr-12 pl-2 text-xs',\n                  ]\n                : [\n                    selfDepth <= 1 && 'pr-2 pl-4 text-sm',\n                    selfDepth === 2 && 'pr-2 pl-7 text-xs',\n                    selfDepth === 3 && 'pr-2 pl-10 text-xs',\n                    selfDepth >= 4 && 'pr-2 pl-12 text-xs',\n                  ],\n              isActive || isParentActive\n                ? 'text-foreground font-medium'\n                : isScrolled\n                  ? 'text-foreground/85'\n                  : 'text-muted-foreground hover:text-foreground',\n            ]\n          : isLeftWithRightRail\n            ? [\n                '-mr-px border-r border-transparent py-0.5 pr-3 pl-2 text-right',\n                selfDepth <= 1 && 'text-sm',\n                selfDepth === 2 && 'pr-6 text-xs',\n                selfDepth === 3 && 'pr-9 text-xs',\n                selfDepth >= 4 && 'pr-11 text-xs',\n                borderActiveClass,\n              ]\n            : [\n                '-ml-px border-l border-transparent py-0.5 pr-2 pl-3',\n                selfDepth <= 1 && 'text-sm',\n                selfDepth === 2 && 'pl-6 text-xs',\n                selfDepth === 3 && 'pl-9 text-xs',\n                selfDepth >= 4 && 'pl-11 text-xs',\n                borderActiveClass,\n              ],\n        props.class,\n      )\n    \"\n    @click=\"onClick\"\n  >\n    <slot>{{ title }}</slot>\n  </a>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpyLink.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpyList.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, inject, onMounted, useTemplateRef } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { SCROLL_SPY_CONTEXT_KEY } from './context'\n\nconst props = defineProps<{\n  class?: HTMLAttributes['class']\n}>()\n\nconst ctx = inject(SCROLL_SPY_CONTEXT_KEY, null)\nconst listRef = useTemplateRef<HTMLUListElement>('listRef')\n\nconst isStraight = computed(() => ctx?.turn.value === 'straight')\nconst isRightRail = computed(() => ctx?.position.value === 'left' && ctx?.railPosition.value === 'right')\nconst lineWidthPx = computed(() => `${ctx?.resolvedLineWidth.value ?? 2.5}px`)\n\nonMounted(() => {\n  if (ctx && listRef.value) {\n    ctx.setListEl(listRef.value)\n  }\n})\n</script>\n\n<template>\n  <ul\n    ref=\"listRef\"\n    data-slot=\"scroll-spy-list\"\n    :style=\"{\n      borderLeftWidth: isStraight && !isRightRail ? lineWidthPx : undefined,\n      borderRightWidth: isStraight && isRightRail ? lineWidthPx : undefined,\n    }\"\n    :class=\"\n      cn(\n        'relative flex flex-col space-y-1 text-sm',\n        isStraight && (isRightRail ? 'border-border border-r' : 'border-border border-l'),\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </ul>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpyList.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpyTitle.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, inject } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { SCROLL_SPY_CONTEXT_KEY } from './context'\n\nconst props = defineProps<{\n  class?: HTMLAttributes['class']\n}>()\n\nconst ctx = inject(SCROLL_SPY_CONTEXT_KEY, null)\nconst isRightRail = computed(() => ctx?.position.value === 'left' && ctx?.railPosition.value === 'right')\n</script>\n\n<template>\n  <p\n    data-slot=\"scroll-spy-title\"\n    :class=\"\n      cn('text-foreground mb-3 text-sm font-semibold tracking-tight', isRightRail && 'pr-3 text-right', props.class)\n    \"\n  >\n    <slot />\n  </p>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpyTitle.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/ScrollSpyStepper.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, inject } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { SCROLL_SPY_CONTEXT_KEY } from './context'\n\nconst props = defineProps<{\n  class?: HTMLAttributes['class']\n}>()\n\nconst ctx = inject(SCROLL_SPY_CONTEXT_KEY, null)\nif (!ctx) throw new Error('ScrollSpyStepper must be used inside <ScrollSpy>.')\n\nconst items = computed(() => ctx.items.value)\nconst activeValue = computed(() => ctx.activeValue.value)\nconst scrollProgress = computed(() => ctx.scrollProgress.value)\nconst position = computed(() => ctx.position.value)\n\nconst activeIndex = computed(() => {\n  const list = items.value\n  const active = activeValue.value\n  const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, ''))\n  return idx >= 0 ? idx : 0\n})\n\nconst activeItem = computed(() => items.value[activeIndex.value])\nconst activeTitle = computed(() => {\n  if (!activeItem.value) return ''\n  return (\n    activeItem.value.title ||\n    activeItem.value.value\n      .replace(/^#/, '')\n      .replace(/[-_]/g, ' ')\n      .replace(/\\b\\w/g, (c) => c.toUpperCase())\n  )\n})\n\nconst isTop = computed(() => position.value === 'top')\nconst isBottom = computed(() => position.value === 'bottom')\nconst variant = computed(() => ctx.variant.value)\nconst indicator = computed(() => ctx.indicator.value)\nconst isScrollSpy = computed(\n  () =>\n    variant.value === 'scrollspy' ||\n    variant.value === 'tabs' ||\n    variant.value === 'pills' ||\n    indicator.value === 'pill' ||\n    indicator.value === 'dot',\n)\n</script>\n\n<template>\n  <!-- Top Sticky Scroll Spy Bar (Highlights ONLY the active section) -->\n  <div\n    v-if=\"isTop && isScrollSpy\"\n    data-slot=\"scroll-spy-top\"\n    :class=\"\n      cn(\n        'border-border/70 bg-card/85 relative sticky top-0 z-20 flex w-full scrollbar-none items-center gap-1 overflow-x-auto rounded-xl border p-1.5 shadow-xs backdrop-blur-md',\n        props.class,\n      )\n    \"\n  >\n    <button\n      v-for=\"(item, idx) in items\"\n      :key=\"item.value\"\n      type=\"button\"\n      :data-active=\"idx === activeIndex ? 'true' : 'false'\"\n      :class=\"\n        cn(\n          'group relative flex shrink-0 items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 outline-none select-none',\n          'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 active:scale-[0.98]',\n          idx === activeIndex\n            ? 'bg-primary/10 text-primary font-semibold shadow-2xs'\n            : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',\n        )\n      \"\n      @click=\"ctx.scrollToHref(item.value)\"\n    >\n      <!-- Active Dot Indicator -->\n      <span v-if=\"idx === activeIndex\" class=\"bg-primary size-1.5 shrink-0 rounded-full\" />\n      <!-- Section Title -->\n      <span class=\"truncate\">\n        {{\n          item.title ||\n          item.value\n            .replace(/^#/, '')\n            .replace(/[-_]/g, ' ')\n            .replace(/\\b\\w/g, (c) => c.toUpperCase())\n        }}\n      </span>\n    </button>\n\n    <!-- Slim Bottom Reading Progress Line -->\n    <div class=\"bg-border/20 absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-xl\">\n      <div\n        class=\"bg-primary h-full transition-[width] duration-150 ease-out\"\n        :style=\"{ width: `${Math.round(scrollProgress * 100)}%` }\"\n      />\n    </div>\n  </div>\n\n  <!-- Top Sticky Stepper Bar (Cumulative Step Wizard with Step Numbers & Connecting Lines) -->\n  <div\n    v-else-if=\"isTop\"\n    data-slot=\"scroll-spy-stepper-top\"\n    :class=\"\n      cn(\n        'border-border/70 bg-card/85 sticky top-0 z-20 flex w-full scrollbar-none items-center gap-1.5 overflow-x-auto rounded-xl border p-2 shadow-xs backdrop-blur-md',\n        props.class,\n      )\n    \"\n  >\n    <template v-for=\"(item, idx) in items\" :key=\"item.value\">\n      <!-- Step Node Button -->\n      <button\n        type=\"button\"\n        :data-active=\"idx === activeIndex ? 'true' : 'false'\"\n        :class=\"\n          cn(\n            'group flex shrink-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-xs font-medium transition-[color,background-color,transform] duration-150 outline-none select-none',\n            'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 active:scale-[0.98]',\n            idx === activeIndex\n              ? 'bg-primary/10 text-foreground font-medium shadow-2xs'\n              : idx < activeIndex\n                ? 'text-foreground/80 hover:bg-muted/50'\n                : 'text-muted-foreground hover:bg-muted/30 hover:text-foreground',\n          )\n        \"\n        @click=\"ctx.scrollToHref(item.value)\"\n      >\n        <!-- Step Number / Dot -->\n        <span\n          :class=\"\n            cn(\n              'flex size-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] transition-all duration-200',\n              idx === activeIndex\n                ? 'bg-primary text-primary-foreground scale-105 font-semibold shadow-2xs'\n                : idx < activeIndex\n                  ? 'bg-primary/15 text-primary font-medium'\n                  : 'bg-muted text-muted-foreground/70',\n            )\n          \"\n        >\n          <svg\n            v-if=\"idx < activeIndex\"\n            class=\"size-3 stroke-[2.5]\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" />\n          </svg>\n          <span v-else>{{ idx + 1 }}</span>\n        </span>\n\n        <!-- Step Title -->\n        <span class=\"max-w-[120px] truncate\">\n          {{\n            item.title ||\n            item.value\n              .replace(/^#/, '')\n              .replace(/[-_]/g, ' ')\n              .replace(/\\b\\w/g, (c) => c.toUpperCase())\n          }}\n        </span>\n      </button>\n\n      <!-- Connecting Progress Line -->\n      <div\n        v-if=\"idx < items.length - 1\"\n        class=\"bg-border/70 h-0.5 max-w-10 min-w-4 flex-1 rounded-full transition-colors duration-200\"\n        :class=\"idx < activeIndex && 'bg-primary'\"\n      />\n    </template>\n  </div>\n\n  <!-- Bottom Floating Stepper Capsule -->\n  <div\n    v-else-if=\"isBottom\"\n    data-slot=\"scroll-spy-stepper-bottom\"\n    :class=\"\n      cn(\n        'border-border/80 bg-background/95 sticky bottom-3 z-30 mx-auto flex items-center gap-2 rounded-full border px-3 py-1.5 shadow-md backdrop-blur-md select-none',\n        props.class,\n      )\n    \"\n  >\n    <!-- Previous Section Button -->\n    <button\n      type=\"button\"\n      aria-label=\"Previous section\"\n      :disabled=\"activeIndex <= 0\"\n      class=\"text-muted-foreground hover:bg-muted/80 hover:text-foreground flex size-7 items-center justify-center rounded-full transition-all active:scale-95 disabled:pointer-events-none disabled:opacity-25\"\n      @click=\"ctx.goToPrev\"\n    >\n      <svg class=\"size-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15.75 19.5L8.25 12l7.5-7.5\" />\n      </svg>\n    </button>\n\n    <!-- Segment / Dot Indicators -->\n    <div class=\"flex items-center gap-1.5 px-1\">\n      <button\n        v-for=\"(item, idx) in items\"\n        :key=\"item.value\"\n        type=\"button\"\n        :aria-label=\"`Jump to section ${idx + 1}`\"\n        :class=\"\n          cn(\n            'h-1.5 cursor-pointer rounded-full transition-all duration-200',\n            idx === activeIndex\n              ? 'bg-primary w-5'\n              : idx < activeIndex\n                ? 'bg-primary/40 hover:bg-primary/60 w-2'\n                : 'bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2',\n          )\n        \"\n        @click=\"ctx.scrollToHref(item.value)\"\n      />\n    </div>\n\n    <!-- Active Section Name -->\n    <div class=\"border-border/60 flex items-center gap-2 border-l pl-2\">\n      <span class=\"text-foreground max-w-[130px] truncate text-xs font-medium tracking-tight\">\n        {{ activeTitle }}\n      </span>\n      <span class=\"bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 font-mono text-[10px] tabular-nums\">\n        {{ Math.round(scrollProgress * 100) }}%\n      </span>\n    </div>\n\n    <!-- Next Section Button -->\n    <button\n      type=\"button\"\n      aria-label=\"Next section\"\n      :disabled=\"activeIndex >= items.length - 1\"\n      class=\"text-muted-foreground hover:bg-muted/80 hover:text-foreground flex size-7 items-center justify-center rounded-full transition-all active:scale-95 disabled:pointer-events-none disabled:opacity-25\"\n      @click=\"ctx.goToNext\"\n    >\n      <svg class=\"size-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M8.25 4.5l7.5 7.5-7.5 7.5\" />\n      </svg>\n    </button>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/ScrollSpyStepper.vue"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/context.ts",
      "content": "import type { InjectionKey, Ref } from 'vue'\n\nexport type ScrollSpyTurn = 'straight' | 'sharp' | 'rounded'\nexport type ScrollSpyVariant =\n  | 'default'\n  | 'line'\n  | 'angle'\n  | 'sharp'\n  | 'rounded'\n  | 'stepper'\n  | 'scrollspy'\n  | 'tabs'\n  | 'pills'\nexport type ScrollSpyIndicatorMode = 'segment' | 'fill' | 'progress' | 'pill' | 'dot' | 'line'\nexport type ScrollSpyPosition = 'right' | 'left' | 'top' | 'bottom'\nexport type ScrollSpyRailPosition = 'left' | 'right'\n\nexport type ScrollSpyLineWidth = 'thin' | 'default' | 'thick' | number\nexport type ScrollSpyColor = 'primary' | 'foreground' | 'destructive' | 'secondary' | (string & {})\n\nexport interface HandleColorResolved {\n  bgClass: string\n  strokeClass: string\n  borderClass: string\n  customColor?: string\n}\n\nexport function resolveScrollSpyColor(color: ScrollSpyColor = 'primary'): HandleColorResolved {\n  if (color === 'primary')\n    return {\n      bgClass: 'bg-primary',\n      strokeClass: 'stroke-primary',\n      borderClass: 'border-primary',\n      customColor: undefined,\n    }\n  if (color === 'foreground')\n    return {\n      bgClass: 'bg-foreground',\n      strokeClass: 'stroke-foreground',\n      borderClass: 'border-foreground',\n      customColor: undefined,\n    }\n  if (color === 'destructive')\n    return {\n      bgClass: 'bg-destructive',\n      strokeClass: 'stroke-destructive',\n      borderClass: 'border-destructive',\n      customColor: undefined,\n    }\n  if (color === 'secondary')\n    return {\n      bgClass: 'bg-secondary',\n      strokeClass: 'stroke-secondary',\n      borderClass: 'border-secondary',\n      customColor: undefined,\n    }\n  if (color.startsWith('bg-')) {\n    const raw = color.replace(/^bg-/, '')\n    return { bgClass: color, strokeClass: `stroke-${raw}`, borderClass: `border-${raw}`, customColor: undefined }\n  }\n  if (\n    color.startsWith('#') ||\n    color.startsWith('oklch') ||\n    color.startsWith('rgb') ||\n    color.startsWith('hsl') ||\n    color.startsWith('var(')\n  ) {\n    return { bgClass: '', strokeClass: '', borderClass: '', customColor: color }\n  }\n  return {\n    bgClass: `bg-${color}`,\n    strokeClass: `stroke-${color}`,\n    borderClass: `border-${color}`,\n    customColor: undefined,\n  }\n}\n\nexport interface RegisteredItem {\n  value: string\n  depth: number\n  el: HTMLElement | null\n  title?: string\n  parentValue?: string | null\n}\n\nexport interface ScrollSpyContext {\n  activeValue: Ref<string>\n  setActiveValue: (value: string) => void\n  scrollProgress: Ref<number>\n  registerItem: (item: RegisteredItem) => void\n  unregisterItem: (value: string) => void\n  variant: Ref<ScrollSpyVariant>\n  turn: Ref<ScrollSpyTurn>\n  indicator: Ref<ScrollSpyIndicatorMode>\n  keepScrolled: Ref<boolean>\n  highlightParent: Ref<boolean>\n  lineWidth: Ref<ScrollSpyLineWidth>\n  resolvedLineWidth: Ref<number>\n  color: Ref<ScrollSpyColor>\n  position: Ref<ScrollSpyPosition>\n  railPosition: Ref<ScrollSpyRailPosition>\n  scrollProgressSmooth: Ref<boolean>\n  scrollContainer: Ref<HTMLElement | Window | null>\n  offsetTop: Ref<number>\n  items: Ref<RegisteredItem[]>\n  getListEl: () => HTMLElement | null\n  setListEl: (el: HTMLElement | null) => void\n  scrollToHref: (href: string) => void\n  goToPrev: () => void\n  goToNext: () => void\n  isItemActive: (value: string) => boolean\n  isItemParentActive: (value: string) => boolean\n  isItemScrolled: (value: string) => boolean\n}\n\nexport const SCROLL_SPY_CONTEXT_KEY: InjectionKey<ScrollSpyContext> = Symbol('uipkge-scroll-spy-context')\nexport const SCROLL_SPY_ITEM_DEPTH_KEY: InjectionKey<Ref<number>> = Symbol('uipkge-scroll-spy-depth')\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/context.ts"
    },
    {
      "path": "packages/registry-vue/components/scroll-spy/index.ts",
      "content": "import ScrollSpyComp from './ScrollSpy.vue'\nimport ScrollSpyIndicatorComp from './ScrollSpyIndicator.vue'\nimport ScrollSpyItemComp from './ScrollSpyItem.vue'\nimport ScrollSpyLinkComp from './ScrollSpyLink.vue'\nimport ScrollSpyListComp from './ScrollSpyList.vue'\nimport ScrollSpyTitleComp from './ScrollSpyTitle.vue'\nimport ScrollSpyStepperComp from './ScrollSpyStepper.vue'\n\nexport interface ScrollSpyItem {\n  href: string\n  title: string\n  depth?: number\n  children?: ScrollSpyItem[]\n}\n\nexport type {\n  ScrollSpyVariant,\n  ScrollSpyTurn,\n  ScrollSpyIndicatorMode,\n  ScrollSpyPosition,\n  ScrollSpyRailPosition,\n  ScrollSpyLineWidth,\n  ScrollSpyColor,\n} from './context'\n\nexport const ScrollSpy = Object.assign(ScrollSpyComp, {\n  Root: ScrollSpyComp,\n  Title: ScrollSpyTitleComp,\n  List: ScrollSpyListComp,\n  Indicator: ScrollSpyIndicatorComp,\n  Item: ScrollSpyItemComp,\n  Link: ScrollSpyLinkComp,\n  Stepper: ScrollSpyStepperComp,\n})\n\nexport const ScrollSpyRoot = ScrollSpyComp\nexport const ScrollSpyTitle = ScrollSpyTitleComp\nexport const ScrollSpyList = ScrollSpyListComp\nexport const ScrollSpyIndicator = ScrollSpyIndicatorComp\nexport const ScrollSpyItem = ScrollSpyItemComp\nexport const ScrollSpyLink = ScrollSpyLinkComp\nexport const ScrollSpyStepper = ScrollSpyStepperComp\n\nexport default ScrollSpy\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/scroll-spy/index.ts"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "In-page navigation list with scroll-spy. Renders a vertical list of links; the active item highlights as the user scrolls through anchored sections.",
  "categories": [
    "navigation"
  ]
}