{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"changelog-scrubber","type":"registry:component","title":"Changelog scrubber","description":"Your release history on a ruler you scrub: drag the playhead, or press play and watch the product ship.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/changelog-scrubber.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CHANGELOG SCRUBBER: release history on a ruler you scrub\n *\n *   scrub     drag the playhead along a ruler of days; the ticks\n *             swell under it like a lens and the card changes to\n *             whichever release is nearest\n *   settle    let go, click, tap or press an arrow key and the\n *             playhead is thrown onto a release\n *   change    the version riding the playhead, the date and the\n *             tag morph, the count rolls, and the release slides\n *             in from the side the playhead moved toward while\n *             the card eases to its new height\n *   step      the arrows beside the count step one release; on\n *             a phone, swipe the card: the release follows your\n *             finger and springs back at either end\n *   play      plays the history from the first release to the\n *             latest, at the pace it actually shipped; pause\n *             holds it wherever it is\n *\n * The ruler is a slider: arrows step between releases, Home and\n * End jump to the first and the latest. Nothing listens to the\n * page's scroll; frames run only while the playhead moves.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ChangelogEntry {\n  id: string;\n  date: string | Date;\n  title: string;\n  version?: string;\n  /** e.g. [\"new\"], [\"improved\", \"fixed\"]. */\n  tags?: string[];\n  body?: React.ReactNode;\n  /** An image or video under the text. */\n  media?: React.ReactNode;\n}\n\nexport interface ChangelogScrubberProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Newest first, as in a timeline; the ruler runs from the oldest on the left to the newest on the right. */\n  entries: ChangelogEntry[];\n  /** The release on show, by id. Starts on the newest. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  /** How long play takes from the first release to the latest, in ms. Defaults to 1.2s a release. */\n  playDuration?: number;\n  locales?: string | string[];\n}\n\ntype Leaving = { index: number; dir: number; key: number; from: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst MORPH = 460;\nconst DAY = 86_400_000;\nconst LENS = 7; // how far the lens reaches either side of the playhead, in % of the ruler\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst TAG_LABEL: Record<string, string> = { new: \"New\", improved: \"Improved\", fixed: \"Fixed\" };\nconst TAG_TONE: Record<string, string> = {\n  new: \"bg-primary/10 text-primary\",\n  improved: \"bg-muted text-foreground/80\",\n  fixed: \"bg-muted text-muted-foreground\",\n};\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** A CSS cubic-bezier solved in JS, so the playhead's tweens run on the same curves as everything else. */\nfunction bezier(x1: number, y1: number, x2: number, y2: number) {\n  const curve = (t: number, p1: number, p2: number) => 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t;\n  const slope = (t: number, p1: number, p2: number) => 3 * (1 - t) * (1 - t) * p1 + 6 * (1 - t) * t * (p2 - p1) + 3 * t * t * (1 - p2);\n  return (x: number) => {\n    if (x <= 0) return 0;\n    if (x >= 1) return 1;\n    let t = x;\n    for (let i = 0; i < 8; i++) {\n      const s = slope(t, x1, x2);\n      if (Math.abs(s) < 1e-6) break;\n      t = Math.min(1, Math.max(0, t - (curve(t, x1, x2) - x) / s));\n    }\n    return curve(t, y1, y2);\n  };\n}\nconst THROWN = bezier(0.34, 1.36, 0.64, 1);\nconst LINEAR = (t: number) => t;\n\n/** A tick at p% that swells as the playhead (--x, set on the ruler) comes near. Pure CSS, so moving the playhead is one style write. */\nconst lens = (p: number, gain: number) =>\n  ({\n    left: `${p}%`,\n    \"--p\": String(p),\n    \"--f\": `max(0, 1 - max(var(--x) - var(--p), var(--p) - var(--x)) / ${LENS})`,\n    transform: `scaleY(calc(1 + ${gain} * var(--f) * var(--f)))`,\n  }) as React.CSSProperties;\n\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"scale(1)\" : \"scale(0.6)\",\n  filter: on ? \"blur(0px)\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 360ms ${EASE}, filter 240ms ${EASE}`,\n});\n\n/** One release in the card; it comes in from the side the playhead moved toward and leaves the other way (from where a swipe let it go). */\nfunction Pane({\n  children,\n  dir,\n  leaving,\n  reduced,\n  from = 0,\n  paneRef,\n}: {\n  children: React.ReactNode;\n  dir: number;\n  leaving: boolean;\n  reduced: boolean;\n  from?: number;\n  paneRef?: React.Ref<HTMLDivElement>;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || reduced || dir === 0) return;\n    const shift = 24 * dir;\n    const animation = leaving\n      ? el.animate(\n          [\n            { opacity: 1, filter: \"blur(0px)\", transform: `translateX(${from}px)` },\n            { opacity: 0, filter: \"blur(6px)\", transform: `translateX(${from - shift}px)` },\n          ],\n          { duration: MORPH * 0.6, easing: EASE, fill: \"forwards\" }\n        )\n      : el.animate(\n          [\n            { opacity: 0, filter: \"blur(6px)\", transform: `translateX(${shift}px)` },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          { duration: MORPH, easing: EASE, delay: 60, fill: \"backwards\" }\n        );\n    return () => animation.cancel();\n  }, [dir, leaving, reduced, from]);\n  return (\n    <div\n      ref={(el) => {\n        ref.current = el;\n        if (typeof paneRef === \"function\") paneRef(el);\n        else if (paneRef) (paneRef as React.MutableRefObject<HTMLDivElement | null>).current = el;\n      }}\n      inert={leaving}\n      aria-hidden={leaving || undefined}\n      className={leaving ? \"absolute inset-x-0 top-0\" : \"relative\"}\n    >\n      {children}\n    </div>\n  );\n}\n\n/** Older / newer. At either end it fades and does nothing, but stays focusable so focus never drops. */\nfunction Step({ dir, disabled, onStep }: { dir: -1 | 1; disabled: boolean; onStep: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={dir < 0 ? \"Older release\" : \"Newer release\"}\n      aria-disabled={disabled || undefined}\n      onClick={() => !disabled && onStep()}\n      className={`flex size-7 items-center justify-center rounded-full text-foreground transition-[background-color,opacity] duration-300 ${FOCUS} ${disabled ? \"cursor-default opacity-30\" : \"hover:bg-accent\"}`}\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" className=\"size-3.5\">\n        <path d={dir < 0 ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} />\n      </svg>\n    </button>\n  );\n}\n\nexport function ChangelogScrubber({ entries, value, defaultValue, onValueChange, playDuration, locales, className = \"\", ...props }: ChangelogScrubberProps) {\n  const reduced = useReducedMotion();\n\n  // The ruler: releases oldest to newest, a little room either side, a tick a day (a week, then four, on long histories) and the months.\n  const model = React.useMemo(() => {\n    const releases = entries.map((entry) => ({ entry, t: new Date(entry.date).getTime() })).sort((a, b) => a.t - b.t);\n    const first = releases[0]?.t ?? 0;\n    const last = releases[releases.length - 1]?.t ?? first;\n    const span = Math.max(14 * DAY, last - first);\n    const pad = Math.max(3 * DAY, span * 0.08);\n    const start = first - pad;\n    const end = last + pad;\n    const pos = (t: number) => ((t - start) / (end - start)) * 100;\n    const days = (end - start) / DAY;\n    const every = days <= 120 ? 1 : days <= 840 ? 7 : 28;\n    const ticks: number[] = [];\n    const d = new Date(start);\n    d.setHours(0, 0, 0, 0);\n    if (d.getTime() < start) d.setDate(d.getDate() + 1);\n    while (every > 1 && d.getDay() !== 1) d.setDate(d.getDate() + 1);\n    for (; d.getTime() <= end; d.setDate(d.getDate() + every)) ticks.push(pos(d.getTime()));\n    const label = (m: Date) => m.toLocaleDateString(locales ?? \"en-GB\", m.getMonth() === 0 ? { month: \"short\", year: \"numeric\" } : { month: \"short\" });\n    const months: { p: number; label: string; edge?: boolean }[] = [];\n    const m = new Date(start);\n    m.setHours(0, 0, 0, 0);\n    m.setDate(1);\n    const opening = label(m);\n    for (m.setMonth(m.getMonth() + 1); m.getTime() <= end; m.setMonth(m.getMonth() + 1)) months.push({ p: pos(m.getTime()), label: label(m) });\n    // The month the ruler opens in gets a label too, when there's room before the next.\n    if (!months.length || months[0].p > 14) months.unshift({ p: 0, label: opening, edge: true });\n    const keep = Math.ceil(months.length / 8);\n    return { releases, at: releases.map((r) => pos(r.t)), ticks, months: months.filter((_, i) => i % keep === 0) };\n  }, [entries, locales]);\n\n  const n = model.releases.length;\n  const [own, setOwn] = React.useState(defaultValue ?? model.releases[n - 1]?.entry.id);\n  const active = value ?? own;\n  const found = model.releases.findIndex((r) => r.entry.id === active);\n  const index = found === -1 ? n - 1 : found;\n  const current = model.releases[index]?.entry;\n\n  const [playing, setPlaying] = React.useState(false);\n  const [dragging, setDragging] = React.useState(false);\n  const [height, setHeight] = React.useState<number | null>(null);\n  const [initialX] = React.useState(() => String(model.at[index] ?? 0));\n  const rulerRef = React.useRef<HTMLDivElement>(null);\n  const ghostRef = React.useRef<HTMLSpanElement>(null);\n  const paneRef = React.useRef<HTMLDivElement | null>(null);\n  const xRef = React.useRef(model.at[index] ?? 0);\n  const indexRef = React.useRef(index);\n  const tweenRef = React.useRef(0);\n  const playingRef = React.useRef(false);\n  const dragRef = React.useRef<{ startX: number; moved: boolean } | null>(null);\n  const reducedRef = React.useRef(reduced);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const swipeRef = React.useRef<{ id: number; x: number; y: number; t: number; dx: number; axis: \"x\" | \"y\" | null } | null>(null);\n  const [carry, setCarry] = React.useState(0);\n\n  // Which release left, and which way: kept from the render before, so a controlled value slides the same way.\n  const [prev, setPrev] = React.useState({ index, n: 0, dir: 0 });\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  if (prev.index !== index) {\n    const dir = Math.sign(index - prev.index);\n    setPrev({ index, n: prev.n + 1, dir });\n    setLeaving((l) => [...l.slice(-1), { index: prev.index, dir, key: prev.n, from: carry }]);\n    if (carry) setCarry(0);\n  }\n\n  React.useEffect(() => {\n    indexRef.current = index;\n    reducedRef.current = reduced;\n  });\n\n  React.useEffect(() => {\n    if (!leaving.length) return;\n    const timer = window.setTimeout(() => setLeaving([]), MORPH);\n    return () => window.clearTimeout(timer);\n  }, [leaving]);\n\n  // The card takes the height of the release it's showing.\n  React.useLayoutEffect(() => {\n    const pane = paneRef.current;\n    if (!pane) return;\n    const measure = () => setHeight((h) => (h === pane.offsetHeight ? h : pane.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(pane);\n    return () => observer.disconnect();\n  }, [index]);\n\n  React.useEffect(() => () => cancelAnimationFrame(tweenRef.current), []);\n\n  const paint = (x: number) => {\n    xRef.current = x;\n    rulerRef.current?.style.setProperty(\"--x\", x.toFixed(3));\n  };\n\n  /** Moves the playhead; every frame is one custom property on the ruler. */\n  const tween = (to: number, duration: number, ease: (t: number) => number, onFrame: (x: number) => void = paint, onEnd?: () => void) => {\n    cancelAnimationFrame(tweenRef.current);\n    const from = xRef.current;\n    const start = performance.now();\n    const step = (now: number) => {\n      const t = duration > 0 ? Math.min(1, (now - start) / duration) : 1;\n      onFrame(from + (to - from) * ease(t));\n      if (t < 1) tweenRef.current = requestAnimationFrame(step);\n      else {\n        tweenRef.current = 0;\n        onEnd?.();\n      }\n    };\n    if (duration > 0) tweenRef.current = requestAnimationFrame(step);\n    else step(start);\n  };\n  const settle = (to: number) => tween(to, reducedRef.current ? 0 : 520, THROWN);\n\n  const choose = (i: number) => {\n    const id = model.releases[i]?.entry.id;\n    if (!id || i === indexRef.current) return;\n    indexRef.current = i;\n    if (value === undefined) setOwn(id);\n    onValueChange?.(id);\n  };\n  const nearest = (x: number) => model.at.reduce((best, p, i) => (Math.abs(p - x) < Math.abs(model.at[best] - x) ? i : best), 0);\n  const lastPassed = (x: number) => model.at.reduce((found, p, i) => (p <= x + 1e-6 ? i : found), 0);\n\n  // A new release from outside the ruler (keys, a click, a controlled value): throw the playhead onto it.\n  React.useEffect(() => {\n    if (dragRef.current?.moved || playingRef.current) return;\n    const to = model.at[index];\n    if (to !== undefined && Math.abs(xRef.current - to) > 0.01) settle(to);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [index, model]);\n\n  // A link to a release (#its-id) opens on it.\n  React.useEffect(() => {\n    const hash = decodeURIComponent(window.location.hash.slice(1));\n    if (value === undefined && hash && entries.some((e) => e.id === hash)) setOwn(hash);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const pause = () => {\n    if (!playingRef.current) return;\n    cancelAnimationFrame(tweenRef.current);\n    tweenRef.current = 0;\n    playingRef.current = false;\n    setPlaying(false);\n  };\n\n  // Play runs at the pace the releases shipped: the playhead moves at one speed and the card changes as it passes each one.\n  // With reduced motion the playhead steps from release to release instead of gliding.\n  const play = () => {\n    if (playingRef.current) return pause();\n    const { at } = model;\n    if (at.length < 2) return;\n    const first = at[0];\n    const last = at[at.length - 1];\n    if (xRef.current >= last - 0.01) {\n      paint(first);\n      choose(0);\n    }\n    playingRef.current = true;\n    setPlaying(true);\n    const total = playDuration ?? at.length * 1200;\n    tween(\n      last,\n      (total * (last - xRef.current)) / (last - first),\n      LINEAR,\n      (x) => {\n        const i = lastPassed(x);\n        xRef.current = x;\n        rulerRef.current?.style.setProperty(\"--x\", (reducedRef.current ? at[i] : x).toFixed(3));\n        choose(i);\n      },\n      () => {\n        playingRef.current = false;\n        setPlaying(false);\n      }\n    );\n  };\n\n  const xAt = (clientX: number) => {\n    const r = rulerRef.current?.getBoundingClientRect();\n    return r ? Math.min(100, Math.max(0, ((clientX - r.left) / r.width) * 100)) : xRef.current;\n  };\n  const ghost = (x: number | null) => {\n    if (x !== null) rulerRef.current?.style.setProperty(\"--h\", x.toFixed(3));\n    if (ghostRef.current) ghostRef.current.style.opacity = x === null ? \"0\" : \"1\";\n  };\n  const release = (clientX: number, up: boolean) => {\n    const drag = dragRef.current;\n    if (!drag) return;\n    dragRef.current = null;\n    if (drag.moved) {\n      setDragging(false);\n      settle(model.at[nearest(xRef.current)]);\n      return;\n    }\n    if (!up) return;\n    const i = nearest(xAt(clientX));\n    if (i === indexRef.current) settle(model.at[i]);\n    else choose(i);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const moves: Record<string, number> = { ArrowLeft: index - 1, ArrowDown: index - 1, ArrowRight: index + 1, ArrowUp: index + 1, Home: 0, End: n - 1 };\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    pause();\n    const i = Math.min(n - 1, Math.max(0, moves[e.key]));\n    if (i === index) settle(model.at[i]);\n    else choose(i);\n  };\n\n  const step = (d: number) => {\n    pause();\n    const i = Math.min(n - 1, Math.max(0, indexRef.current + d));\n    if (i !== indexRef.current) choose(i);\n  };\n\n  // Swiping the card (touch and pen; a mouse selects text): the release follows the finger, a swipe past 56px or a quick\n  // flick moves one release and the old one carries on from where it was let go; anything less springs back.\n  const onSwipeStart = (e: React.PointerEvent) => {\n    if (e.pointerType === \"mouse\" || (e.target as HTMLElement).closest(\"button, a\")) return;\n    swipeRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, t: performance.now(), dx: 0, axis: null };\n  };\n  const onSwipeMove = (e: React.PointerEvent) => {\n    const s = swipeRef.current;\n    if (!s || s.id !== e.pointerId) return;\n    const dx = e.clientX - s.x;\n    const dy = e.clientY - s.y;\n    if (!s.axis) {\n      if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;\n      s.axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n      if (s.axis === \"y\") {\n        swipeRef.current = null;\n        return;\n      }\n      e.currentTarget.setPointerCapture(e.pointerId);\n      pause();\n    }\n    const edge = (dx < 0 && indexRef.current === n - 1) || (dx > 0 && indexRef.current === 0);\n    s.dx = dx * (edge ? 0.3 : 0.8);\n    const stage = stageRef.current;\n    if (stage && !reducedRef.current) {\n      stage.style.transition = \"none\";\n      stage.style.transform = `translateX(${s.dx}px)`;\n    }\n  };\n  const onSwipeEnd = (e: React.PointerEvent, commit: boolean) => {\n    const s = swipeRef.current;\n    if (!s || s.id !== e.pointerId) return;\n    swipeRef.current = null;\n    if (s.axis !== \"x\") return;\n    const stage = stageRef.current;\n    const raw = e.clientX - s.x;\n    const flick = performance.now() - s.t < 260 && Math.abs(raw) > 24;\n    const d = commit && (Math.abs(raw) > 56 || flick) ? (raw < 0 ? 1 : -1) : 0;\n    const i = Math.min(n - 1, Math.max(0, indexRef.current + d));\n    if (d && i !== indexRef.current) {\n      if (stage) {\n        stage.style.transition = \"none\";\n        stage.style.transform = \"\";\n      }\n      setCarry(reducedRef.current ? 0 : s.dx);\n      step(d);\n    } else if (stage) {\n      stage.style.transition = `transform 420ms ${THROW}`;\n      stage.style.transform = \"\";\n    }\n  };\n\n  if (!current) return null;\n\n  const dateText = (d: string | Date, short = false) =>\n    new Date(d).toLocaleDateString(locales ?? \"en-GB\", short ? { day: \"numeric\", month: \"short\" } : { day: \"numeric\", month: \"short\", year: \"numeric\" });\n  const tags = current.tags ?? [];\n  const tagText = tags.map((t) => TAG_LABEL[t] ?? t).join(\", \");\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"flex items-start gap-3\">\n        <button\n          type=\"button\"\n          aria-label={playing ? \"Pause\" : \"Play the releases\"}\n          onClick={play}\n          className={`relative mt-5 flex size-8 shrink-0 items-center justify-center rounded-full text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n        >\n          <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\" className=\"absolute size-3.5 translate-x-px\" style={swap(!playing, reduced)}>\n            <path d=\"M5 3.6v8.8a.6.6 0 0 0 .92.5l6.9-4.4a.6.6 0 0 0 0-1L5.92 3.1A.6.6 0 0 0 5 3.6Z\" />\n          </svg>\n          <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\" className=\"absolute size-3.5\" style={swap(playing, reduced)}>\n            <rect x=\"4\" y=\"3\" width=\"2.75\" height=\"10\" rx=\"0.8\" />\n            <rect x=\"9.25\" y=\"3\" width=\"2.75\" height=\"10\" rx=\"0.8\" />\n          </svg>\n        </button>\n\n        <div\n          ref={rulerRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Release\"\n          aria-valuemin={1}\n          aria-valuemax={n}\n          aria-valuenow={index + 1}\n          aria-valuetext={`${current.version ? `${current.version}, ` : \"\"}${current.title}, ${dateText(current.date)}`}\n          onKeyDown={onKeyDown}\n          onPointerDown={(e) => {\n            if (e.button !== 0) return;\n            e.currentTarget.setPointerCapture(e.pointerId);\n            pause();\n            dragRef.current = { startX: e.clientX, moved: false };\n          }}\n          onPointerMove={(e) => {\n            const drag = dragRef.current;\n            if (!drag) {\n              if (e.pointerType === \"mouse\") ghost(xAt(e.clientX));\n              return;\n            }\n            if (!drag.moved) {\n              if (Math.abs(e.clientX - drag.startX) < 4) return;\n              drag.moved = true;\n              setDragging(true);\n              ghost(null);\n            }\n            cancelAnimationFrame(tweenRef.current);\n            tweenRef.current = 0;\n            const x = xAt(e.clientX);\n            paint(x);\n            choose(nearest(x));\n          }}\n          onPointerUp={(e) => release(e.clientX, true)}\n          onPointerCancel={(e) => release(e.clientX, false)}\n          onPointerLeave={() => ghost(null)}\n          className={`relative h-[66px] min-w-0 flex-1 touch-none select-none rounded-lg [container-type:inline-size] ${dragging ? \"cursor-grabbing\" : \"cursor-grab\"} ${FOCUS}`}\n          style={{ \"--x\": initialX } as React.CSSProperties}\n        >\n          {/* The baseline, and the part of history already behind the playhead. */}\n          <span aria-hidden=\"true\" className=\"absolute inset-x-0 top-[46px] h-px bg-border\" />\n          <span aria-hidden=\"true\" className=\"absolute inset-x-0 top-[46px] h-px origin-left bg-foreground/50\" style={{ transform: \"scaleX(calc(var(--x) / 100))\" }} />\n          {model.ticks.map((p, i) => (\n            <span key={i} aria-hidden=\"true\" className=\"absolute top-[40px] h-[6px] w-px origin-bottom bg-border\" style={lens(p, 1.6)} />\n          ))}\n          {model.months.map((m) =>\n            m.edge ? null : <span key={`tick-${m.p}`} aria-hidden=\"true\" className=\"absolute top-[36px] h-[10px] w-px origin-bottom bg-foreground/25\" style={lens(m.p, 0.9)} />\n          )}\n          {model.at.map((p, i) => (\n            <span\n              key={model.releases[i].entry.id}\n              aria-hidden=\"true\"\n              className={`absolute top-[32px] h-[14px] w-px origin-bottom transition-colors duration-300 ${i === index ? \"bg-foreground\" : \"bg-foreground/40\"}`}\n              style={lens(p, 0.45)}\n            />\n          ))}\n          {model.months.map((m) => (\n            <span\n              key={`label-${m.p}`}\n              aria-hidden=\"true\"\n              className={`absolute top-[53px] whitespace-nowrap text-[11px] leading-none text-muted-foreground ${m.p > 90 ? \"-translate-x-full pr-1\" : \"pl-1\"}`}\n              style={{ left: `${m.p}%` }}\n            >\n              {m.label}\n            </span>\n          ))}\n\n          {/* Where the pointer would land. */}\n          <span\n            ref={ghostRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-0 top-[26px] h-[24px] w-px bg-foreground/25 opacity-0 transition-opacity duration-200\"\n            style={{ transform: \"translateX(calc(var(--h, 0) * 1cqw))\" }}\n          />\n\n          {/* The playhead, carrying the version it's on. */}\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 left-0 w-px\" style={{ transform: \"translateX(calc(var(--x) * 1cqw))\" }}>\n            <span className=\"absolute left-0 top-[20px] h-[30px] w-px bg-primary\" />\n            <span className=\"absolute left-0 top-0 flex h-5 -translate-x-1/2 items-center whitespace-nowrap rounded-full bg-primary px-2 font-mono text-[11px] font-medium text-primary-foreground\">\n              <TextMorph>{current.version ?? dateText(current.date, true)}</TextMorph>\n            </span>\n          </div>\n        </div>\n      </div>\n\n      <div\n        onPointerDown={onSwipeStart}\n        onPointerMove={onSwipeMove}\n        onPointerUp={(e) => onSwipeEnd(e, true)}\n        onPointerCancel={(e) => onSwipeEnd(e, false)}\n        className=\"mt-5 touch-pan-y touch-pinch-zoom rounded-[22px] bg-background p-5 shadow-[0_0_0_1px_var(--border)] sm:p-6\"\n      >\n        <div className=\"flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground\">\n          <div className=\"flex min-w-0 items-center\">\n            <span\n              className=\"grid\"\n              style={{\n                gridTemplateColumns: tags.length ? \"1fr\" : \"0fr\",\n                opacity: tags.length ? 1 : 0,\n                transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity 240ms ${EASE}`,\n              }}\n            >\n              <span className=\"min-w-0 pr-2 [clip-path:inset(-4px_-2px)]\">\n                <span className={`inline-flex whitespace-nowrap rounded-full px-2 py-px text-[11px] font-medium transition-colors duration-300 ${TAG_TONE[tags[0]] ?? \"bg-muted text-foreground/80\"}`}>\n                  <TextMorph>{tagText || \" \"}</TextMorph>\n                </span>\n              </span>\n            </span>\n            <time dateTime={new Date(current.date).toISOString()}>\n              <TextMorph>{dateText(current.date)}</TextMorph>\n            </time>\n          </div>\n          <div className=\"-my-1 -mr-2 flex shrink-0 items-center\">\n            <Step dir={-1} disabled={index === 0} onStep={() => step(-1)} />\n            <p className=\"flex items-baseline gap-[0.3em] px-1 tabular-nums\">\n              <NumberRoll value={index + 1} duration={600} />\n              <span>of {n}</span>\n            </p>\n            <Step dir={1} disabled={index === n - 1} onStep={() => step(1)} />\n          </div>\n        </div>\n\n        <div className=\"relative mt-4 [clip-path:inset(0_-20px)]\" style={{ height: height ?? undefined, transition: reduced || height === null ? \"none\" : `height ${MORPH}ms ${EASE}` }}>\n          {/* What a swipe moves. */}\n          <div ref={stageRef} className=\"relative\">\n            {leaving.map((l) => {\n              const e = model.releases[l.index]?.entry;\n              return e ? (\n                <Pane key={`out-${l.key}`} dir={l.dir} leaving reduced={reduced} from={l.from}>\n                  <Release entry={e} />\n                </Pane>\n              ) : null;\n            })}\n            <Pane key={current.id} dir={prev.dir} leaving={false} reduced={reduced} paneRef={paneRef}>\n              <Release entry={current} />\n            </Pane>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction Release({ entry }: { entry: ChangelogEntry }) {\n  return (\n    <>\n      <h3 className=\"text-[19px] font-medium leading-snug tracking-[-0.02em] text-foreground\">{entry.title}</h3>\n      {entry.body && <div className=\"mt-2 text-[14px] leading-relaxed text-muted-foreground [&_li]:mt-1 [&_ul]:mt-2 [&_ul]:list-disc [&_ul]:pl-5\">{entry.body}</div>}\n      {entry.media && <div className=\"mt-4 overflow-hidden rounded-[16px] border border-border\">{entry.media}</div>}\n    </>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/changelog-scrubber"}