{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"timeline","type":"registry:component","title":"Timeline","description":"A draggable timeline of dated events around a focused card.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/timeline.tsx","type":"registry:component","target":"@components/motion/timeline.tsx","content":"\"use client\";\n// beui.dev/components/motion/timeline\n\nimport {\n  animate,\n  type AnimationPlaybackControls,\n  motion,\n  type MotionValue,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Timeline-specific: a release spring that takes the flick velocity, so the\n// track coasts on past the finger and settles onto an event without a step.\nconst GLIDE_SPRING = { stiffness: 120, damping: 24, mass: 1 };\n\n// Projected events per unit of release velocity (events/s), capped.\nconst FLICK_MOMENTUM = 0.3;\nconst MAX_FLICK_EVENTS = 4;\n\n// Pointer travel before a press becomes a drag. Below it the press is a click.\nconst DRAG_THRESHOLD = 4;\n\n// Past either end the track follows the pointer at this fraction.\nconst RUBBER_BAND = 0.3;\n\n// Neighbours rest at this scale, veiled by SIDE_DIM; the focused event sits\n// at full strength. The card is dimmed by a veil in the stage colour rather\n// than by opacity, so it stays solid and nothing behind shows through it.\nconst SIDE_SCALE = 0.72;\nconst SIDE_DIM = 0.45;\n\n// Year numerals drift at this fraction of the track speed, so they read as a\n// far layer behind the cards.\nconst YEAR_PARALLAX = 0.5;\n\n// Gap above the year numerals, as a fraction of their font size.\nconst YEAR_INSET = 0.33;\n\n// Cards sit on the stage middle, between two equal bands: the top one holds\n// the year numerals, and each holds the apex one fan of cables leaves from.\nconst CARD_CENTER = 0.5;\n\n// Each gap between cards holds a group of ticks: GAP_TICKS lines spread over\n// GAP_TICK_SPAN of the gap, the middle one taller and brighter. Heights are\n// fractions of the card width so the group scales with the cards.\nconst GAP_TICKS = 5;\nconst GAP_TICK_SPAN = 0.7;\nconst TICK_SHORT = 0.13;\nconst TICK_TALL = 0.2;\n\nexport type TimelineEvent = {\n  /** Stable identity, used for `value` and React keys. */\n  id: string;\n  /** ISO date string or Date. Read in UTC. */\n  date: string | Date;\n  title: string;\n  /** Visual shown inside the card. Keep it non-interactive: the card itself\n   * is the button. */\n  media?: ReactNode;\n  /** Footer line under the media: a stat, a tag, a reaction count. */\n  meta?: ReactNode;\n};\n\nexport interface TimelineProps {\n  events: TimelineEvent[];\n  /** Focused event id (controlled). */\n  value?: string;\n  /** Initially focused event id (uncontrolled). Defaults to the first event. */\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  /** Card width in px at full size. Shrinks to fit narrow containers. */\n  itemWidth?: number;\n  /** Stage height in px. */\n  height?: number;\n  /** Large year numerals drifting behind the cards. */\n  years?: boolean;\n  /** A group of ticks in each gap between cards. */\n  ruler?: boolean;\n  /** Lines fanning from the top and bottom center to the cards near the center. */\n  cables?: boolean;\n  formatDate?: (date: Date) => string;\n  formatYear?: (date: Date) => string;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\nconst MONTH = new Intl.DateTimeFormat(\"en-US\", { month: \"short\", timeZone: \"UTC\" });\n\nfunction toDate(date: string | Date) {\n  return typeof date === \"string\" ? new Date(date) : date;\n}\n\nfunction defaultFormatYear(date: Date) {\n  return `’${String(date.getUTCFullYear()).slice(-2)}`;\n}\n\nfunction defaultFormatDate(date: Date) {\n  return `${MONTH.format(date)} ${date.getUTCDate()} ${defaultFormatYear(date)}`;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\n/** Card center relative to the stage center. Over the first step a card\n * travels `near`, which leaves the same gap beside the bigger focused card\n * as between the smaller cards further out, one `step` apart. */\nfunction cardX(o: number, { step, near }: Geometry) {\n  const a = Math.abs(o);\n  return Math.sign(o) * (a <= 1 ? a * near : near + (a - 1) * step);\n}\n\nfunction cardScale(o: number) {\n  return 1 - (1 - SIDE_SCALE) * Math.min(Math.abs(o), 1);\n}\n\ntype Geometry = {\n  /** Track px per event. */\n  step: number;\n  /** Card width at full size. */\n  cardWidth: number;\n  /** Distance from the focused card's center to a neighbour's. */\n  near: number;\n  halfWidth: number;\n};\n\nfunction TimelineCard({\n  scroll,\n  index,\n  geometry,\n  event,\n  dateLabel,\n  active,\n  onSelect,\n  onKeyDown,\n  buttonRef,\n}: {\n  scroll: MotionValue<number>;\n  index: number;\n  geometry: Geometry;\n  event: TimelineEvent;\n  dateLabel: string;\n  active: boolean;\n  onSelect: (index: number) => void;\n  onKeyDown: (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => void;\n  buttonRef: (node: HTMLButtonElement | null) => void;\n}) {\n  const id = useId();\n  const { cardWidth } = geometry;\n  const offset = useTransform(scroll, (s) => index - s);\n  // Off-stage cards stay visible to the browser (the stage clips them), so\n  // Home and End can focus a card that is still far away.\n  const x = useTransform(offset, (o) => cardX(o, geometry));\n  const scale = useTransform(offset, cardScale);\n  // Nearer the center paints on top, so a card arriving at the center slides\n  // over the one leaving mid-drag.\n  const zIndex = useTransform(offset, (o) => 100 - Math.round(Math.abs(o) * 10));\n  const veilOpacity = useTransform(\n    offset,\n    (o) => SIDE_DIM * Math.min(Math.abs(o), 1),\n  );\n\n  return (\n    <motion.li\n      data-slot=\"timeline-event\"\n      data-active={active || undefined}\n      className=\"absolute left-1/2\"\n      style={{\n        top: `${CARD_CENTER * 100}%`,\n        x,\n        scale,\n        zIndex,\n        width: cardWidth,\n        marginLeft: -cardWidth / 2,\n      }}\n    >\n      {/* One solid surface holds the label, media and meta, so the cables\n          and ruler stop at its edge instead of showing through any part. */}\n      <div className=\"relative -translate-y-1/2\">\n        <div className=\"relative overflow-hidden rounded-3xl border border-border bg-card\">\n          <div className=\"px-3.5 pt-3 pb-2\">\n            <p\n              id={`${id}-date`}\n              className=\"text-muted-foreground text-xs tabular-nums\"\n            >\n              {dateLabel}\n            </p>\n            <h3\n              id={`${id}-title`}\n              className=\"truncate font-medium text-base text-foreground tracking-tight\"\n            >\n              {event.title}\n            </h3>\n          </div>\n          <div className=\"px-2\">\n            <div className=\"aspect-[16/10] overflow-hidden rounded-2xl border border-border bg-background\">\n              {event.media}\n            </div>\n          </div>\n          {event.meta ? (\n            <div className=\"flex items-center gap-1.5 px-3.5 py-2.5 text-muted-foreground text-sm tabular-nums\">\n              {event.meta}\n            </div>\n          ) : (\n            <div className=\"h-2\" />\n          )}\n          <motion.div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 bg-background\"\n            style={{ opacity: veilOpacity }}\n          />\n        </div>\n        {/* Stretched over the whole card so the media stays valid content\n            outside the button and remains readable on its own. */}\n        <button\n          ref={buttonRef}\n          type=\"button\"\n          tabIndex={active ? 0 : -1}\n          aria-current={active ? \"step\" : undefined}\n          aria-labelledby={`${id}-date ${id}-title`}\n          onClick={() => onSelect(index)}\n          onKeyDown={(e) => onKeyDown(e, index)}\n          className=\"absolute inset-0 cursor-pointer rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-foreground\"\n        />\n      </div>\n    </motion.li>\n  );\n}\n\n// Cables reach this far across a card's half-width, so they meet its top edge\n// just inside the corners.\nconst CABLE_REACH = 0.8;\n// Only the centered card and its two neighbours carry cables: full strength\n// out to CABLE_HOLD events from the center, gone by CABLE_FADE, so the fan\n// stays at six lines however many events there are.\nconst CABLE_HOLD = 1;\nconst CABLE_FADE = 1.6;\n\n/**\n * Lines from the apexes at the top and bottom center to one card's near\n * corners. Each ends at the card's vertical center, behind the opaque\n * surface, so the visible part stops exactly where it meets the card's top or\n * bottom edge and reads as attached there, with no measuring of the card.\n */\nfunction Cables({\n  scroll,\n  index,\n  geometry,\n  height,\n}: {\n  scroll: MotionValue<number>;\n  index: number;\n  geometry: Geometry;\n  height: number;\n}) {\n  const { halfWidth, cardWidth } = geometry;\n  const offset = useTransform(scroll, (s) => index - s);\n  const reach = (o: number) => (cardWidth / 2) * cardScale(o) * CABLE_REACH;\n  const left = useTransform(\n    offset,\n    (o) => halfWidth + cardX(o, geometry) - reach(o),\n  );\n  const right = useTransform(\n    offset,\n    (o) => halfWidth + cardX(o, geometry) + reach(o),\n  );\n  const opacity = useTransform(offset, (o) =>\n    clamp((CABLE_FADE - Math.abs(o)) / (CABLE_FADE - CABLE_HOLD), 0, 1),\n  );\n  const y = height * CARD_CENTER;\n  return (\n    <motion.g style={{ opacity }}>\n      <motion.line x1={halfWidth} y1={0} x2={left} y2={y} />\n      <motion.line x1={halfWidth} y1={0} x2={right} y2={y} />\n      <motion.line x1={halfWidth} y1={height} x2={left} y2={y} />\n      <motion.line x1={halfWidth} y1={height} x2={right} y2={y} />\n    </motion.g>\n  );\n}\n\nfunction YearMark({\n  scroll,\n  position,\n  step,\n  label,\n  fontSize,\n}: {\n  scroll: MotionValue<number>;\n  /** Track position (in events) the numeral centers on. */\n  position: number;\n  step: number;\n  label: string;\n  fontSize: number;\n}) {\n  const x = useTransform(scroll, (s) => (position - s) * step * YEAR_PARALLAX);\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      className=\"absolute left-1/2\"\n      // Inset from the top so the numeral sits under the cables' apex, with\n      // air above it.\n      style={{ x, top: fontSize * YEAR_INSET }}\n    >\n      <span\n        className=\"block -translate-x-1/2 font-medium text-foreground/[0.06] leading-none tracking-tighter tabular-nums\"\n        style={{ fontSize }}\n      >\n        {label}\n      </span>\n    </motion.div>\n  );\n}\n\n/**\n * The tick group in the gap after card `index`. It sits midway between that\n * card's right edge and the next card's left edge; the gap keeps one width\n * while the cards move (see `cardX`), so the group only translates.\n */\nfunction GapTicks({\n  scroll,\n  index,\n  geometry,\n  gap,\n}: {\n  scroll: MotionValue<number>;\n  index: number;\n  geometry: Geometry;\n  gap: number;\n}) {\n  const { cardWidth } = geometry;\n  const x = useTransform(scroll, (s) => {\n    const o = index - s;\n    const right = cardX(o, geometry) + (cardWidth * cardScale(o)) / 2;\n    const left = cardX(o + 1, geometry) - (cardWidth * cardScale(o + 1)) / 2;\n    return (right + left) / 2;\n  });\n  const spacing = (gap * GAP_TICK_SPAN) / (GAP_TICKS - 1);\n  const middle = (GAP_TICKS - 1) / 2;\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      className=\"absolute left-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center\"\n      style={{ x, top: `${CARD_CENTER * 100}%`, gap: spacing - 1 }}\n    >\n      {Array.from({ length: GAP_TICKS }, (_, i) => (\n        <span\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed decorative ticks\n          key={i}\n          className={cn(\n            \"block w-px rounded-full\",\n            i === middle ? \"bg-foreground/45\" : \"bg-foreground/25\",\n          )}\n          style={{\n            height: cardWidth * (i === middle ? TICK_TALL : TICK_SHORT),\n          }}\n        />\n      ))}\n    </motion.div>\n  );\n}\n\nexport function Timeline({\n  events,\n  value,\n  defaultValue,\n  onValueChange,\n  itemWidth = 340,\n  height = 600,\n  years = true,\n  ruler = true,\n  cables = true,\n  formatDate = defaultFormatDate,\n  formatYear = defaultFormatYear,\n  \"aria-label\": ariaLabel = \"Timeline\",\n  className,\n}: TimelineProps) {\n  const reduce = useReducedMotion() ?? false;\n  const hintId = useId();\n  const count = events.length;\n  const last = Math.max(0, count - 1);\n\n  const [innerValue, setInnerValue] = useState(defaultValue ?? events[0]?.id);\n  const activeId = value ?? innerValue;\n  // Resolved during render: an unknown or removed id falls back to the first\n  // event, so there is always exactly one tab stop.\n  const found = events.findIndex((e) => e.id === activeId);\n  const activeIndex = found === -1 ? 0 : found;\n\n  const stageRef = useRef<HTMLElement>(null);\n  const [width, setWidth] = useState(0);\n  // Layout effect with an immediate read, so the first paint already uses the\n  // real width instead of the fallback.\n  useLayoutEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n    setWidth(el.clientWidth);\n    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const stageWidth = width || 800;\n  // About a third of the stage at most, so each neighbour shows in full or\n  // nearly beside the focused card; phones give the card more of the width\n  // and keep a sliver of each neighbour.\n  const cardWidth = Math.min(itemWidth, stageWidth * (stageWidth < 640 ? 0.58 : 0.36));\n  // One gap everywhere: between the focused card and its neighbours, and\n  // between every pair of smaller cards further out.\n  // Wide enough to hold the tick group between cards.\n  const gap = clamp(cardWidth * 0.22, 40, 72);\n  const geometry: Geometry = {\n    step: cardWidth * SIDE_SCALE + gap,\n    cardWidth,\n    near: (cardWidth * (1 + SIDE_SCALE)) / 2 + gap,\n    halfWidth: stageWidth / 2,\n  };\n  const { step } = geometry;\n\n  // Track position in events (continuous); event i is centered at scroll = i.\n  const scroll = useMotionValue(activeIndex);\n  const glideRef = useRef<AnimationPlaybackControls | null>(null);\n  const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);\n\n  // Latest values for stable callbacks, written after commit.\n  const latest = useRef({ events, activeIndex, onValueChange, step, last });\n  useLayoutEffect(() => {\n    latest.current = { events, activeIndex, onValueChange, step, last };\n  });\n\n  const commit = useCallback((index: number) => {\n    const { events, activeIndex, onValueChange } = latest.current;\n    const event = events[index];\n    if (!event || index === activeIndex) return;\n    latest.current.activeIndex = index;\n    setInnerValue(event.id);\n    onValueChange?.(event.id);\n  }, []);\n\n  // While a glide runs the target is already committed, so the track passing\n  // over the events in between must not hand focus back to them.\n  const glidingRef = useRef(false);\n\n  // Under a drag or wheel, focus follows the event nearest the center.\n  useEffect(\n    () =>\n      scroll.on(\"change\", (v) => {\n        if (glidingRef.current) return;\n        commit(clamp(Math.round(v), 0, latest.current.last));\n      }),\n    [scroll, commit],\n  );\n\n  const stopGlide = useCallback(() => {\n    glidingRef.current = false;\n    glideRef.current?.stop();\n    glideRef.current = null;\n  }, []);\n\n  const glideTo = useCallback(\n    (to: number, velocity = scroll.getVelocity()) => {\n      stopGlide();\n      commit(to);\n      if (reduce) {\n        scroll.set(to);\n        return;\n      }\n      glidingRef.current = true;\n      glideRef.current = animate(scroll, to, {\n        type: \"spring\",\n        ...GLIDE_SPRING,\n        velocity,\n        restDelta: 0.001,\n        onComplete: () => {\n          glidingRef.current = false;\n          glideRef.current = null;\n        },\n      });\n    },\n    [scroll, stopGlide, commit, reduce],\n  );\n\n  const settle = useCallback(\n    (velocity: number) => {\n      const projected =\n        scroll.get() +\n        clamp(velocity * FLICK_MOMENTUM, -MAX_FLICK_EVENTS, MAX_FLICK_EVENTS);\n      glideTo(clamp(Math.round(projected), 0, latest.current.last), velocity);\n    },\n    [scroll, glideTo],\n  );\n\n  // Follow a value changed from outside (controlled), or a list that shrank.\n  const draggingRef = useRef(false);\n  useEffect(() => {\n    if (draggingRef.current || glidingRef.current) return;\n    if (clamp(Math.round(scroll.get()), 0, last) === activeIndex) return;\n    glideTo(activeIndex);\n  }, [activeIndex, last, scroll, glideTo]);\n\n  const select = useCallback(\n    (index: number, focus = false) => {\n      const target = clamp(index, 0, latest.current.last);\n      glideTo(target);\n      if (focus) buttonsRef.current[target]?.focus({ preventScroll: true });\n    },\n    [glideTo],\n  );\n\n  const onKeyDown = useCallback(\n    (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {\n      const next =\n        e.key === \"ArrowRight\"\n          ? index + 1\n          : e.key === \"ArrowLeft\"\n            ? index - 1\n            : e.key === \"Home\"\n              ? 0\n              : e.key === \"End\"\n                ? latest.current.last\n                : null;\n      if (next === null) return;\n      e.preventDefault();\n      select(next, true);\n    },\n    [select],\n  );\n\n  const drag = useRef({\n    id: -1,\n    startX: 0,\n    startScroll: 0,\n    moved: false,\n    lastX: 0,\n    lastT: 0,\n    prevX: 0,\n    prevT: 0,\n  });\n\n  const onPointerDown = (e: ReactPointerEvent) => {\n    if (e.button !== 0) return;\n    stopGlide();\n    const now = performance.now();\n    drag.current = {\n      id: e.pointerId,\n      startX: e.clientX,\n      startScroll: scroll.get(),\n      moved: false,\n      lastX: e.clientX,\n      lastT: now,\n      prevX: e.clientX,\n      prevT: now,\n    };\n    draggingRef.current = true;\n  };\n\n  const onPointerMove = (e: ReactPointerEvent) => {\n    const d = drag.current;\n    if (!draggingRef.current || e.pointerId !== d.id) return;\n    const dx = e.clientX - d.startX;\n    if (!d.moved) {\n      if (Math.abs(dx) < DRAG_THRESHOLD) return;\n      // Capture only once it is a drag, so a plain press still clicks the\n      // card under it; after a drag the click lands on the stage instead.\n      d.moved = true;\n      capturePointer(e.currentTarget, e.pointerId);\n    }\n    const raw = d.startScroll - dx / step;\n    scroll.set(\n      raw < 0\n        ? raw * RUBBER_BAND\n        : raw > last\n          ? last + (raw - last) * RUBBER_BAND\n          : raw,\n    );\n    d.prevX = d.lastX;\n    d.prevT = d.lastT;\n    d.lastX = e.clientX;\n    d.lastT = performance.now();\n  };\n\n  const onPointerUp = (e: ReactPointerEvent) => {\n    const d = drag.current;\n    if (!draggingRef.current || e.pointerId !== d.id) return;\n    draggingRef.current = false;\n    if (!d.moved) return;\n    releasePointer(e.currentTarget, e.pointerId);\n    const dt = d.lastT - d.prevT;\n    const vpx = dt > 0 ? (d.lastX - d.prevX) / dt : 0; // px per ms\n    settle((-vpx * 1000) / step); // events per second\n  };\n\n  // Horizontal wheel and trackpad swipes move the track; vertical wheel is\n  // left to the page. Native listener: React's wheel handler is passive and\n  // could not stop the browser's back-swipe.\n  useEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n    let settleTimer: number | undefined;\n    const onWheel = (e: WheelEvent) => {\n      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;\n      e.preventDefault();\n      stopGlide();\n      const { step, last } = latest.current;\n      scroll.set(clamp(scroll.get() + e.deltaX / step, -0.3, last + 0.3));\n      window.clearTimeout(settleTimer);\n      settleTimer = window.setTimeout(\n        () =>\n          glideTo(clamp(Math.round(scroll.get()), 0, latest.current.last), 0),\n        140,\n      );\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel);\n      window.clearTimeout(settleTimer);\n    };\n  }, [scroll, stopGlide, glideTo]);\n\n  useEffect(() => stopGlide, [stopGlide]);\n\n  const dates = useMemo(() => events.map((e) => toDate(e.date)), [events]);\n\n  // One numeral per year, centered on the boundary where that year begins.\n  const yearMarks = useMemo(() => {\n    const marks: { key: string; label: string; position: number }[] = [];\n    let previous: number | null = null;\n    dates.forEach((date, i) => {\n      const year = date.getUTCFullYear();\n      if (year === previous) return;\n      previous = year;\n      marks.push({\n        key: String(year),\n        label: formatYear(date),\n        position: i === 0 ? 0 : i - 0.5,\n      });\n    });\n    return marks;\n  }, [dates, formatYear]);\n\n  return (\n    <section\n      ref={stageRef}\n      aria-label={ariaLabel}\n      aria-describedby={hintId}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={onPointerUp}\n      className={cn(\n        // clip-path rather than overflow: it also clips composited layers.\n        // The whole stage fades at its edges together, so a card never turns\n        // see-through over the cables behind it.\n        \"[mask-image:linear-gradient(to_right,transparent,black_6%,black_94%,transparent)]\",\n        \"relative w-full cursor-grab touch-pan-y overscroll-x-contain [clip-path:inset(0)] active:cursor-grabbing\",\n        TOUCH_GESTURE_CLASS,\n        className,\n      )}\n      style={{ height }}\n    >\n      <p id={hintId} className=\"sr-only\">\n        Use the left and right arrow keys to move between events.\n      </p>\n      {years\n        ? yearMarks.map((mark) => (\n            <YearMark\n              key={mark.key}\n              scroll={scroll}\n              position={mark.position}\n              step={step}\n              label={mark.label}\n              fontSize={height * 0.2}\n            />\n          ))\n        : null}\n      {cables ? (\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 size-full stroke-foreground/[0.14] [mask-image:linear-gradient(to_bottom,transparent,black_6%,black_94%,transparent)]\"\n          fill=\"none\"\n          strokeWidth={1}\n        >\n          {events.map((event, i) => (\n            <Cables\n              key={event.id}\n              scroll={scroll}\n              index={i}\n              geometry={geometry}\n              height={height}\n            />\n          ))}\n        </svg>\n      ) : null}\n      {/* The playhead: drawn over the cables so it reads as the line they hang\n          from, bright from end to end and fading only at the stage edges. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 text-foreground/80\"\n        style={{\n          backgroundImage:\n            \"linear-gradient(to bottom, transparent, currentColor 6%, currentColor 94%, transparent)\",\n        }}\n      />\n      {ruler\n        ? events.slice(0, -1).map((event, i) => (\n            <GapTicks\n              key={event.id}\n              scroll={scroll}\n              index={i}\n              geometry={geometry}\n              gap={gap}\n            />\n          ))\n        : null}\n      <ul className=\"absolute inset-0\">\n        {events.map((event, i) => (\n          <TimelineCard\n            key={event.id}\n            scroll={scroll}\n            index={i}\n            geometry={geometry}\n            event={event}\n            dateLabel={formatDate(dates[i])}\n            active={i === activeIndex}\n            onSelect={select}\n            onKeyDown={onKeyDown}\n            buttonRef={(node) => {\n              buttonsRef.current[i] = node;\n            }}\n          />\n        ))}\n      </ul>\n    </section>\n  );\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@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"}]}