{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"timeline-dock","type":"registry:ui","title":"TimelineDock","description":"Scrub\/play dock for authoring + previewing a timeline.","package":"fancy-motion","dependencies":[],"registryDependencies":["timeline"],"files":[{"path":"components\/fancy\/timeline-dock\/TimelineDock.tsx","content":"import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactElement } from \"react\";\nimport type { Keyframe, TimelineDoc } from \"..\/timeline\/types\";\n\nexport interface TimelineDockProps {\n  value: TimelineDoc;\n  onChange: (doc: TimelineDoc) => void;\n  \/** Playhead position 0..1. *\/\n  progress?: number;\n  onScrub?: (progress: number) => void;\n  selectedKeyframe?: string | null;\n  onSelectKeyframe?: (id: string | null) => void;\n  \/** Milliseconds the Play button takes to sweep the whole timeline (default frames \u00d7 1400ms). *\/\n  previewDurationMs?: number;\n}\n\n\/**\n * The EditMode timeline dock. A single page is a video; this is its filmstrip:\n * a **frame ruler** (1 segment \u2248 1 viewport), **keyframe** markers you can add \/\n * select \/ toggle snap\u00b7scroll, and a scrubbable **playhead**. Keyframes capture\n * whole-page snapshots (the engine tweens between them). Controlled.\n *\/\nexport function TimelineDock({\n  value,\n  onChange,\n  progress = 0,\n  onScrub,\n  selectedKeyframe = null,\n  onSelectKeyframe,\n  previewDurationMs,\n}: TimelineDockProps): ReactElement {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const frames = Math.max(1, value.frames);\n  const sorted = [...value.keyframes].sort((a, b) => a.at - b.at);\n  const selected = sorted.find((k) => k.id === selectedKeyframe) ?? null;\n\n  \/\/ Play = sweep the playhead 0\u21921 over time so you can preview the configured\n  \/\/ animation without scrolling. Driven by setInterval (fires in background tabs\n  \/\/ too, unlike rAF) off wall-clock elapsed, so playback speed is frame-independent.\n  const [playing, setPlaying] = useState(false);\n  const timer = useRef<ReturnType<typeof setInterval> | null>(null);\n  const stop = () => {\n    if (timer.current) clearInterval(timer.current);\n    timer.current = null;\n    setPlaying(false);\n  };\n  useEffect(() => () => stop(), []);\n  const play = () => {\n    if (!onScrub) return;\n    if (timer.current) clearInterval(timer.current);\n    const duration = Math.max(600, previewDurationMs ?? frames * 1400);\n    const from = progress >= 0.999 ? 0 : progress; \/\/ restart if parked at the end\n    const t0 = Date.now() - from * duration;\n    setPlaying(true);\n    onScrub(from);\n    timer.current = setInterval(() => {\n      const p = Math.min(1, (Date.now() - t0) \/ duration);\n      onScrub(p);\n      if (p >= 1) stop();\n    }, 1000 \/ 30); \/\/ 30fps \u2014 smooth enough, and easy on heavy full-page subtrees\n  };\n  const togglePlay = () => (playing ? stop() : play());\n\n  const addKeyframe = () => {\n    const id = `kf-${value.keyframes.length + 1}-${Math.floor(performance.now())}`;\n    const kf: Keyframe = { id, at: clamp01(progress), mode: \"scroll\", snapshot: {} };\n    onChange({ ...value, keyframes: [...value.keyframes, kf] });\n    onSelectKeyframe?.(id);\n  };\n  const addScene = () => {\n    const id = `sc-${(value.scenes?.length ?? 0) + 1}`;\n    onChange({\n      ...value,\n      scenes: [...(value.scenes ?? []), { id, at: clamp01(progress), length: 0.15, keyframes: [] }],\n    });\n  };\n  const patchKf = (id: string, patch: Partial<Keyframe>) =>\n    onChange({ ...value, keyframes: value.keyframes.map((k) => (k.id === id ? { ...k, ...patch } : k)) });\n  const removeKf = (id: string) => {\n    onChange({ ...value, keyframes: value.keyframes.filter((k) => k.id !== id) });\n    if (selectedKeyframe === id) onSelectKeyframe?.(null);\n  };\n  const patchScene = (id: string, patch: Partial<{ at: number; length: number }>) =>\n    onChange({ ...value, scenes: (value.scenes ?? []).map((s) => (s.id === id ? { ...s, ...patch } : s)) });\n  const removeScene = (id: string) =>\n    onChange({ ...value, scenes: (value.scenes ?? []).filter((s) => s.id !== id) });\n\n  \/\/ Scene resize \u2014 drag the left edge to move the start, the right edge to set\n  \/\/ the length. Min length 0.02 (so a scene never collapses to zero).\n  const sceneDrag = useRef<{ id: string; edge: \"l\" | \"r\" } | null>(null);\n  const posOnTrack = (clientX: number): number => {\n    const el = trackRef.current;\n    if (!el) return 0;\n    const r = el.getBoundingClientRect();\n    return clamp01((clientX - r.left) \/ r.width);\n  };\n  const onSceneHandleDown = (id: string, edge: \"l\" | \"r\") => (e: ReactPointerEvent<HTMLDivElement>) => {\n    e.stopPropagation();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    sceneDrag.current = { id, edge };\n  };\n  const onSceneHandleMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const d = sceneDrag.current;\n    if (!d) return;\n    const s = (value.scenes ?? []).find((x) => x.id === d.id);\n    if (!s) return;\n    const p = posOnTrack(e.clientX);\n    if (d.edge === \"l\") {\n      const at = Math.max(0, Math.min(p, s.at + s.length - 0.02));\n      patchScene(d.id, { at, length: s.at + s.length - at });\n    } else {\n      patchScene(d.id, { length: Math.max(0.02, Math.min(1 - s.at, p - s.at)) });\n    }\n  };\n  const onSceneHandleUp = () => {\n    sceneDrag.current = null;\n  };\n\n  const scrubTo = (clientX: number) => {\n    const el = trackRef.current;\n    if (!el || !onScrub) return;\n    const r = el.getBoundingClientRect();\n    onScrub(clamp01((clientX - r.left) \/ r.width));\n  };\n  const onTrackPointer = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    scrubTo(e.clientX);\n  };\n  const onTrackMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (e.buttons & 1) scrubTo(e.clientX);\n  };\n\n  return (\n    <div style={dock} data-fancy-motion-dock=\"\">\n      <div style={header}>\n        <button\n          type=\"button\"\n          style={{ ...btn, background: playing ? \"var(--fmo-accent, #8b5cf6)\" : \"var(--fmo-control, #334155)\", minWidth: 64 }}\n          onClick={togglePlay}\n          disabled={!onScrub}\n          title={playing ? \"Pause preview\" : \"Play preview\"}\n        >\n          {playing ? \"\u275a\u275a Pause\" : \"\u25b6 Play\"}\n        <\/button>\n        <strong style={{ fontSize: 12 }}>Timeline<\/strong>\n        <span style={{ opacity: 0.6, fontSize: 11 }}>\n          {value.axis} \u00b7 {frames} frame{frames === 1 ? \"\" : \"s\"}\n        <\/span>\n        <span style={{ flex: 1 }} \/>\n        <button type=\"button\" style={btn} onClick={addKeyframe}>\u25c6 Keyframe<\/button>\n        <button type=\"button\" style={btn} onClick={addScene}>\u25a3 Scene<\/button>\n        <button type=\"button\" style={btn} onClick={() => onChange({ ...value, frames: frames + 1 })} title=\"Expand page (add a viewport of scroll length)\">\uff0b Frame<\/button>\n        <button type=\"button\" style={{ ...btn, opacity: frames > 1 ? 1 : 0.4 }} disabled={frames <= 1} onClick={() => onChange({ ...value, frames: frames - 1 })} title=\"Shrink page\">\uff0d Frame<\/button>\n      <\/div>\n\n      <div\n        ref={trackRef}\n        \/\/ A scrubbable track is a slider, and this one was a bare div: no role,\n        \/\/ no value, and nothing a keyboard could reach.\n        role=\"slider\"\n        aria-label=\"Timeline playhead\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(clamp01(progress) * 100)}\n        aria-valuetext={`${Math.round(clamp01(progress) * 100)}% through the timeline`}\n        tabIndex={onScrub ? 0 : -1}\n        data-fmo-track=\"\"\n        onKeyDown={(e) => {\n          if (!onScrub) return;\n          const step = e.shiftKey ? 0.1 : 0.02;\n          if (e.key === \"ArrowRight\") { e.preventDefault(); onScrub(clamp01(progress + step)); }\n          if (e.key === \"ArrowLeft\") { e.preventDefault(); onScrub(clamp01(progress - step)); }\n          if (e.key === \"Home\") { e.preventDefault(); onScrub(0); }\n          if (e.key === \"End\") { e.preventDefault(); onScrub(1); }\n        }}\n        style={track}\n        onPointerDown={onTrackPointer}\n        onPointerMove={onTrackMove}\n      >\n        {\/* frame dividers *\/}\n        {Array.from({ length: frames }, (_, i) => (\n          <div key={`f${i}`} style={{ ...frameCell, left: `${(i \/ frames) * 100}%`, width: `${(1 \/ frames) * 100}%` }}>\n            <span style={frameLabel}>{i + 1}<\/span>\n          <\/div>\n        ))}\n\n        {\/* scenes (pinned ranges) \u2014 drag the edges to resize, \u2715 to remove *\/}\n        {(value.scenes ?? []).map((s) => (\n          <div key={s.id} style={{ ...sceneBand, left: `${s.at * 100}%`, width: `${s.length * 100}%` }} title=\"Scene (pinned) \u2014 drag edges to resize\">\n            <div\n              onPointerDown={onSceneHandleDown(s.id, \"l\")}\n              onPointerMove={onSceneHandleMove}\n              onPointerUp={onSceneHandleUp}\n              style={{ ...sceneHandle, left: -3 }}\n            \/>\n            <button\n              type=\"button\"\n              \/\/ \"\u2715\" is a glyph, not a name.\n              aria-label={`Remove scene ${s.id}`}\n              data-fmo-scene-remove={s.id}\n              title=\"Remove scene\"\n              onPointerDown={(e) => e.stopPropagation()}\n              onClick={() => removeScene(s.id)}\n              style={sceneRemove}\n            >\n              \u2715\n            <\/button>\n            <div\n              onPointerDown={onSceneHandleDown(s.id, \"r\")}\n              onPointerMove={onSceneHandleMove}\n              onPointerUp={onSceneHandleUp}\n              style={{ ...sceneHandle, right: -3 }}\n            \/>\n          <\/div>\n        ))}\n\n        {\/* keyframes *\/}\n        {sorted.map((k) => (\n          <button\n            key={k.id}\n            type=\"button\"\n            \/\/ The marker renders as a bare diamond with no text at all, so the\n            \/\/ name has to be explicit. `aria-pressed` is what tells a screen\n            \/\/ reader which keyframe is selected \u2014 the white outline below says\n            \/\/ it only to people who can see it.\n            aria-label={`${k.mode} keyframe at ${Math.round(k.at * 100)}%`}\n            aria-pressed={k.id === selectedKeyframe}\n            data-fmo-keyframe={k.id}\n            onPointerDown={(e) => e.stopPropagation()}\n            onClick={() => onSelectKeyframe?.(k.id)}\n            title={`${k.mode} keyframe`}\n            style={{\n              ...diamond,\n              left: `${k.at * 100}%`,\n              background: k.mode === \"snap\" ? \"var(--fmo-scene, #f59e0b)\" : \"var(--fmo-accent, #8b5cf6)\",\n              outline: k.id === selectedKeyframe ? \"2px solid #fff\" : \"none\",\n            }}\n          \/>\n        ))}\n\n        {\/* playhead *\/}\n        <div style={{ ...playhead, left: `${clamp01(progress) * 100}%` }} \/>\n      <\/div>\n\n      {selected ? (\n        <div style={kfRow}>\n          <span style={{ opacity: 0.7, fontSize: 11 }}>keyframe @ {(selected.at * 100).toFixed(0)}%<\/span>\n          <button\n            type=\"button\"\n            style={{ ...btn, background: selected.mode === \"snap\" ? \"var(--fmo-scene, #f59e0b)\" : \"var(--fmo-control, #334155)\" }}\n            onClick={() => patchKf(selected.id, { mode: selected.mode === \"snap\" ? \"scroll\" : \"snap\" })}\n          >\n            {selected.mode === \"snap\" ? \"snap\" : \"scroll\"}\n          <\/button>\n          <span style={{ flex: 1 }} \/>\n          <button type=\"button\" style={{ ...btn, color: \"#fca5a5\" }} onClick={() => removeKf(selected.id)}>Delete<\/button>\n        <\/div>\n      ) : null}\n    <\/div>\n  );\n}\n\nfunction clamp01(n: number): number {\n  return Math.min(1, Math.max(0, n));\n}\n\n\/**\n * Theme tokens for the dock.\n *\n * Every colour below resolved from a hardcoded slate hex, so a host could not\n * retheme the timeline at all \u2014 it was a fixed dark bar whatever the app around\n * it looked like. The suite's other editor surfaces solve this the same way\n * (`--ff-*` in fancy-flow, `--fcms-*` in fancy-cms-ui); this is the `--fmo-*`\n * layer, with the previous values as fallbacks so nothing moves by default.\n *\n * Override on any ancestor:\n *\n *   .my-app { --fmo-surface: #17171c; --fmo-accent: #ec4899; }\n *\n * The dock stays dark BY DEFAULT on purpose \u2014 it is editor chrome over a live\n * page, the same call a video editor's timeline makes \u2014 but that is now a\n * default rather than a hard-coding.\n *\/\nconst dock: CSSProperties = {\n  background: \"var(--fmo-surface, #0b1220)\",\n  color: \"var(--fmo-fg, #e2e8f0)\",\n  borderTop: \"1px solid var(--fmo-border, #1e293b)\",\n  padding: \"10px 14px 14px\",\n  fontFamily: \"system-ui, sans-serif\",\n  boxShadow: \"0 -8px 24px -12px rgba(0,0,0,0.5)\",\n};\nconst header: CSSProperties = { display: \"flex\", alignItems: \"center\", gap: 10, marginBottom: 10 };\nconst btn: CSSProperties = {\n  font: \"inherit\",\n  fontSize: 11,\n  color: \"var(--fmo-fg, #e2e8f0)\",\n  background: \"var(--fmo-control, #334155)\",\n  border: \"1px solid var(--fmo-control-border, #475569)\",\n  borderRadius: 6,\n  padding: \"4px 8px\",\n  cursor: \"pointer\",\n};\nconst track: CSSProperties = {\n  position: \"relative\",\n  height: 56,\n  background: \"var(--fmo-track, #0f172a)\",\n  border: \"1px solid var(--fmo-border, #1e293b)\",\n  borderRadius: 8,\n  overflow: \"hidden\",\n  cursor: \"ew-resize\",\n};\nconst frameCell: CSSProperties = {\n  position: \"absolute\",\n  top: 0,\n  bottom: 0,\n  borderRight: \"1px solid var(--fmo-border, #1e293b)\",\n  boxSizing: \"border-box\",\n};\nconst frameLabel: CSSProperties = { position: \"absolute\", top: 4, left: 6, fontSize: 10, opacity: 0.4 };\nconst sceneBand: CSSProperties = {\n  position: \"absolute\",\n  top: 0,\n  bottom: 0,\n  background: \"rgba(245,158,11,0.14)\",\n  borderLeft: \"1px solid rgba(245,158,11,0.5)\",\n  borderRight: \"1px solid rgba(245,158,11,0.5)\",\n};\nconst sceneHandle: CSSProperties = {\n  position: \"absolute\",\n  top: 0,\n  bottom: 0,\n  width: 7,\n  cursor: \"ew-resize\",\n  background: \"rgba(245,158,11,0.6)\",\n  touchAction: \"none\",\n};\nconst sceneRemove: CSSProperties = {\n  position: \"absolute\",\n  top: 3,\n  right: 9,\n  width: 16,\n  height: 16,\n  lineHeight: \"12px\",\n  fontSize: 10,\n  color: \"#fff\",\n  background: \"rgba(245,158,11,0.7)\",\n  border: \"none\",\n  borderRadius: 4,\n  cursor: \"pointer\",\n  padding: 0,\n};\nconst diamond: CSSProperties = {\n  position: \"absolute\",\n  top: \"50%\",\n  width: 14,\n  height: 14,\n  transform: \"translate(-50%, -50%) rotate(45deg)\",\n  border: \"1px solid rgba(255,255,255,0.4)\",\n  borderRadius: 3,\n  padding: 0,\n  cursor: \"pointer\",\n};\nconst playhead: CSSProperties = {\n  position: \"absolute\",\n  top: -2,\n  bottom: -2,\n  width: 2,\n  marginLeft: -1,\n  background: \"var(--fmo-playhead, #38bdf8)\",\n  pointerEvents: \"none\",\n};\nconst kfRow: CSSProperties = { display: \"flex\", alignItems: \"center\", gap: 10, marginTop: 10 };\n","type":"registry:ui","target":"components\/fancy\/timeline-dock\/TimelineDock.tsx"}]}