{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "type": "registry:component",
  "title": "Timeline",
  "description": "Time laid out left to right: tracks of clips under a ruler, with a playhead.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [
    "https://panelui.dev/r/web/slider.json"
  ],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/timeline.tsx",
      "type": "registry:component",
      "target": "@components/panelui/timeline.tsx",
      "content": "'use client';\n\n/**\n * Timeline — time laid out left to right: tracks of clips under a ruler,\n * with a playhead, for editing a recording, an animation or a schedule.\n *\n * ```tsx\n * <Timeline duration={150} defaultZoom={12}>\n *   <Timeline.Header>\n *     <Timeline.Time />\n *     <Timeline.Zoom />\n *   </Timeline.Header>\n *   <Timeline.Viewport>\n *     <Timeline.Ruler />\n *     <Timeline.Track label=\"Zooms\">\n *       <Timeline.Clip start={12} end={20} color=\"chart-2\" variant=\"block\" onChange={…} />\n *     </Timeline.Track>\n *     <Timeline.Playhead />\n *   </Timeline.Viewport>\n * </Timeline>\n * ```\n *\n * ## Clips you take hold of\n *\n * A clip with `onChange` is moved by dragging its middle and trimmed by\n * dragging either end. It cannot be pushed into its neighbour on the same\n * track, and near another clip's edge, the playhead or either end of the\n * timeline it snaps there, with a guide line drawn the length of the\n * timeline so the alignment can be seen.\n *\n * ## Three kinds of clip\n *\n * `bar` is a flat segment on a thin track, for something that only has a\n * span: a cut, a caption. `block` is a tall pill with grips, for an effect\n * placed on top of the footage: a zoom, a highlight. `media` is the\n * footage itself, a wide band that holds a `Timeline.Waveform`.\n *\n * ## The ruler keeps its distance\n *\n * Labels on the ruler stay at least 64 pixels apart at any zoom: zoomed\n * out, it counts in minutes; zoomed in, in fractions of a second. Pinch,\n * or ⌘ or Ctrl with the wheel, zooms about the pointer, so the moment under\n * the hand stays under it.\n */\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type RefObject,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { Slider } from '@/components/panelui/slider';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type * as ReactTypes from 'react';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type TimelineColor =\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5';\nexport type TimelineClipVariant = 'bar' | 'block' | 'media';\nexport type TimelineTrackSize = 'sm' | 'md' | 'lg';\n\n/** Space before the first second and after the last, in pixels. */\nconst GUTTER = 16;\n/** How close, in pixels, an edge has to come to something before it snaps there. */\nconst SNAP = 8;\n/** The narrowest a clip can be trimmed to, in pixels at the current zoom. */\nconst MIN_WIDTH = 12;\n/** How far apart, at least, the ruler's labels stand. */\nconst LABEL_GAP = 64;\n/** Steps the ruler can count in, in seconds. */\nconst STEPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];\n\nconst COLORS: Record<TimelineColor, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\n/**\n * A time as a reader expects it. On a timeline under a minute long, seconds\n * with a unit: `0.5s`, `12s`. Longer, `m:ss`, and `h:mm:ss` past an hour.\n * `tenths` adds a tenth of a second either way.\n */\nexport function formatTime(seconds: number, tenths = false, short = false) {\n  const sign = seconds < 0 ? '−' : '';\n  const total = Math.abs(seconds);\n  if (short) return `${sign}${tenths ? total.toFixed(1) : Math.round(total)}s`;\n  const hours = Math.floor(total / 3600);\n  const minutes = Math.floor((total - hours * 3600) / 60);\n  const rest = total - hours * 3600 - minutes * 60;\n  const whole = Math.floor(rest + 1e-6);\n  const second = String(whole).padStart(2, '0');\n  const fraction = tenths ? `.${Math.floor((rest - whole) * 10 + 1e-6)}` : '';\n  return hours\n    ? `${sign}${hours}:${String(minutes).padStart(2, '0')}:${second}${fraction}`\n    : `${sign}${minutes}:${second}${fraction}`;\n}\n\n/** Under a minute, a timeline counts in seconds rather than minutes. */\nconst isShort = (duration: number) => duration < 60;\n\n/** The ruler's step at a zoom: the smallest that keeps labels `LABEL_GAP` apart. */\nexport function rulerStep(zoom: number) {\n  return STEPS.find((step) => step * zoom >= LABEL_GAP) ?? STEPS[STEPS.length - 1]!;\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst timelineVariants = tv({\n  slots: {\n    root: [\n      'group/timeline relative flex w-full min-w-0 flex-col overflow-hidden rounded-[22px] bg-popover text-popover-foreground select-none',\n      'shadow-[0_10px_30px_-12px_rgb(0_0_0/0.3)] ring-1 ring-foreground/[0.06] dark:ring-foreground/10',\n    ],\n    header: 'flex min-h-12 min-w-0 items-center gap-2 px-3 pt-1.5',\n    time: 'shrink-0 px-1 text-xs font-medium whitespace-nowrap tabular-nums text-muted-foreground [&>b]:font-medium [&>b]:text-foreground',\n    // The first thing to give way on a narrow screen.\n    zoom: 'ms-auto flex w-44 min-w-24 shrink items-center',\n    viewport: 'relative min-w-0 flex-1 overscroll-x-contain outline-none',\n    content: 'relative isolate pb-3',\n    scrollbar:\n      'mx-3 mb-1 flex h-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',\n    thumb: 'relative flex-1 rounded-full bg-foreground/20',\n    // Past the end, a strip of diagonal lines says there is nothing more.\n    end: [\n      'pointer-events-none absolute top-8 bottom-0 -z-10',\n      'bg-[repeating-linear-gradient(-45deg,color-mix(in_oklab,var(--foreground)_12%,transparent)_0_2px,transparent_2px_7px)]',\n    ],\n    ruler: 'relative h-8 cursor-ew-resize border-b border-foreground/[0.06]',\n    tick: 'pointer-events-none absolute bottom-0 w-px bg-foreground/20',\n    label:\n      'pointer-events-none absolute top-1.5 -translate-x-1/2 text-[11px] font-medium tabular-nums text-muted-foreground',\n    track: 'relative my-1.5 first-of-type:mt-3',\n    clip: [\n      'group/clip absolute inset-y-0 flex min-w-0 items-center justify-center overflow-hidden outline-none',\n      'bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--clip)_78%,white),var(--clip)_55%,color-mix(in_oklab,var(--clip)_88%,black))]',\n      'shadow-[inset_0_1px_0_rgb(255_255_255/0.35),inset_0_-1px_0_rgb(0_0_0/0.15),0_1px_2px_rgb(0_0_0/0.2)]',\n      'transition-[scale,box-shadow,filter] duration-150',\n      'data-editable:cursor-grab data-dragging:cursor-grabbing data-dragging:scale-y-[1.04] data-dragging:brightness-110',\n      'focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-popover',\n      'motion-reduce:transition-none',\n    ],\n    handle: [\n      'absolute inset-y-0 z-10 w-2.5 cursor-ew-resize opacity-0 transition-opacity duration-150',\n      'group-hover/clip:opacity-100 group-data-dragging/clip:opacity-100',\n      \"after:absolute after:inset-y-[28%] after:w-[3px] after:rounded-full after:bg-white/80 after:content-['']\",\n    ],\n    grip: 'pointer-events-none flex h-[42%] gap-[3px] [&>span]:w-[3px] [&>span]:rounded-full [&>span]:bg-white/45',\n    clipLabel: [\n      'pointer-events-none relative z-10 inline-flex max-w-full items-center gap-1.5 truncate px-2 text-xs font-semibold text-(--clip-foreground,white)',\n      '[text-shadow:0_1px_2px_rgb(0_0_0/0.25)] [&_svg]:size-3.5 [&_svg]:shrink-0',\n    ],\n    waveform: 'pointer-events-none absolute inset-x-0 inset-y-[12%] size-auto h-[76%] w-full text-white/40',\n    guide: 'pointer-events-none absolute top-8 bottom-0 z-20 w-px -translate-x-1/2 bg-foreground/70',\n    playhead: 'pointer-events-none absolute top-0 bottom-0 z-30 -translate-x-1/2',\n    playheadLine: 'absolute top-6 bottom-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-(--playhead)',\n    playheadHead: [\n      'pointer-events-auto absolute top-1 left-1/2 flex h-5 min-w-5 -translate-x-1/2 cursor-ew-resize items-center justify-center rounded-md px-1.5',\n      'bg-(--playhead) text-[10px] font-semibold tabular-nums text-(--playhead-foreground) shadow-sm outline-none',\n      'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover',\n    ],\n  },\n  variants: {\n    variant: {\n      bar: { clip: 'rounded-lg' },\n      block: { clip: 'rounded-[14px]' },\n      media: { clip: 'rounded-xl' },\n    },\n    size: {\n      sm: { track: 'h-6' },\n      md: { track: 'h-14' },\n      lg: { track: 'h-16' },\n    },\n  },\n});\n\ninterface Span {\n  start: number;\n  end: number;\n}\n\ninterface TimelineContextValue {\n  duration: number;\n  zoom: number;\n  minZoom: number;\n  maxZoom: number;\n  setZoom(zoom: number, anchor?: { time: number; x: number }): void;\n  time: number;\n  setTime(time: number): void;\n  playing: boolean;\n  setPlaying(playing: boolean): void;\n  snap: boolean;\n  readOnly: boolean;\n  /** Every clip's span, so an edge can snap to another track's clip. */\n  spans: Map<string, Span>;\n  /** Where a snapped edge is, while a guide line marks it. */\n  setGuide(time: number | null): void;\n  viewport: RefObject<HTMLDivElement | null>;\n}\n\nconst TimelineContext = createContext<TimelineContextValue | null>(null);\n\nfunction useTimelineContext(part: string) {\n  const context = useContext(TimelineContext);\n  if (!context) throw new Error(`${part} must be used inside a <Timeline>.`);\n  return context;\n}\n\n/**\n * The timeline's state, for controls of your own: a play button in a\n * toolbar, a readout, a button that zooms to fit. Use it inside `<Timeline>`.\n */\nexport function useTimeline() {\n  const { duration, time, setTime, playing, setPlaying, zoom, setZoom, minZoom, maxZoom } =\n    useTimelineContext('useTimeline');\n  return { duration, time, setTime, playing, setPlaying, zoom, setZoom, minZoom, maxZoom };\n}\n\nexport interface TimelineProps extends Omit<ComponentProps<'div'>, 'onChange'> {\n  /** How long the timeline is, in seconds. */\n  duration: number;\n  /** Where the playhead is, in seconds, when the caller holds it. */\n  time?: number;\n  /** Where the playhead starts, when the caller does not hold it. Default 0. */\n  defaultTime?: number;\n  /** Called with the new time when the playhead moves: scrubbed, stepped or playing. */\n  onTimeChange?: (time: number) => void;\n  /** Pixels per second, when the caller holds it. */\n  zoom?: number;\n  /** Pixels per second to start at. Default 12. */\n  defaultZoom?: number;\n  /** Called with the new zoom. */\n  onZoomChange?: (zoom: number) => void;\n  /** The furthest out it zooms, in pixels per second. Default 2. */\n  minZoom?: number;\n  /** The furthest in it zooms, in pixels per second. Default 120. */\n  maxZoom?: number;\n  /** Whether time is running, when the caller holds it. */\n  playing?: boolean;\n  /** Called when playback starts or stops, including when it reaches the end. */\n  onPlayingChange?: (playing: boolean) => void;\n  /** Start again from the beginning on reaching the end, instead of stopping. */\n  loop?: boolean;\n  /** Snap clip edges to other edges, the playhead and the ends. Default true. */\n  snap?: boolean;\n  /** Show it without letting clips be moved or trimmed. The playhead still moves. */\n  readOnly?: boolean;\n}\n\n/** Holds the time, the zoom and playback for the parts inside it. */\nfunction TimelineRoot({\n  duration,\n  time: timeProp,\n  defaultTime = 0,\n  onTimeChange,\n  zoom: zoomProp,\n  defaultZoom = 12,\n  onZoomChange,\n  minZoom = 2,\n  maxZoom = 120,\n  playing: playingProp,\n  onPlayingChange,\n  loop = false,\n  snap = true,\n  readOnly = false,\n  className,\n  children,\n  ...props\n}: TimelineProps) {\n  const slots = timelineVariants();\n  const [innerTime, setInnerTime] = useState(defaultTime);\n  const [innerZoom, setInnerZoom] = useState(defaultZoom);\n  const [innerPlaying, setInnerPlaying] = useState(false);\n  const [guide, setGuide] = useState<number | null>(null);\n  const time = clamp(timeProp ?? innerTime, 0, duration);\n  const zoom = clamp(zoomProp ?? innerZoom, minZoom, maxZoom);\n  const playing = playingProp ?? innerPlaying;\n  const spans = useRef(new Map<string, Span>()).current;\n  const viewport = useRef<HTMLDivElement>(null);\n\n  const live = useRef({\n    time,\n    zoom,\n    duration,\n    loop,\n    onTimeChange,\n    onZoomChange,\n    onPlayingChange,\n    timeProp,\n    zoomProp,\n    playingProp,\n  });\n  live.current = {\n    time,\n    zoom,\n    duration,\n    loop,\n    onTimeChange,\n    onZoomChange,\n    onPlayingChange,\n    timeProp,\n    zoomProp,\n    playingProp,\n  };\n\n  const setTime = useCallback((next: number) => {\n    const value = clamp(next, 0, live.current.duration);\n    // Seen by the playback loop at once, so a jump back to the start is not\n    // overwritten by the frame that was already on its way.\n    live.current.time = value;\n    if (live.current.timeProp === undefined) setInnerTime(value);\n    live.current.onTimeChange?.(value);\n  }, []);\n\n  const setPlaying = useCallback((next: boolean) => {\n    if (live.current.playingProp === undefined) setInnerPlaying(next);\n    live.current.onPlayingChange?.(next);\n  }, []);\n\n  const setZoom = useCallback(\n    (next: number, anchor?: { time: number; x: number }) => {\n      const value = clamp(next, minZoom, maxZoom);\n      if (live.current.zoomProp === undefined) setInnerZoom(value);\n      live.current.onZoomChange?.(value);\n      // Keep the moment under the pointer (or the playhead) where it was.\n      const box = viewport.current;\n      if (!box) return;\n      const at = anchor ?? {\n        time: live.current.time,\n        x: GUTTER + live.current.time * live.current.zoom - box.scrollLeft,\n      };\n      requestAnimationFrame(() => {\n        box.scrollLeft = GUTTER + at.time * value - at.x;\n      });\n    },\n    [minZoom, maxZoom]\n  );\n\n  // Playback: time runs on with the clock while playing.\n  useEffect(() => {\n    if (!playing) return;\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const next = live.current.time + (now - last) / 1000;\n      last = now;\n      if (next >= live.current.duration) {\n        if (live.current.loop) setTime(0);\n        else {\n          setTime(live.current.duration);\n          setPlaying(false);\n          return;\n        }\n      } else setTime(next);\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, setTime, setPlaying]);\n\n  const context = useMemo<TimelineContextValue>(\n    () => ({\n      duration,\n      zoom,\n      minZoom,\n      maxZoom,\n      setZoom,\n      time,\n      setTime,\n      playing,\n      setPlaying,\n      snap,\n      readOnly,\n      spans,\n      setGuide,\n      viewport,\n    }),\n    [duration, zoom, minZoom, maxZoom, setZoom, time, setTime, playing, setPlaying, snap, readOnly, spans]\n  );\n\n  return (\n    <TimelineContext.Provider value={context}>\n      <GuideContext.Provider value={guide}>\n        <div\n          data-slot=\"timeline\"\n          data-playing={playing ? '' : undefined}\n          className={slots.root({ className })}\n          {...props}\n        >\n          {children}\n        </div>\n      </GuideContext.Provider>\n    </TimelineContext.Provider>\n  );\n}\nTimelineRoot.displayName = 'Timeline';\n\nconst GuideContext = createContext<number | null>(null);\n\nexport interface TimelineHeaderProps extends ComponentProps<'div'> {}\n\n/** A row above the tracks, for a toolbar, the time and the zoom. */\nfunction TimelineHeader({ className, render, ...props }: TimelineHeaderProps & RenderProp<'div'>) {\n  const slots = timelineVariants();\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ 'data-slot': 'timeline-header', className: slots.header({ className }) }, props),\n  });\n}\nTimelineHeader.displayName = 'Timeline.Header';\n\nexport interface TimelineTimeProps extends ComponentProps<'span'> {\n  /** Show the length after the time, as `1:32 / 2:30`. Default true. */\n  showDuration?: boolean;\n}\n\n/** Where the playhead is, written out, and how long the whole is. */\nfunction TimelineTime({ showDuration = true, className, render, ...props }: TimelineTimeProps & RenderProp<'span'>) {\n  const { time, duration } = useTimelineContext('Timeline.Time');\n  const slots = timelineVariants();\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'timeline-time',\n        className: slots.time({ className }),\n        children: (\n          <>\n            <b>{formatTime(time, true, isShort(duration))}</b>\n            {showDuration ? ` / ${formatTime(duration, isShort(duration), isShort(duration))}` : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nTimelineTime.displayName = 'Timeline.Time';\n\nexport interface TimelineZoomProps extends Omit<ComponentProps<'div'>, 'children'> {}\n\n/** A line slider that zooms the timeline, with zoom out and zoom in either side. */\nfunction TimelineZoom({ className, render, ...props }: TimelineZoomProps & RenderProp<'div'>) {\n  const { zoom, setZoom, minZoom, maxZoom } = useTimelineContext('Timeline.Zoom');\n  const slots = timelineVariants();\n  // The slider runs on a log scale, so each stretch of it zooms by the same factor.\n  const toSlider = (value: number) => Math.log(value / minZoom) / Math.log(maxZoom / minZoom);\n  const fromSlider = (value: number) => minZoom * Math.pow(maxZoom / minZoom, value);\n  const step = 0.125;\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'timeline-zoom',\n        className: slots.zoom({ className }),\n        children: (\n          <>\n            <Slider\n              variant=\"line\"\n              label=\"Zoom\"\n              min={0}\n              max={1}\n              step={0.001}\n              value={toSlider(zoom)}\n              onValueChange={(value) => setZoom(fromSlider(value))}\n              format={() => `${Math.round(zoom)} pixels per second`}\n            >\n              <Slider.Step by={-step} aria-label=\"Zoom out\">\n                <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\">\n                  <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n                  <path d=\"M5 7h4M10.5 10.5 13.5 13.5\" />\n                </svg>\n              </Slider.Step>\n              <Slider.Step by={step} aria-label=\"Zoom in\">\n                <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\">\n                  <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n                  <path d=\"M5 7h4M7 5v4M10.5 10.5 13.5 13.5\" />\n                </svg>\n              </Slider.Step>\n            </Slider>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nTimelineZoom.displayName = 'Timeline.Zoom';\n\nexport interface TimelineViewportProps extends ComponentProps<'div'> {}\n\n/**\n * The part that scrolls sideways: the ruler, the tracks and the playhead go\n * in here. Pinch, or ⌘ or Ctrl with the wheel, zooms about the pointer.\n */\nfunction TimelineViewport({ className, style, children, ...props }: TimelineViewportProps) {\n  const timeline = useTimelineContext('Timeline.Viewport');\n  const guide = useContext(GuideContext);\n  const slots = timelineVariants();\n  const { viewport, duration, zoom, setZoom } = timeline;\n  const width = GUTTER * 2 + duration * zoom;\n\n  useEffect(() => {\n    const box = viewport.current;\n    if (!box) return;\n    const onWheel = (event: WheelEvent) => {\n      if (!event.ctrlKey && !event.metaKey) return;\n      event.preventDefault();\n      const x = event.clientX - box.getBoundingClientRect().left;\n      const time = (box.scrollLeft + x - GUTTER) / live.current;\n      setZoom(live.current * Math.exp(-event.deltaY * 0.01), { time, x });\n    };\n    box.addEventListener('wheel', onWheel, { passive: false });\n    return () => box.removeEventListener('wheel', onWheel);\n  }, [viewport, setZoom]);\n  const live = useRef(zoom);\n  live.current = zoom;\n\n  return (\n    <ScrollArea.Root data-slot=\"timeline-viewport\" className={slots.viewport({ className })}>\n      <ScrollArea.Viewport ref={viewport} className=\"outline-none\">\n        <ScrollArea.Content\n          dir=\"ltr\"\n          className={slots.content()}\n          style={{ ...style, width, minWidth: '100%', ['--timeline-zoom' as string]: zoom } as CSSProperties}\n          {...props}\n        >\n          <span aria-hidden className={slots.end()} style={{ left: GUTTER + duration * zoom, right: 0 }} />\n          {children}\n          {guide !== null ? (\n            <span\n              aria-hidden\n              data-slot=\"timeline-guide\"\n              className={slots.guide()}\n              style={{ left: GUTTER + guide * zoom }}\n            />\n          ) : null}\n        </ScrollArea.Content>\n      </ScrollArea.Viewport>\n      <ScrollArea.Scrollbar orientation=\"horizontal\" className={slots.scrollbar()}>\n        <ScrollArea.Thumb className={slots.thumb()} />\n      </ScrollArea.Scrollbar>\n    </ScrollArea.Root>\n  );\n}\nTimelineViewport.displayName = 'Timeline.Viewport';\n\n/** Turns a pointer's position into a time on the timeline. */\nfunction useTimeAt() {\n  const { viewport, zoom, duration } = useTimelineContext('A timeline part');\n  return useCallback(\n    (clientX: number) => {\n      const box = viewport.current;\n      if (!box) return 0;\n      const x = clientX - box.getBoundingClientRect().left + box.scrollLeft;\n      return clamp((x - GUTTER) / zoom, 0, duration);\n    },\n    [viewport, zoom, duration]\n  );\n}\n\nexport interface TimelineRulerProps extends ComponentProps<'div'> {}\n\n/** Ticks and times along the top. Pressing or dragging on it moves the playhead there. */\nfunction TimelineRuler({ className, render, ...props }: TimelineRulerProps & RenderProp<'div'>) {\n  const { duration, zoom, setTime } = useTimelineContext('Timeline.Ruler');\n  const slots = timelineVariants();\n  const timeAt = useTimeAt();\n  const step = rulerStep(zoom);\n  // Minor ticks between labels, where there is room for them.\n  const minor = [5, 4, 2].map((parts) => step / parts).find((each) => each * zoom >= 8) ?? step;\n  const ticks: ReactNode[] = [];\n  const count = Math.floor(duration / minor + 1e-6);\n  for (let at = 0; at <= count; at++) {\n    const t = at * minor;\n    const major = Math.abs(t / step - Math.round(t / step)) < 1e-6;\n    const left = GUTTER + t * zoom;\n    ticks.push(\n      <span key={`t${at}`} className={slots.tick({ className: major ? 'h-2.5' : 'h-1.5' })} style={{ left }} />\n    );\n    if (major) {\n      ticks.push(\n        <span key={`l${at}`} className={slots.label()} style={{ left }}>\n          {formatTime(t, step < 1, isShort(duration))}\n        </span>\n      );\n    }\n  }\n\n  const scrub = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setTime(timeAt(event.clientX));\n  };\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'aria-hidden': true,\n        'data-slot': 'timeline-ruler',\n        className: slots.ruler({ className }),\n        onPointerDown: scrub,\n        onPointerMove: (event: ReactTypes.PointerEvent<HTMLDivElement>) => {\n          if (event.currentTarget.hasPointerCapture(event.pointerId)) setTime(timeAt(event.clientX));\n        },\n        children: <>{ticks}</>,\n      },\n      props\n    ),\n  });\n}\nTimelineRuler.displayName = 'Timeline.Ruler';\n\ninterface TrackContextValue {\n  /** The clips on this track, so a drag stops at its neighbours. */\n  spans: Map<string, Span>;\n  size: TimelineTrackSize;\n}\n\nconst TrackContext = createContext<TrackContextValue | null>(null);\n\n/** How wide the clip is on screen, so a waveform can draw bars of a fixed width. */\nconst ClipWidthContext = createContext<number>(0);\n\nexport interface TimelineTrackProps extends ComponentProps<'div'> {\n  /** The height of the row: `sm` for bars, `md` for blocks, `lg` for media. Default `md`. */\n  size?: TimelineTrackSize;\n  /** What the track holds, for a screen reader: \"Zooms\", \"Voice\". */\n  label?: string;\n}\n\n/** A row of clips. Clips on one track cannot overlap. */\nfunction TimelineTrack({ size = 'md', label, className, ...props }: TimelineTrackProps) {\n  const slots = timelineVariants({ size });\n  const spans = useRef(new Map<string, Span>()).current;\n  return (\n    <TrackContext.Provider value={useMemo(() => ({ spans, size }), [spans, size])}>\n      <div\n        role=\"group\"\n        aria-label={label}\n        data-slot=\"timeline-track\"\n        data-size={size}\n        className={slots.track({ className })}\n        {...props}\n      />\n    </TrackContext.Provider>\n  );\n}\nTimelineTrack.displayName = 'Timeline.Track';\n\nexport interface TimelineClipProps extends Omit<ComponentProps<'div'>, 'onChange'> {\n  /** Where it starts, in seconds. */\n  start: number;\n  /** Where it ends, in seconds. */\n  end: number;\n  /** Called with the new span while it is moved or trimmed. Without it the clip stays put. */\n  onChange?: (span: { start: number; end: number }) => void;\n  /** Called once a move or a trim ends. */\n  onChangeEnd?: (span: { start: number; end: number }) => void;\n  /** Called on Delete or Backspace while the clip has focus. */\n  onRemove?: () => void;\n  /** `bar`, `block` or `media`. Default: `bar` on a small track, `media` on a large one, `block` otherwise. */\n  variant?: TimelineClipVariant;\n  /** Its colour, from the theme. Default `primary`. */\n  color?: TimelineColor;\n  /** What a screen reader calls it, before its times. */\n  label?: string;\n}\n\n/** One clip. With `onChange` it moves by its middle and trims by its ends. */\nfunction TimelineClip({\n  start,\n  end,\n  onChange,\n  onChangeEnd,\n  onRemove,\n  variant: variantProp,\n  color = 'primary',\n  label,\n  className,\n  style,\n  children,\n  onKeyDown,\n  render,\n  ...props\n}: TimelineClipProps & RenderProp<'div'>) {\n  const timeline = useTimelineContext('Timeline.Clip');\n  const track = useContext(TrackContext);\n  if (!track) throw new Error('Timeline.Clip must be used inside a <Timeline.Track>.');\n  const variant = variantProp ?? (track.size === 'sm' ? 'bar' : track.size === 'lg' ? 'media' : 'block');\n  const slots = timelineVariants({ variant });\n  const id = useRef(Math.random().toString(36).slice(2)).current;\n  const editable = Boolean(onChange) && !timeline.readOnly;\n  const [dragging, setDragging] = useState(false);\n  const { zoom, duration, spans: everyone, setGuide, time } = timeline;\n\n  // Known to the track, for neighbours, and to the timeline, for snapping.\n  track.spans.set(id, { start, end });\n  everyone.set(id, { start, end });\n  useEffect(\n    () => () => {\n      track.spans.delete(id);\n      everyone.delete(id);\n    },\n    [track.spans, everyone, id]\n  );\n\n  const live = useRef({ start, end, onChange, onChangeEnd, time });\n  live.current = { start, end, onChange, onChangeEnd, time };\n\n  /** The room this clip has: from its left neighbour's end to its right neighbour's start. */\n  const room = () => {\n    let lower = 0;\n    let upper = duration;\n    track.spans.forEach((span, other) => {\n      if (other === id) return;\n      if (span.end <= live.current.start + 1e-6) lower = Math.max(lower, span.end);\n      if (span.start >= live.current.end - 1e-6) upper = Math.min(upper, span.start);\n    });\n    return { lower, upper };\n  };\n\n  /** Moves an edge onto a nearby edge, the playhead or an end, and says where. */\n  const snapEdge = (at: number): { at: number; to: number | null } => {\n    if (!timeline.snap) return { at, to: null };\n    const reach = SNAP / zoom;\n    const targets = [0, duration, live.current.time];\n    everyone.forEach((span, other) => {\n      if (other !== id) targets.push(span.start, span.end);\n    });\n    let best: number | null = null;\n    for (const target of targets) {\n      if (Math.abs(target - at) <= reach && (best === null || Math.abs(target - at) < Math.abs(best - at)))\n        best = target;\n    }\n    return best === null ? { at, to: null } : { at: best, to: best };\n  };\n\n  const press = (event: ReactPointerEvent<HTMLElement>, part: 'move' | 'start' | 'end') => {\n    if (!editable || event.button !== 0) return;\n    event.preventDefault();\n    event.stopPropagation();\n    (event.currentTarget as HTMLElement)\n      .closest<HTMLElement>('[data-slot=timeline-clip]')\n      ?.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n    const originX = event.clientX;\n    const origin = { start: live.current.start, end: live.current.end };\n    const { lower, upper } = room();\n    const length = origin.end - origin.start;\n    const shortest = MIN_WIDTH / zoom;\n    setDragging(true);\n    let last = origin;\n\n    const move = (next: PointerEvent) => {\n      const delta = (next.clientX - originX) / zoom;\n      let span: Span;\n      let guide: number | null = null;\n      if (part === 'move') {\n        let from = clamp(origin.start + delta, lower, upper - length);\n        // Either edge may snap; the nearer one wins.\n        const head = snapEdge(from);\n        const tail = snapEdge(from + length);\n        if (head.to !== null && (tail.to === null || Math.abs(head.at - from) <= Math.abs(tail.at - from - length))) {\n          from = clamp(head.at, lower, upper - length);\n          guide = head.to;\n        } else if (tail.to !== null) {\n          from = clamp(tail.at - length, lower, upper - length);\n          guide = tail.to;\n        }\n        span = { start: from, end: from + length };\n      } else if (part === 'start') {\n        const snapped = snapEdge(origin.start + delta);\n        const from = clamp(snapped.at, lower, origin.end - shortest);\n        guide = snapped.to !== null && from === snapped.at ? snapped.to : null;\n        span = { start: from, end: origin.end };\n      } else {\n        const snapped = snapEdge(origin.end + delta);\n        const to = clamp(snapped.at, origin.start + shortest, upper);\n        guide = snapped.to !== null && to === snapped.at ? snapped.to : null;\n        span = { start: origin.start, end: to };\n      }\n      setGuide(guide);\n      last = span;\n      live.current.onChange?.(span);\n    };\n    const release = () => {\n      setDragging(false);\n      setGuide(null);\n      live.current.onChangeEnd?.(last);\n      window.removeEventListener('pointermove', move);\n      window.removeEventListener('pointerup', release);\n      window.removeEventListener('pointercancel', release);\n    };\n    window.addEventListener('pointermove', move);\n    window.addEventListener('pointerup', release);\n    window.addEventListener('pointercancel', release);\n  };\n\n  const keys = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    if ((event.key === 'Delete' || event.key === 'Backspace') && onRemove && !timeline.readOnly) {\n      event.preventDefault();\n      onRemove();\n      return;\n    }\n    if (!editable || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;\n    event.preventDefault();\n    const { lower, upper } = room();\n    const by = (event.key === 'ArrowLeft' ? -1 : 1) * (event.shiftKey ? 1 : 0.1);\n    const length = end - start;\n    const from = clamp(start + by, lower, upper - length);\n    const span = { start: from, end: from + length };\n    onChange?.(span);\n    onChangeEnd?.(span);\n  };\n\n  const left = GUTTER + start * zoom;\n  const width = Math.max((end - start) * zoom, 2);\n  const name = `${label ? `${label}, ` : ''}${formatTime(start, true, isShort(duration))} to ${formatTime(end, true, isShort(duration))}`;\n  const roomy = width >= 40;\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        role: 'button',\n        tabIndex: 0,\n        'aria-roledescription': 'clip',\n        'aria-label': name,\n        'data-slot': 'timeline-clip',\n        'data-variant': variant,\n        'data-editable': editable ? '' : undefined,\n        'data-dragging': dragging ? '' : undefined,\n        className: slots.clip({ className }),\n        style: {\n          ...style,\n          left,\n          width,\n          '--clip': COLORS[color],\n          // Text on a warning-coloured clip is dark; on the others it is white.\n          ...(color === 'warning' ? { '--clip-foreground': 'rgb(0 0 0 / 0.8)' } : null),\n        } as CSSProperties,\n        onPointerDown: (event: ReactTypes.PointerEvent<HTMLDivElement>) => press(event, 'move'),\n        onKeyDown: keys,\n        children: (\n          <>\n            {variant === 'block' && roomy && !hasLabel(children) ? (\n              <span aria-hidden className={slots.grip()}>\n                <span />\n                <span />\n              </span>\n            ) : null}\n            <ClipWidthContext.Provider value={width}>{children}</ClipWidthContext.Provider>\n            {editable && width >= 28 ? (\n              <>\n                <span\n                  aria-hidden\n                  data-slot=\"timeline-clip-handle\"\n                  className={slots.handle({ className: 'start-0 after:start-1' })}\n                  onPointerDown={(event) => press(event, 'start')}\n                />\n                <span\n                  aria-hidden\n                  data-slot=\"timeline-clip-handle\"\n                  className={slots.handle({ className: 'end-0 after:end-1' })}\n                  onPointerDown={(event) => press(event, 'end')}\n                />\n              </>\n            ) : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nTimelineClip.displayName = 'Timeline.Clip';\n\nfunction hasLabel(children: ReactNode) {\n  let found = false;\n  Children.forEach(children, (child) => {\n    if (isValidElement(child) && child.type === TimelineClipLabel) found = true;\n  });\n  return found;\n}\n\nexport interface TimelineClipLabelProps extends ComponentProps<'span'> {}\n\n/** Words on a clip, with an icon: its length, its speed, what it is. */\nfunction TimelineClipLabel({ className, render, ...props }: TimelineClipLabelProps & RenderProp<'span'>) {\n  const slots = timelineVariants();\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps({ 'data-slot': 'timeline-clip-label', className: slots.clipLabel({ className }) }, props),\n  });\n}\nTimelineClipLabel.displayName = 'Timeline.ClipLabel';\n\nexport interface TimelineWaveformProps extends Omit<ComponentProps<'svg'>, 'children'> {\n  /** Loudness along the clip, each from 0 to 1. Drawn mirrored about the middle. */\n  peaks: number[];\n}\n\n/** The sound of a media clip, drawn as bars across it. */\nfunction TimelineWaveform({ peaks, className, render, ...props }: TimelineWaveformProps & RenderProp<'svg'>) {\n  const slots = timelineVariants();\n  const width = useContext(ClipWidthContext);\n  // One bar every 4 pixels at any zoom: the peaks are resampled to the room\n  // there is, each bar taking the loudest of the peaks it stands for.\n  const bars = Math.max(1, Math.floor((width || peaks.length * 4) / 4));\n  const heights = Array.from({ length: bars }, (_, at) => {\n    let loudest = 0;\n    if (bars > peaks.length) {\n      // More bars than peaks: blend between the two nearest, so zooming in\n      // draws a curve rather than steps.\n      const position = (at / Math.max(bars - 1, 1)) * (peaks.length - 1);\n      const below = Math.floor(position);\n      const mix = position - below;\n      loudest = (peaks[below] ?? 0) * (1 - mix) + (peaks[below + 1] ?? peaks[below] ?? 0) * mix;\n    } else {\n      const from = Math.floor((at / bars) * peaks.length);\n      const to = Math.max(from + 1, Math.floor(((at + 1) / bars) * peaks.length));\n      for (let each = from; each < to && each < peaks.length; each++) loudest = Math.max(loudest, peaks[each] ?? 0);\n    }\n    // Rounded, so the server and the browser write the same numbers.\n    return Math.round(Math.max(clamp(loudest, 0, 1) * 100, 3) * 100) / 100;\n  });\n  return useRender({\n    defaultTagName: 'svg',\n    render,\n    props: mergeProps(\n      {\n        'aria-hidden': true,\n        'data-slot': 'timeline-waveform',\n        viewBox: `0 0 ${bars * 4} 100`,\n        preserveAspectRatio: 'none',\n        className: slots.waveform({ className }),\n        children: (\n          <>\n            {heights.map((height, at) => (\n              <rect\n                key={at}\n                x={at * 4 + 1}\n                y={Math.round((50 - height / 2) * 100) / 100}\n                width={2}\n                height={height}\n                rx={1}\n                fill=\"currentColor\"\n              />\n            ))}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nTimelineWaveform.displayName = 'Timeline.Waveform';\n\nexport interface TimelinePlayheadProps extends Omit<ComponentProps<'div'>, 'color'> {\n  /** The colour of the line and its head. Default `primary`. */\n  color?: TimelineColor;\n  /** Write the time in the head. Default true. */\n  showTime?: boolean;\n}\n\n/**\n * The line at the current time, from the ruler to the bottom. Its head can\n * be dragged, and with focus the arrow keys step it: a tenth of a second,\n * or a second with Shift.\n */\nfunction TimelinePlayhead({\n  color = 'primary',\n  showTime = true,\n  className,\n  style,\n  render,\n  ...props\n}: TimelinePlayheadProps & RenderProp<'div'>) {\n  const { time, setTime, duration, zoom, playing, setPlaying } = useTimelineContext('Timeline.Playhead');\n  const slots = timelineVariants();\n  const timeAt = useTimeAt();\n  const left = GUTTER + time * zoom;\n  const foreground = color === 'primary' ? 'var(--primary-foreground)' : color === 'warning' ? 'black' : 'white';\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'timeline-playhead',\n        className: slots.playhead({ className }),\n        style: {\n          ...style,\n          left,\n          ['--playhead' as string]: COLORS[color],\n          ['--playhead-foreground' as string]: foreground,\n        } as CSSProperties,\n        children: (\n          <>\n            <span aria-hidden className={slots.playheadLine()} />\n            <div\n              role=\"slider\"\n              tabIndex={0}\n              aria-label=\"Playhead\"\n              aria-valuemin={0}\n              aria-valuemax={duration}\n              aria-valuenow={Math.round(time * 10) / 10}\n              aria-valuetext={formatTime(time, true, isShort(duration))}\n              data-slot=\"timeline-playhead-head\"\n              className={slots.playheadHead()}\n              onPointerDown={(event) => {\n                if (event.button !== 0) return;\n                event.currentTarget.setPointerCapture(event.pointerId);\n                if (playing) setPlaying(false);\n              }}\n              onPointerMove={(event) => {\n                if (event.currentTarget.hasPointerCapture(event.pointerId)) setTime(timeAt(event.clientX));\n              }}\n              onKeyDown={(event) => {\n                const by = event.shiftKey ? 1 : 0.1;\n                const moves: Record<string, number> = {\n                  ArrowLeft: time - by,\n                  ArrowRight: time + by,\n                  Home: 0,\n                  End: duration,\n                };\n                if (event.key === ' ') {\n                  event.preventDefault();\n                  setPlaying(!playing);\n                  return;\n                }\n                const next = moves[event.key];\n                if (next === undefined) return;\n                event.preventDefault();\n                setTime(next);\n              }}\n            >\n              {showTime ? formatTime(time, isShort(duration), isShort(duration)) : null}\n            </div>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nTimelinePlayhead.displayName = 'Timeline.Playhead';\n\nexport const Timeline = Object.assign(TimelineRoot, {\n  Header: TimelineHeader,\n  Time: TimelineTime,\n  Zoom: TimelineZoom,\n  Viewport: TimelineViewport,\n  Ruler: TimelineRuler,\n  Track: TimelineTrack,\n  Clip: TimelineClip,\n  ClipLabel: TimelineClipLabel,\n  Waveform: TimelineWaveform,\n  Playhead: TimelinePlayhead,\n});\n\nexport {\n  TimelineHeader,\n  TimelineTime,\n  TimelineZoom,\n  TimelineViewport,\n  TimelineRuler,\n  TimelineTrack,\n  TimelineClip,\n  TimelineClipLabel,\n  TimelineWaveform,\n  TimelinePlayhead,\n  timelineVariants,\n};\n"
    }
  ]
}
