{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "minimap-scrollbar",
  "title": "Minimap scrollbar",
  "description": "Minimap scrollbar is a production-ready navigation component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/navigation/minimap-scrollbar.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type RefObject,\n} from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { LiveRegion } from \"@/components/bjork-ui/_core/a11y\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\n\nexport interface MinimapMark {\n  at: number;\n  label?: string;\n  level?: 1 | 2 | 3 | 4;\n}\n\nexport interface MinimapScrollbarProps {\n  target: RefObject<HTMLElement | null>;\n  selector?: string;\n  marks?: MinimapMark[];\n  side?: \"right\" | \"left\";\n  width?: number;\n  showLabels?: boolean;\n  showBlocks?: boolean;\n  lens?: boolean;\n  onJump?: (mark: MinimapMark) => void;\n  ariaLabel?: string;\n  tone?: BjorkTone;\n  attract?: boolean;\n  className?: string;\n  /** Document fraction (0-1) to pin the hover lens and label to. For previews and tests. */\n  debugHoverAt?: number;\n}\n\ninterface Mark {\n  at: number;\n  label: string;\n  level: 1 | 2 | 3 | 4;\n  block: boolean;\n  /** Section number shown in the label pill, \"01\"..\"99\". Only set on headings. */\n  num?: string;\n}\n\ninterface DomMarks {\n  headings: Mark[];\n  blocks: Mark[];\n}\n\ninterface DragState {\n  id: number;\n  startY: number;\n  startScroll: number;\n  trackH: number;\n  scrollH: number;\n}\n\nconst DEFAULT_SELECTOR = \"h2, h3\";\nconst BLOCK_SELECTOR = \"p, pre, figure, ul, ol\";\nconst HEADING_LEVELS: Record<string, 1 | 2 | 3 | 4> = { H1: 1, H2: 1, H3: 2, H4: 3, H5: 3, H6: 3 };\nconst TICK_SIZE: Record<Mark[\"level\"], { w: number; h: number }> = {\n  1: { w: 16, h: 1.5 },\n  2: { w: 10, h: 1 },\n  3: { w: 6, h: 1 },\n  4: { w: 4, h: 1 },\n};\nconst COARSE_TRACK_WIDTH = 24;\nconst LENS_RANGE = 48;\nconst LENS_SIGMA = 18;\nconst LENS_GAIN = 0.6;\nconst LABEL_FLASH_MS = 900;\nconst LABEL_FADE_MS = 120;\nconst MUTATION_DEBOUNCE_MS = 150;\nconst ATTRACT_SPEED = 40;\nconst ATTRACT_PAUSE_MS = 1000;\nconst ATTRACT_IDLE_RESUME_MS = 4000;\n\nconst WINDOW_FILL = { dark: \"rgba(237,237,237,0.04)\", light: \"rgba(23,23,23,0.04)\" } as const;\n// bjorkMenu surface and shadow, from the menu tokens in app/globals.css.\nconst MENU = {\n  dark: {\n    background: \"rgba(18, 18, 18, 0.98)\",\n    shadow:\n      \"inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)\",\n  },\n  light: {\n    background: \"rgba(255, 252, 246, 0.98)\",\n    shadow:\n      \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)\",\n  },\n} as const;\n\ntype Palette = (typeof BJORK_PALETTE)[BjorkTone];\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction clamp01(value: number) {\n  return clamp(value, 0, 1);\n}\n\nfunction cleanText(value: string | null | undefined) {\n  return (value ?? \"\").replace(/\\s+/g, \" \").trim();\n}\n\n// offsetTop is relative to the offsetParent. When the target is not positioned, fall back to rects.\nfunction offsetWithin(el: HTMLElement, container: HTMLElement) {\n  if (el.offsetParent === container) return el.offsetTop;\n  return el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;\n}\n\nfunction readDomMarks(container: HTMLElement, headingSelector: string | null, showBlocks: boolean): DomMarks {\n  const scrollH = Math.max(container.scrollHeight, 1);\n  const headings: Mark[] = headingSelector\n    ? Array.from(container.querySelectorAll<HTMLElement>(headingSelector)).map((el) => ({\n        at: clamp01(offsetWithin(el, container) / scrollH),\n        label: cleanText(el.textContent),\n        level: HEADING_LEVELS[el.tagName] ?? 2,\n        block: false,\n      }))\n    : [];\n  const blocks: Mark[] = showBlocks\n    ? Array.from(container.querySelectorAll<HTMLElement>(BLOCK_SELECTOR)).map((el) => ({\n        at: clamp01(offsetWithin(el, container) / scrollH),\n        label: cleanText(el.textContent),\n        level: 4,\n        block: true,\n      }))\n    : [];\n  return { headings, blocks };\n}\n\nfunction sameMarks(a: Mark[], b: Mark[]) {\n  if (a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) {\n    if (a[i].at !== b[i].at || a[i].label !== b[i].label || a[i].level !== b[i].level) return false;\n  }\n  return true;\n}\n\nfunction nearestIndex(list: Mark[], at: number) {\n  let best = -1;\n  let dist = Infinity;\n  list.forEach((mark, index) => {\n    const d = Math.abs(mark.at - at);\n    if (d < dist) {\n      dist = d;\n      best = index;\n    }\n  });\n  return best;\n}\n\nfunction tickColor(palette: Palette, level: Mark[\"level\"]) {\n  if (level === 1) return palette.textMuted;\n  if (level === 4) return palette.hair;\n  return palette.textSoft;\n}\n\nfunction subscribeMediaQuery(query: string) {\n  return (onChange: () => void) => {\n    const list = window.matchMedia(query);\n    list.addEventListener(\"change\", onChange);\n    return () => list.removeEventListener(\"change\", onChange);\n  };\n}\n\nfunction useMediaQuery(query: string) {\n  return useSyncExternalStore(\n    subscribeMediaQuery(query),\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n\nexport function MinimapScrollbar({\n  target,\n  selector,\n  marks,\n  side = \"right\",\n  width = 28,\n  showLabels = true,\n  showBlocks = true,\n  lens = true,\n  onJump,\n  ariaLabel = \"Document position\",\n  tone,\n  attract = false,\n  className,\n  debugHoverAt,\n}: MinimapScrollbarProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const palette = BJORK_PALETTE[resolvedTone];\n  const menu = MENU[resolvedTone];\n  const reducedMotion = useReducedMotion() ?? false;\n  const coarse = useMediaQuery(\"(pointer: coarse)\");\n  const trackWidth = coarse ? Math.min(width, COARSE_TRACK_WIDTH) : width;\n  const lensOn = lens && !coarse && !reducedMotion;\n  const reactId = useId();\n  const contentIdBase = reactId.replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const hasMarksProp = marks !== undefined;\n\n  const [dom, setDom] = useState<DomMarks>({ headings: [], blocks: [] });\n  const [labelIdx, setLabelIdx] = useState(-1);\n  const [labelOpen, setLabelOpen] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const windowRef = useRef<HTMLDivElement>(null);\n  const lineRef = useRef<HTMLDivElement>(null);\n  const pillRef = useRef<HTMLDivElement>(null);\n  const tickRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const trackHRef = useRef(0);\n  const marksRef = useRef<Mark[]>([]);\n  // 0 = outside the viewport, 1 = inside it, 2 = unknown (forces a repaint of every tick).\n  const litRef = useRef<Uint8Array>(new Uint8Array(0));\n  const rafRef = useRef(0);\n  const dragRef = useRef<DragState | null>(null);\n  const pillYRef = useRef<number | null>(null);\n  const lensYRef = useRef<number | null>(null);\n  const flashTimerRef = useRef(0);\n  const attractStateRef = useRef({ started: false, pos: 0, dir: 1 as 1 | -1, hold: 0, wrote: 0 });\n  const attractUserRef = useRef(false);\n  const attractIdleTimerRef = useRef(0);\n  const rectRef = useRef<DOMRect | null>(null);\n  // The scroll container, read from the target prop after each commit. Callbacks use this ref, not the prop.\n  const containerRef = useRef<HTMLElement | null>(null);\n  useLayoutEffect(() => {\n    containerRef.current = target.current;\n  });\n\n  const headings = useMemo(() => {\n    const base: Mark[] = hasMarksProp\n      ? marks.map((m) => ({ at: clamp01(m.at), label: cleanText(m.label), level: m.level ?? 1, block: false }))\n      : dom.headings;\n    const sorted = [...base].sort((a, b) => a.at - b.at);\n    const numbered: Mark[] = [];\n    let section = 0;\n    for (const mark of sorted) {\n      if (mark.level === 1) section += 1;\n      numbered.push({ ...mark, num: section ? String(section).padStart(2, \"0\") : \"\" });\n    }\n    return numbered;\n  }, [hasMarksProp, marks, dom.headings]);\n\n  const allMarks = useMemo(\n    () => [...headings, ...(showBlocks ? dom.blocks : [])].sort((a, b) => a.at - b.at),\n    [headings, dom.blocks, showBlocks],\n  );\n\n  // A debug pose pins the label to a fixed position; otherwise it follows hover or keyboard flashes.\n  const shownIdx = debugHoverAt != null ? nearestIndex(headings, clamp01(debugHoverAt)) : labelIdx;\n  const shownOpen = debugHoverAt != null ? true : labelOpen;\n  const pillMark = showLabels && shownIdx >= 0 ? headings[shownIdx] : undefined;\n\n  // Scroll sync. Reads scroll metrics once, then writes transforms only.\n  const sync = useCallback(() => {\n    const t = containerRef.current;\n    const win = windowRef.current;\n    const line = lineRef.current;\n    const track = trackRef.current;\n    if (!t) return;\n    const scrollTop = t.scrollTop;\n    const scrollH = Math.max(t.scrollHeight, 1);\n    const clientH = t.clientHeight;\n    const maxScroll = scrollH - clientH;\n    const overflow = maxScroll > 1;\n    const trackH = trackHRef.current;\n\n    const top = overflow ? scrollTop / scrollH : 0;\n    const size = overflow ? clientH / scrollH : 1;\n    if (win) {\n      win.style.transform = `translateY(${top * trackH}px)`;\n      win.style.height = `${size * trackH}px`;\n    }\n    if (line) {\n      line.style.transform = `translateY(${((scrollTop + clientH / 2) / scrollH) * trackH}px)`;\n    }\n\n    const vTop = scrollTop / scrollH;\n    const vBottom = (scrollTop + clientH) / scrollH;\n    const marksNow = marksRef.current;\n    const lit = litRef.current;\n    const ticks = tickRefs.current;\n    for (let i = 0; i < marksNow.length; i++) {\n      const on = marksNow[i].at >= vTop && marksNow[i].at <= vBottom ? 1 : 0;\n      if (lit[i] === on) continue;\n      lit[i] = on;\n      const el = ticks[i];\n      if (el) el.style.backgroundColor = on ? palette.text : tickColor(palette, marksNow[i].level);\n    }\n\n    if (track) {\n      const value = overflow ? Math.round(clamp01(scrollTop / maxScroll) * 100) : 0;\n      track.setAttribute(\"aria-valuenow\", String(value));\n    }\n  }, [palette]);\n\n  const scheduleSync = useCallback(() => {\n    if (rafRef.current) return;\n    rafRef.current = requestAnimationFrame(() => {\n      rafRef.current = 0;\n      sync();\n    });\n  }, [sync]);\n\n  // Re-reads marks and track size from the DOM. Only called on mount, resize and DOM mutation, never on scroll.\n  const measure = useCallback(() => {\n    const t = containerRef.current;\n    const track = trackRef.current;\n    if (!t) return;\n    if (track) trackHRef.current = track.clientHeight;\n    rectRef.current = null;\n    const next = readDomMarks(t, hasMarksProp ? null : selector ?? DEFAULT_SELECTOR, showBlocks);\n    setDom((prev) =>\n      sameMarks(prev.headings, next.headings) && sameMarks(prev.blocks, next.blocks) ? prev : next,\n    );\n    sync();\n  }, [selector, hasMarksProp, showBlocks, sync]);\n\n  // Writes the lens scale and the label pill position.\n  const applyHover = useCallback(() => {\n    const trackH = trackHRef.current;\n    const pill = pillRef.current;\n    const pillY = debugHoverAt != null ? clamp01(debugHoverAt) * trackH : pillYRef.current;\n    if (pill && pillY != null) pill.style.transform = `translateY(${pillY}px)`;\n\n    const lensY =\n      debugHoverAt != null ? clamp01(debugHoverAt) * trackH : lensOn ? lensYRef.current : null;\n    const marksNow = marksRef.current;\n    const ticks = tickRefs.current;\n    for (let i = 0; i < marksNow.length; i++) {\n      const el = ticks[i];\n      if (!el) continue;\n      let scale = 1;\n      if (lensY != null) {\n        const dy = lensY - marksNow[i].at * trackH;\n        if (Math.abs(dy) < LENS_RANGE) {\n          scale = 1 + LENS_GAIN * Math.exp(-(dy * dy) / (2 * LENS_SIGMA * LENS_SIGMA));\n        }\n      }\n      el.style.transform = scale === 1 ? \"\" : `scaleX(${scale.toFixed(3)})`;\n    }\n  }, [debugHoverAt, lensOn]);\n\n  // Marks or tone changed: refresh the tick arrays and repaint every tick.\n  useLayoutEffect(() => {\n    marksRef.current = allMarks;\n    tickRefs.current.length = allMarks.length;\n    litRef.current = new Uint8Array(allMarks.length).fill(2);\n    sync();\n    applyHover();\n  }, [allMarks, resolvedTone, sync, applyHover]);\n\n  // The pill mounts after the state change commits, so position it after commit.\n  useLayoutEffect(() => {\n    applyHover();\n  }, [applyHover, shownIdx, shownOpen]);\n\n  // Target wiring: scroll listener, observers and aria-controls.\n  useEffect(() => {\n    const t = containerRef.current;\n    const track = trackRef.current;\n    if (!t || !track) return;\n    if (!t.id) t.id = `${contentIdBase}-content`;\n    track.setAttribute(\"aria-controls\", t.id);\n\n    t.addEventListener(\"scroll\", scheduleSync, { passive: true });\n\n    // ResizeObserver fires once on observe, which covers the initial measure.\n    const ro = new ResizeObserver(() => {\n      trackHRef.current = track.clientHeight || trackHRef.current;\n      measure();\n    });\n    ro.observe(track);\n    ro.observe(t);\n    const content = t.firstElementChild;\n    if (content instanceof HTMLElement) ro.observe(content);\n\n    let mutationTimer = 0;\n    const mo = new MutationObserver(() => {\n      window.clearTimeout(mutationTimer);\n      mutationTimer = window.setTimeout(measure, MUTATION_DEBOUNCE_MS);\n    });\n    mo.observe(t, { childList: true, subtree: true });\n\n    return () => {\n      t.removeEventListener(\"scroll\", scheduleSync);\n      ro.disconnect();\n      mo.disconnect();\n      window.clearTimeout(mutationTimer);\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = 0;\n    };\n  }, [contentIdBase, measure, scheduleSync]);\n\n  // Attract: ping-pong the target at a fixed speed. useVisibleLoop pauses it offscreen.\n  // User input holds it still; it resumes after ATTRACT_IDLE_RESUME_MS without input.\n  const attractOn = attract && !reducedMotion;\n  const attractWakeRef = useRef<() => void>(() => {});\n\n  const noteAttractInput = useCallback(() => {\n    if (!attractOn) return;\n    attractUserRef.current = true;\n    window.clearTimeout(attractIdleTimerRef.current);\n    attractIdleTimerRef.current = window.setTimeout(() => {\n      const el = containerRef.current;\n      if (el) {\n        attractStateRef.current = {\n          started: true,\n          pos: el.scrollTop,\n          dir: attractStateRef.current.dir,\n          hold: 0,\n          wrote: el.scrollTop,\n        };\n      }\n      attractUserRef.current = false;\n      attractWakeRef.current();\n    }, ATTRACT_IDLE_RESUME_MS);\n  }, [attractOn]);\n\n  const attractLoop = useVisibleLoop(\n    trackRef,\n    (dt) => {\n      const el = containerRef.current;\n      if (!el || attractUserRef.current) return false;\n      const maxScroll = el.scrollHeight - el.clientHeight;\n      if (maxScroll <= 0) return false;\n      const s = attractStateRef.current;\n      if (!s.started) {\n        s.started = true;\n        s.pos = s.wrote = el.scrollTop;\n      } else if (Math.abs(el.scrollTop - s.wrote) > 1.5) {\n        // Moved by something other than attract (native scrollbar, keys, touch).\n        noteAttractInput();\n        return false;\n      }\n      if (s.hold > 0) {\n        s.hold -= dt;\n      } else {\n        s.pos += s.dir * ATTRACT_SPEED * dt;\n        if (s.pos >= maxScroll) {\n          s.pos = maxScroll;\n          s.dir = -1;\n          s.hold = ATTRACT_PAUSE_MS / 1000;\n        } else if (s.pos <= 0) {\n          s.pos = 0;\n          s.dir = 1;\n          s.hold = ATTRACT_PAUSE_MS / 1000;\n        }\n        s.wrote = s.pos;\n        el.scrollTop = s.pos;\n      }\n      return true;\n    },\n    { enabled: attractOn },\n  );\n\n  useEffect(() => {\n    attractWakeRef.current = attractLoop.wake;\n  }, [attractLoop.wake]);\n\n  useEffect(() => {\n    const t = containerRef.current;\n    if (!attractOn || !t) return;\n    const onUser = () => noteAttractInput();\n    t.addEventListener(\"wheel\", onUser, { passive: true });\n    t.addEventListener(\"touchstart\", onUser, { passive: true });\n    t.addEventListener(\"pointerdown\", onUser);\n    return () => {\n      t.removeEventListener(\"wheel\", onUser);\n      t.removeEventListener(\"touchstart\", onUser);\n      t.removeEventListener(\"pointerdown\", onUser);\n    };\n  }, [attractOn, noteAttractInput]);\n\n  useEffect(() => {\n    return () => {\n      window.clearTimeout(flashTimerRef.current);\n      window.clearTimeout(attractIdleTimerRef.current);\n    };\n  }, []);\n\n  const pointAt = (y: number, withLens: boolean) => {\n    const trackH = trackHRef.current || 1;\n    pillYRef.current = y;\n    lensYRef.current = withLens ? y : null;\n    if (showLabels) {\n      const idx = nearestIndex(headings, clamp01(y / trackH));\n      setLabelIdx(idx);\n      setLabelOpen(idx >= 0);\n    }\n    applyHover();\n  };\n\n  const clearHover = () => {\n    if (debugHoverAt != null) return;\n    lensYRef.current = null;\n    setLabelOpen(false);\n    applyHover();\n  };\n\n  const flashHeading = (mark: Mark) => {\n    const trackH = trackHRef.current || 1;\n    setAnnouncement(`Section: ${mark.label}`);\n    const idx = headings.indexOf(mark);\n    if (idx < 0) return;\n    pillYRef.current = mark.at * trackH;\n    lensYRef.current = null;\n    setLabelIdx(idx);\n    setLabelOpen(true);\n    window.clearTimeout(flashTimerRef.current);\n    flashTimerRef.current = window.setTimeout(() => setLabelOpen(false), LABEL_FLASH_MS);\n    applyHover();\n  };\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const t = containerRef.current;\n    const track = trackRef.current;\n    if (!t || !track || event.button !== 0) return;\n    noteAttractInput();\n    const rect = track.getBoundingClientRect();\n    rectRef.current = rect;\n    const trackH = rect.height || trackHRef.current || 1;\n    const y = clamp(event.clientY - rect.top, 0, trackH);\n    const maxScroll = Math.max(0, t.scrollHeight - t.clientHeight);\n    track.setPointerCapture(event.pointerId);\n\n    if (maxScroll > 1 && windowRef.current?.contains(event.target as Node)) {\n      dragRef.current = {\n        id: event.pointerId,\n        startY: event.clientY,\n        startScroll: t.scrollTop,\n        trackH,\n        scrollH: t.scrollHeight,\n      };\n      pointAt(y, false);\n      return;\n    }\n\n    // Click on the rail: centre the clicked fraction of the document.\n    const fraction = y / trackH;\n    const top = clamp(fraction * t.scrollHeight - t.clientHeight / 2, 0, maxScroll);\n    t.scrollTo({ top, behavior: reducedMotion ? \"auto\" : \"smooth\" });\n\n    const mark = allMarks[nearestIndex(allMarks, fraction)];\n    if (!mark) return;\n    onJump?.({ at: mark.at, label: mark.label, level: mark.level });\n    if (!mark.block) flashHeading(mark);\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const t = containerRef.current;\n    const track = trackRef.current;\n    if (!t || !track) return;\n    // Uses the rect cached on enter or down, so pointer moves do no layout reads.\n    const rect = rectRef.current ?? (rectRef.current = track.getBoundingClientRect());\n    const y = clamp(event.clientY - rect.top, 0, rect.height);\n    const drag = dragRef.current;\n    if (drag && drag.id === event.pointerId) {\n      const maxScroll = Math.max(0, t.scrollHeight - t.clientHeight);\n      t.scrollTop = clamp(\n        drag.startScroll + ((event.clientY - drag.startY) * drag.scrollH) / drag.trackH,\n        0,\n        maxScroll,\n      );\n      pointAt(y, false);\n      return;\n    }\n    if (event.pointerType === \"touch\" || drag) return;\n    pointAt(y, lensOn);\n  };\n\n  const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (drag && drag.id === event.pointerId) dragRef.current = null;\n    if (event.pointerType !== \"mouse\") clearHover();\n  };\n\n  const onPointerEnter = (event: ReactPointerEvent<HTMLDivElement>) => {\n    rectRef.current = event.currentTarget.getBoundingClientRect();\n  };\n\n  const onPointerLeave = () => {\n    if (dragRef.current) return;\n    rectRef.current = null;\n    clearHover();\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const t = containerRef.current;\n    if (!t) return;\n    noteAttractInput();\n    const scrollH = Math.max(t.scrollHeight, 1);\n    const maxScroll = Math.max(0, t.scrollHeight - t.clientHeight);\n    const current = t.scrollTop;\n\n    if (event.key === \"[\" || event.key === \"]\") {\n      event.preventDefault();\n      const position = current / scrollH;\n      const eps = 0.5 / scrollH;\n      const next =\n        event.key === \"]\"\n          ? headings.find((h) => h.at > position + eps)\n          : [...headings].reverse().find((h) => h.at < position - eps);\n      if (!next) return;\n      t.scrollTop = clamp(next.at * scrollH, 0, maxScroll);\n      flashHeading(next);\n      return;\n    }\n\n    let to: number;\n    switch (event.key) {\n      case \"ArrowUp\":\n        to = current - 0.05 * scrollH;\n        break;\n      case \"ArrowDown\":\n        to = current + 0.05 * scrollH;\n        break;\n      case \"PageUp\":\n        to = current - t.clientHeight;\n        break;\n      case \"PageDown\":\n        to = current + t.clientHeight;\n        break;\n      case \"Home\":\n        to = 0;\n        break;\n      case \"End\":\n        to = maxScroll;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    // Keyboard repeats stay instant: no smooth scroll.\n    t.scrollTop = clamp(to, 0, maxScroll);\n  };\n\n  const edge: CSSProperties =\n    side === \"right\" ? { right: 0, transformOrigin: \"left center\" } : { left: 0, transformOrigin: \"right center\" };\n  const pillEdge: CSSProperties =\n    side === \"right\" ? { right: \"calc(100% + 8px)\" } : { left: \"calc(100% + 8px)\" };\n\n  return (\n    <>\n      <div\n        ref={trackRef}\n        role=\"scrollbar\"\n        tabIndex={0}\n        aria-controls={`${contentIdBase}-content`}\n        aria-label={ariaLabel}\n        aria-orientation=\"vertical\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={0}\n        onPointerDown={onPointerDown}\n        onPointerEnter={onPointerEnter}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerEnd}\n        onPointerCancel={onPointerEnd}\n        onPointerLeave={onPointerLeave}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"absolute inset-y-3 z-10 cursor-pointer touch-none select-none rounded-[3px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bjork-ring-offset)]\",\n          side === \"right\" ? \"right-2\" : \"left-2\",\n          className,\n        )}\n        style={{ width: trackWidth, color: palette.accent }}\n      >\n        <div\n          ref={windowRef}\n          aria-hidden\n          className=\"absolute inset-x-0 top-0 cursor-grab rounded-[3px] border active:cursor-grabbing\"\n          style={{ borderColor: palette.hair, backgroundColor: WINDOW_FILL[resolvedTone], height: \"100%\" }}\n        />\n\n        {allMarks.map((mark, index) => {\n          const size = TICK_SIZE[mark.level];\n          return (\n            <div\n              key={index}\n              ref={(el) => {\n                tickRefs.current[index] = el;\n              }}\n              aria-hidden\n              className=\"pointer-events-none absolute -translate-y-1/2 transition-transform duration-[120ms] ease-out motion-reduce:transition-none\"\n              style={{\n                ...edge,\n                top: `${mark.at * 100}%`,\n                width: size.w,\n                height: size.h,\n                backgroundColor: tickColor(palette, mark.level),\n              }}\n            />\n          );\n        })}\n\n        <div\n          ref={lineRef}\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-0 top-0 h-[2px] -translate-y-1/2\"\n          style={{ backgroundColor: palette.accent }}\n        />\n\n        {pillMark ? (\n          <div\n            ref={pillRef}\n            aria-hidden\n            className=\"pointer-events-none absolute top-0 z-20 flex max-w-[240px] -translate-y-1/2 items-center whitespace-nowrap rounded-[8px] border px-2.5 py-1.5 text-[12px] font-medium leading-none\"\n            style={{\n              ...pillEdge,\n              backgroundColor: menu.background,\n              borderColor: palette.border,\n              boxShadow: menu.shadow,\n              color: palette.text,\n              opacity: shownOpen ? 1 : 0,\n              transition: shownOpen ? \"none\" : `opacity ${LABEL_FADE_MS}ms ease-out`,\n            }}\n          >\n            {pillMark.num ? (\n              <span className=\"mr-2 font-mono text-[10px] tabular-nums\" style={{ color: palette.textFaint }}>\n                {pillMark.num}\n              </span>\n            ) : null}\n            <span className=\"min-w-0 truncate\">{pillMark.label}</span>\n          </div>\n        ) : null}\n      </div>\n      <LiveRegion message={announcement} />\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/navigation/minimap-scrollbar.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/a11y.tsx",
      "content": "import type { ReactNode } from \"react\";\n\n// Text for assistive tech only.\nexport function VisuallyHidden({ children }: { children: ReactNode }) {\n  return <span className=\"sr-only\">{children}</span>;\n}\n\n// Announces `message` when it changes. Same text re-rendered does not re-announce.\nexport function LiveRegion({\n  message,\n  politeness = \"polite\",\n}: {\n  message: string;\n  politeness?: \"polite\" | \"assertive\";\n}) {\n  return (\n    <span role=\"status\" aria-live={politeness} aria-atomic=\"true\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/a11y.tsx"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Navigation",
    "registryUrl": "https://ui.isaiahbjork.com/minimap-scrollbar.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/minimap-scrollbar.json"
  },
  "type": "registry:component"
}