{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sync-grid",
  "type": "registry:ui",
  "title": "Sync Grid",
  "description": "A grid of videos on one clock. Scrub any card and the rest follow it to the same moment. Stack them to flip between versions of a single frame, or drop one onto another to compare them side by side.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/sync-grid.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  cancelFrame,\n  frame,\n  motion,\n  motionValue,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControlsWithThen,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * SYNC GRID STORYBOARD\n *\n *  scrub    move the pointer across any card: the clock follows\n *           its x. That card leads, with a ring, a thicker line\n *           and a timestamp riding its playhead. Leave, and\n *           every card keeps the moment\n *  ripple   the others chase the leader on springs that slow\n *           with each cell of distance, so time washes outward\n *           across the grid. 150ms after the pointer stops they\n *           all sit on exactly the same frame\n *  play     Space or ▶: every card plays on the one clock.\n *           Videos play natively, nudged back into line when\n *           they drift. Off screen, everything pauses\n *  stack    the cards fly into one frame, fanned behind the\n *           front one. x scrubs, y picks the variant, which\n *           swaps in a 100ms crossfade on the same frame\n *  compare  drag a card onto another (long-press on touch):\n *           they merge into one frame with a divider, or layer\n *           in Difference so only what changed glows\n *  hint     the first time the grid is seen, a ghost cursor\n *           sweeps the first card once\n *  reduce   no ripple, no flights (a quick fade), no hint\n *\n *  Nothing renders React per frame. One frame loop moves the\n *  clock and the followers; cards listen to motion values and\n *  write styles directly. Videos that share a source decode\n *  once and are drawn into each card's canvas.\n * ───────────────────────────────────────────────────────── */\n\nconst SYNC = {\n  // A follower's spring, as motion's visualDuration in s: base plus step per\n  // cell from the leader, with the step shrunk so the farthest card stays\n  // under max.\n  ripple: { base: 0.06, step: 0.04, max: 0.14 },\n  settle: 0.15, // s after the clock stops until every card is on the leader's frame\n  fly: { type: \"spring\", visualDuration: 0.5, bounce: 0.16 },\n  stagger: 0.035, // s of flight delay per cell from the card that led\n  fan: { type: \"spring\", visualDuration: 0.3, bounce: 0.12 },\n  lift: { type: \"spring\", visualDuration: 0.28, bounce: 0.22 },\n  glide: { type: \"spring\", visualDuration: 0.25, bounce: 0.12 }, // highlights\n  wipe: { type: \"spring\", visualDuration: 0.45, bounce: 0.08 }, // the divider opening\n  swap: { duration: 0.1, ease: \"linear\" }, // stack variants crossfade\n  fade: { duration: 0.16, ease: \"easeOut\" },\n  instant: { duration: 0 },\n  inset: 12, // px between a card's edges and its timeline\n  press: 5, // px a mouse press travels before it lifts the card\n  slop: 8, // px a touch travels before it commits to scrubbing\n  longPress: 350, // ms a touch holds still to lift the card\n  hysteresis: 0.12, // share of a band the pointer crosses before the stack switches\n  ring: 10, // decoded frames kept per shared video, so followers can lag behind\n  drift: { seek: 0.25, gain: 0.4, cap: 0.1 }, // s off before a hard seek; playbackRate nudge\n} as const;\n\n// Cards behind the front one, by depth: a few px up, a degree or two off, and\n// a little darker the further back they sit.\nconst FAN = [\n  { x: 0, y: 0, rotate: 0, shade: 0 },\n  { x: -3, y: -9, rotate: -1.5, shade: 0.07 },\n  { x: 4, y: -17, rotate: 1.2, shade: 0.13 },\n  { x: -2, y: -24, rotate: -1.9, shade: 0.19 },\n];\nconst FAN_ROOM = 26; // px above the stack for the fan to show\n\ntype Mode = \"grid\" | \"stack\" | \"compare\";\ntype View = \"split\" | \"difference\";\n\nexport type SyncGridSprite = {\n  /** Image URL of the sheet: frames left to right, then top to bottom. */\n  url: string;\n  columns: number;\n  rows: number;\n  /** How many cells hold frames. Defaults to `columns × rows`. */\n  frames?: number;\n};\n\n// ── Clock ───────────────────────────────────────────────────\n\ntype Card = {\n  index: number;\n  /** This card's own time, 0–1. Followers spring toward the clock. */\n  shown: MotionValue<number>;\n  velocity: number;\n  lead: MotionValue<number>; // 0–1: ring, thick line, timestamp\n  glow: MotionValue<number>; // 0–1: a lifted card is over it\n  lift: MotionValue<number>; // 0–1: picked up\n  aim: MotionValue<number>; // 0–1: picked up and over a card to drop on\n  dragX: MotionValue<number>;\n  dragY: MotionValue<number>;\n  element: HTMLElement | null;\n};\n\ntype CardInit = Omit<Card, \"index\" | \"velocity\">;\n\nfunction createGrid() {\n  const time = motionValue(0);\n  // 1 while videos should show over sprites: playing, or paused after playing.\n  const live = motionValue(0);\n  const divider = motionValue(0.5);\n  const cards = new Map<number, Card>();\n  const decoders = new Map<string, Decoder>();\n  const sliders = new Set<HTMLElement>();\n  const settings = { duration: 4, fps: 30, columns: 2, ripple: true, mode: \"grid\" as Mode };\n  const state = {\n    leader: 0,\n    reach: 1, // cells from the leader to the farthest card\n    playing: false,\n    played: false,\n    near: false, // within a screen of the viewport: videos may load\n    changed: 0, // when the clock last moved, other than by playing\n    running: false,\n    announce: 0,\n    emphasized: null as number | null,\n  };\n  const listeners = {\n    play: null as ((playing: boolean) => void) | null,\n    duration: null as ((seconds: number) => void) | null,\n    interrupt: null as (() => void) | null,\n  };\n  let park: HTMLElement | null = null;\n\n  const frames = () => Math.max(1, Math.round(settings.duration * settings.fps));\n\n  const cellOf = (i: number) => ({ x: i % settings.columns, y: Math.floor(i / settings.columns) });\n  const distance = (a: number, b: number) => {\n    const p = cellOf(a);\n    const q = cellOf(b);\n    return Math.hypot(p.x - q.x, p.y - q.y);\n  };\n  const rippleOf = (i: number) => {\n    const { base, step, max } = SYNC.ripple;\n    return base + Math.min(step, (max - base) / state.reach) * distance(i, state.leader);\n  };\n\n  const tick = ({ delta }: { delta: number }) => {\n    const dt = Math.min(delta, 50) / 1000;\n    if (state.playing) time.set((time.get() + dt / settings.duration) % 1);\n    const target = time.get();\n    const exact =\n      state.playing ||\n      !settings.ripple ||\n      settings.mode !== \"grid\" ||\n      performance.now() - state.changed > SYNC.settle * 1000;\n    const half = 0.5 / frames();\n    let moving = state.playing;\n\n    for (const card of cards.values()) {\n      const shown = card.shown.get();\n      if (exact || card.index === state.leader) {\n        card.velocity = 0;\n        if (shown !== target) card.shown.set(target);\n        continue;\n      }\n      // A critically damped spring, stepped exactly, keeping its velocity\n      // however often the target moves.\n      const omega = (2 * Math.PI) / (1.2 * rippleOf(card.index));\n      const decay = Math.exp(-omega * dt);\n      const x0 = shown - target;\n      const c = card.velocity + omega * x0;\n      const x = (x0 + c * dt) * decay;\n      card.velocity = (card.velocity - omega * c * dt) * decay;\n      if (Math.abs(x) < half) {\n        // The last fraction of a frame: land exactly.\n        card.velocity = 0;\n        card.shown.set(target);\n      } else {\n        card.shown.set(target + x);\n        moving = true;\n      }\n    }\n\n    for (const decoder of decoders.values()) decoder.sync();\n    if (!moving && performance.now() - state.changed > SYNC.settle * 1000) stop();\n  };\n\n  const wake = () => {\n    if (state.running) return;\n    state.running = true;\n    frame.update(tick, true);\n  };\n  const stop = () => {\n    state.running = false;\n    cancelFrame(tick);\n  };\n\n  const announce = () => {\n    window.clearTimeout(state.announce);\n    state.announce = window.setTimeout(() => {\n      const seconds = time.get() * settings.duration;\n      for (const slider of sliders) {\n        slider.setAttribute(\"aria-valuenow\", seconds.toFixed(2));\n        slider.setAttribute(\"aria-valuetext\", spoken(seconds, settings.duration));\n      }\n    }, 120);\n  };\n\n  time.on(\"change\", () => {\n    if (!state.playing) {\n      state.changed = performance.now();\n      announce();\n    }\n    wake();\n  });\n\n  const setLeader = (index: number) => {\n    if (state.leader === index) return;\n    state.leader = index;\n    let reach = 1;\n    for (const i of cards.keys()) reach = Math.max(reach, distance(i, index));\n    state.reach = reach;\n    const card = cards.get(index);\n    if (card) card.velocity = 0;\n  };\n\n  const emphasize = (index: number | null) => {\n    if (state.emphasized === index) return;\n    state.emphasized = index;\n    for (const card of cards.values()) {\n      animate(card.lead, card.index === index ? 1 : 0, SYNC.fade);\n    }\n  };\n\n  const setPlaying = (playing: boolean) => {\n    if (state.playing === playing) return;\n    state.playing = playing;\n    if (playing) {\n      state.played = true;\n      live.set(1);\n      if (time.get() >= 1 - 0.5 / frames()) time.jump(0);\n      for (const decoder of decoders.values()) decoder.play();\n      wake();\n    } else {\n      state.changed = performance.now();\n      for (const decoder of decoders.values()) decoder.pause();\n      announce();\n    }\n    listeners.play?.(playing);\n  };\n\n  const grid = {\n    time,\n    live,\n    divider,\n    cards,\n    settings,\n    state,\n    listeners,\n    sliders,\n\n    configure(next: Partial<typeof settings>) {\n      const durationChanged = next.duration !== undefined && next.duration !== settings.duration;\n      Object.assign(settings, next);\n      if (durationChanged) announce();\n      wake();\n    },\n\n    register(index: number, init: CardInit) {\n      const card: Card = { index, velocity: 0, ...init };\n      cards.set(index, card);\n      let reach = 1;\n      for (const i of cards.keys()) reach = Math.max(reach, distance(i, state.leader));\n      state.reach = reach;\n      return () => {\n        if (cards.get(index) === card) cards.delete(index);\n      };\n    },\n\n    distance,\n    setLeader,\n    emphasize,\n\n    /** Sets the clock from a card: it leads. */\n    scrub(index: number, progress: number) {\n      if (state.playing) setPlaying(false);\n      setLeader(index);\n      live.set(0);\n      time.set(clamp(progress, 0, 1));\n    },\n\n    /** Moves `count` frames, landing on a frame boundary. */\n    step(index: number, count: number) {\n      if (state.playing) setPlaying(false);\n      setLeader(index);\n      live.set(0);\n      const total = frames();\n      time.set(clamp(Math.round(time.get() * total) + count, 0, total) / total);\n    },\n\n    setPlaying,\n    toggle() {\n      setPlaying(!state.playing);\n    },\n\n    interrupt() {\n      listeners.interrupt?.();\n    },\n\n    /** Hooks the component's callbacks in; returns a function that unhooks them. */\n    listen(next: Partial<typeof listeners>) {\n      Object.assign(listeners, next);\n      return () => {\n        for (const key of Object.keys(next) as (keyof typeof listeners)[]) {\n          if (listeners[key] === next[key]) listeners[key] = null;\n        }\n      };\n    },\n\n    setPark(element: HTMLElement | null) {\n      park = element;\n    },\n    get park() {\n      return park;\n    },\n\n    setNear(near: boolean) {\n      state.near = near;\n      if (near) for (const decoder of decoders.values()) decoder.load();\n    },\n\n    attach(src: string, consumer: Consumer) {\n      let decoder = decoders.get(src);\n      if (!decoder) {\n        decoder = createDecoder(src, grid);\n        decoders.set(src, decoder);\n      }\n      decoder.attach(consumer);\n      const owner = decoder;\n      return () => {\n        owner.detach(consumer);\n        // Strict mode and quick remounts re-attach straight away; wait a tick\n        // before tearing the video down.\n        window.setTimeout(() => {\n          if (owner.empty() && decoders.get(src) === owner) {\n            owner.dispose();\n            decoders.delete(src);\n          }\n        }, 0);\n      };\n    },\n\n    decoderOf(src: string) {\n      return decoders.get(src) ?? null;\n    },\n\n    reportDuration(seconds: number) {\n      listeners.duration?.(seconds);\n    },\n\n    dispose() {\n      stop();\n      window.clearTimeout(state.announce);\n    },\n  };\n  return grid;\n}\n\ntype Grid = ReturnType<typeof createGrid>;\n\n// ── Video ───────────────────────────────────────────────────\n\ntype Consumer = {\n  index: number;\n  shown: MotionValue<number>;\n  resolution: number;\n  /** Sprite items: the video only loads for playback. */\n  lazy: boolean;\n  holder: HTMLElement;\n  canvas: HTMLCanvasElement;\n  context: CanvasRenderingContext2D | null;\n  drawn: number;\n  onReady: () => void;\n  /** The source's size is known: size the canvas against it. */\n  refit: () => void;\n};\n\ntype Slot = { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D | null; time: number; id: number };\n\ntype VideoFrameVideo = HTMLVideoElement & {\n  requestVideoFrameCallback?: (callback: () => void) => number;\n  cancelVideoFrameCallback?: (handle: number) => void;\n};\n\n/**\n * One `<video>` per source. A source with a single full-resolution card plays\n * in that card; otherwise the video stays hidden and every decoded frame is\n * drawn into each card's canvas. Recent frames are kept in a small ring, so a\n * card that's still catching up shows the frame nearest its own time.\n *\n * Seeks coalesce: one is in flight at a time, and when it lands the next goes\n * straight to the newest target. Only ever draws, never reads pixels, so\n * cross-origin videos are fine.\n */\nfunction createDecoder(src: string, grid: Grid) {\n  const video: VideoFrameVideo = document.createElement(\"video\");\n  video.muted = true;\n  video.playsInline = true;\n  video.preload = \"auto\";\n  video.disablePictureInPicture = true;\n  video.setAttribute(\"aria-hidden\", \"true\");\n  video.setAttribute(\"tabindex\", \"-1\");\n  video.className = \"pointer-events-none absolute inset-0 size-full object-cover\";\n\n  const consumers = new Set<Consumer>();\n  const ring: Slot[] = [];\n  let direct: Consumer | null = null;\n  let loaded = false;\n  let ready = false;\n  let failed = false;\n  let seeking = false;\n  let fastLast = false;\n  let playing = false;\n  let target = 0;\n  let ids = 0;\n  let callback = 0;\n  let lastFrame = -1;\n\n  const length = () => (Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0);\n  const wanted = () => grid.state.near && ([...consumers].some((c) => !c.lazy) || grid.state.played);\n\n  const load = () => {\n    if (loaded || failed || !wanted()) return;\n    loaded = true;\n    video.src = src;\n  };\n\n  const arrange = () => {\n    const only = consumers.size === 1 ? [...consumers][0] : null;\n    direct = only && only.resolution >= 1 ? only : null;\n    for (const consumer of consumers) consumer.canvas.style.display = consumer === direct ? \"none\" : \"\";\n    const home = direct ? direct.holder : grid.park;\n    if (home && video.parentElement !== home) home.appendChild(video);\n    if (!direct) paintAll(true);\n  };\n\n  /** Where the video should be: the clock for shared sources, the card's own time otherwise. */\n  const retarget = () => {\n    const d = length();\n    if (!d) return;\n    const only = consumers.size === 1 ? [...consumers][0] : null;\n    target = (only ? only.shown.get() : grid.time.get()) * d;\n  };\n\n  const seek = () => {\n    if (!loaded || seeking || playing || video.readyState < HTMLMediaElement.HAVE_METADATA) return;\n    const d = length();\n    const to = clamp(target, 0, Math.max(d - 0.001, 0));\n    const off = Math.abs(video.currentTime - to);\n    if (ready && off < 0.5 / grid.settings.fps) return;\n    seeking = true;\n    // fastSeek lands on a keyframe: quick feedback for a long jump, then a\n    // precise seek. Ordinary MP4s put keyframes seconds apart, so it's never\n    // used twice running.\n    const fast = !fastLast && off > 1.5 && typeof video.fastSeek === \"function\";\n    fastLast = fast;\n    if (fast) video.fastSeek(to);\n    else video.currentTime = to;\n  };\n\n  const slotSize = () => {\n    let width = 1;\n    for (const consumer of consumers) {\n      if (consumer === direct) continue;\n      const aspect = video.videoWidth / Math.max(video.videoHeight, 1);\n      width = Math.max(width, consumer.canvas.width, consumer.canvas.height * aspect);\n    }\n    width = Math.min(Math.ceil(width), video.videoWidth || 1);\n    const height = Math.max(1, Math.round((width * video.videoHeight) / Math.max(video.videoWidth, 1)));\n    return { width, height };\n  };\n\n  /** Copies the frame on screen into the ring, then repaints the cards. */\n  const capture = () => {\n    if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;\n    if (!ready) {\n      ready = true;\n      for (const consumer of consumers) consumer.onReady();\n    }\n    if (direct && consumers.size === 1) return;\n    const { width, height } = slotSize();\n    const slot =\n      ring.length >= SYNC.ring\n        ? ring.shift()!\n        : (() => {\n            const canvas = document.createElement(\"canvas\");\n            return { canvas, context: canvas.getContext(\"2d\"), time: 0, id: 0 };\n          })();\n    if (slot.canvas.width !== width || slot.canvas.height !== height) {\n      slot.canvas.width = width;\n      slot.canvas.height = height;\n    }\n    slot.context?.drawImage(video, 0, 0, width, height);\n    slot.time = video.currentTime;\n    slot.id = ++ids;\n    ring.push(slot);\n    paintAll(false);\n  };\n\n  const paint = (consumer: Consumer, force: boolean) => {\n    if (consumer === direct || !ring.length || !consumer.context) return;\n    let best = ring[ring.length - 1];\n    if (!playing) {\n      const t = consumer.shown.get() * length();\n      for (const slot of ring) if (Math.abs(slot.time - t) < Math.abs(best.time - t)) best = slot;\n    }\n    if (!force && best.id === consumer.drawn) return;\n    consumer.drawn = best.id;\n    drawCover(consumer.context, best.canvas, consumer.canvas.width, consumer.canvas.height);\n  };\n\n  const paintAll = (force: boolean) => {\n    for (const consumer of consumers) paint(consumer, force);\n  };\n\n  const onFrame = () => {\n    callback = 0;\n    if (!playing) return;\n    capture();\n    callback = video.requestVideoFrameCallback?.(onFrame) ?? 0;\n  };\n\n  video.addEventListener(\"loadedmetadata\", () => {\n    if (length()) grid.reportDuration(length());\n    for (const consumer of consumers) consumer.refit();\n    retarget();\n    seek();\n  });\n  video.addEventListener(\"loadeddata\", () => {\n    if (!seeking) capture();\n    seek();\n  });\n  video.addEventListener(\"seeked\", () => {\n    seeking = false;\n    capture();\n    retarget();\n    seek();\n  });\n  video.addEventListener(\"error\", () => {\n    failed = true;\n  });\n\n  return {\n    attach(consumer: Consumer) {\n      consumers.add(consumer);\n      arrange();\n      if (ready) consumer.onReady();\n      load();\n    },\n    detach(consumer: Consumer) {\n      consumers.delete(consumer);\n      if (consumers.size) arrange();\n    },\n    empty: () => consumers.size === 0,\n    load,\n    dimensions: () => (video.videoWidth ? { width: video.videoWidth, height: video.videoHeight } : null),\n\n    /** A card's time moved: follow it. */\n    update(consumer: Consumer) {\n      if (playing) return;\n      retarget();\n      seek();\n      paint(consumer, false);\n    },\n\n    /** A card's canvas was resized, which clears it. */\n    resized(consumer: Consumer) {\n      if (!ring.length) return;\n      // Take a fresh, larger copy of the paused frame for the new size.\n      if (!playing && !seeking && ready && Math.abs(ring[ring.length - 1].time - video.currentTime) < 0.001) {\n        const { width } = slotSize();\n        if (ring[ring.length - 1].canvas.width < width) capture();\n      }\n      paint(consumer, true);\n    },\n\n    play() {\n      load();\n      if (!loaded || failed) return;\n      playing = true;\n      fastLast = false;\n      void video.play().catch(() => {});\n      if (video.requestVideoFrameCallback && !callback) callback = video.requestVideoFrameCallback(onFrame);\n    },\n    pause() {\n      if (!playing) return;\n      playing = false;\n      video.pause();\n      if (callback) video.cancelVideoFrameCallback?.(callback);\n      callback = 0;\n      video.playbackRate = 1;\n      retarget();\n      seek();\n    },\n\n    /** Each tick while playing: hold the video to the clock. */\n    sync() {\n      if (!playing || !loaded) return;\n      const d = length();\n      if (!d || video.seeking) return;\n      if (!video.requestVideoFrameCallback && video.currentTime !== lastFrame) {\n        lastFrame = video.currentTime;\n        capture();\n      }\n      const base = d / grid.settings.duration; // percentage matching: a longer clip plays faster\n      const want = grid.time.get() * d;\n      let drift = (video.currentTime - want) / base; // in the clock's seconds\n      const span = grid.settings.duration;\n      if (Math.abs(drift) > span / 2) drift -= Math.sign(drift) * span; // across the loop\n      if (Math.abs(drift) > SYNC.drift.seek) {\n        video.currentTime = want;\n      } else {\n        const nudge =\n          Math.abs(drift) > 1 / grid.settings.fps\n            ? clamp(drift * SYNC.drift.gain, -SYNC.drift.cap, SYNC.drift.cap)\n            : 0;\n        const rate = clamp(base * (1 - nudge), 0.0625, 16);\n        if (Math.abs(video.playbackRate - rate) > 0.001) video.playbackRate = rate;\n      }\n      if (video.paused && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {\n        void video.play().catch(() => {});\n      }\n    },\n\n    dispose() {\n      playing = false;\n      if (callback) video.cancelVideoFrameCallback?.(callback);\n      video.pause();\n      video.removeAttribute(\"src\");\n      video.load();\n      video.remove();\n      ring.length = 0;\n    },\n  };\n}\n\ntype Decoder = ReturnType<typeof createDecoder>;\n\n/** Draws `source` to fill the canvas, cropping the overflow like object-fit: cover. */\nfunction drawCover(\n  context: CanvasRenderingContext2D,\n  source: HTMLCanvasElement,\n  width: number,\n  height: number,\n) {\n  const sw = source.width;\n  const sh = source.height;\n  if (!sw || !sh) return;\n  const scale = Math.max(width / sw, height / sh);\n  const cw = width / scale;\n  const ch = height / scale;\n  context.imageSmoothingQuality = \"high\";\n  context.drawImage(source, (sw - cw) / 2, (sh - ch) / 2, cw, ch, 0, 0, width, height);\n}\n\n// ── Context ─────────────────────────────────────────────────\n\ntype GridContextValue = {\n  grid: Grid;\n  id: string;\n  mode: Mode;\n  active: number;\n  previous: number | null;\n  pair: [number, number] | null;\n  view: View;\n  origin: number;\n  lifted: number | null;\n  armed: number | null;\n  tabStop: number;\n  count: number;\n  columns: number;\n  aspect: number;\n  duration: number;\n  reduce: boolean;\n  comparable: boolean;\n  labels: string[];\n  cardClassName?: string;\n  arm: (index: number | null) => void;\n  compare: (a: number, b: number) => void;\n};\n\nconst GridContext = React.createContext<GridContextValue | null>(null);\nconst IndexContext = React.createContext(-1);\n\nfunction useGridContext() {\n  const context = React.useContext(GridContext);\n  if (!context) throw new Error(\"SyncGridItem must be used inside <SyncGrid>.\");\n  return context;\n}\n\n// ── SyncGrid ────────────────────────────────────────────────\n\nexport interface SyncGridProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onTimeChange\"> {\n  /** Cards per row. */\n  columns?: number;\n  /**\n   * Length of the timeline in seconds. Drives `render` items and playback.\n   * Defaults to the first video's length, or 4 with no video.\n   */\n  duration?: number;\n  /** Frame rate for arrow-key steps and for landing every card on one frame. */\n  fps?: number;\n  /** Width ÷ height of every card. */\n  aspect?: number;\n  /** Followers chase the leader with a delay that grows with distance. Off with reduced motion. */\n  ripple?: boolean;\n  /** Show the Stack button. */\n  stackable?: boolean;\n  /** Allow dropping one card on another to compare them. */\n  comparable?: boolean;\n  /** A ghost cursor sweeps the first card once, the first time the grid is seen. */\n  hint?: boolean;\n  /** Called whenever the clock moves, with the time in seconds and as 0–1. */\n  onTimeChange?: (seconds: number, progress: number) => void;\n  /** Classes for every card. */\n  cardClassName?: string;\n}\n\nexport function SyncGrid({\n  columns = 2,\n  duration,\n  fps = 30,\n  aspect = 16 / 9,\n  ripple = true,\n  stackable = true,\n  comparable = true,\n  hint = true,\n  onTimeChange,\n  cardClassName,\n  className,\n  children,\n  onKeyDown,\n  ...props\n}: SyncGridProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [grid] = React.useState(createGrid);\n  const id = React.useId();\n\n  const items = React.Children.toArray(children).filter(React.isValidElement);\n  const count = items.length;\n  const labels = items.map((item, i) => {\n    const label = (item.props as { label?: unknown }).label;\n    return typeof label === \"string\" ? label : `Card ${i + 1}`;\n  });\n\n  const [mode, setMode] = React.useState<Mode>(\"grid\");\n  const [active, setActive] = React.useState(0);\n  const [previous, setPrevious] = React.useState<number | null>(null);\n  const [pair, setPair] = React.useState<[number, number] | null>(null);\n  const [view, setView] = React.useState<View>(\"split\");\n  const [origin, setOrigin] = React.useState(0);\n  const [lifted, setLifted] = React.useState<number | null>(null);\n  const [armed, setArmed] = React.useState<number | null>(null);\n  const [tabStop, setTabStop] = React.useState(0);\n  const [playing, setPlaying] = React.useState(false);\n  const [videoLength, setVideoLength] = React.useState(0);\n  const total = duration ?? (videoLength || 4);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const parkRef = React.useRef<HTMLDivElement>(null);\n  const timeRef = React.useRef<HTMLSpanElement>(null);\n  const labelsRef = React.useRef<HTMLDivElement>(null);\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const gesture = React.useRef<Gesture | null>(null);\n  const band = React.useRef<number | null>(null);\n  const sizes = React.useRef({ grid: 0, stack: 0, compare: 0, labels: 0 });\n  const onTimeChangeRef = React.useRef(onTimeChange);\n  const swapTimer = React.useRef(0);\n\n  const stageHeight = useMotionValue<number | string>(\"auto\");\n  const stageOpacity = useMotionValue(1);\n\n  React.useLayoutEffect(() => {\n    onTimeChangeRef.current = onTimeChange;\n  });\n\n  React.useLayoutEffect(() => {\n    grid.configure({ duration: total, fps, columns, ripple: ripple && !reduce, mode });\n  }, [grid, total, fps, columns, ripple, reduce, mode]);\n\n  React.useEffect(() => {\n    const unlisten = grid.listen({\n      play: setPlaying,\n      duration: (seconds) => setVideoLength((current) => current || seconds),\n    });\n    return () => {\n      unlisten();\n      grid.setPlaying(false);\n      grid.dispose();\n    };\n  }, [grid]);\n\n  React.useLayoutEffect(() => {\n    grid.setPark(parkRef.current);\n  }, [grid]);\n\n  // The time readout and onTimeChange follow the clock without rendering.\n  React.useEffect(() => {\n    const write = (progress: number) => {\n      const seconds = progress * total;\n      if (timeRef.current) timeRef.current.textContent = formatTime(seconds, total);\n      onTimeChangeRef.current?.(seconds, progress);\n    };\n    write(grid.time.get());\n    return grid.time.on(\"change\", write);\n  }, [grid, total]);\n\n  // Sizes for each mode: the grid's own height, and the one frame the cards\n  // merge into, as large as fits without growing a multi-row grid.\n  React.useLayoutEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const measure = () => {\n      const width = stage.clientWidth;\n      if (!width) return;\n      const gap = parseFloat(getComputedStyle(stage).columnGap) || 0;\n      const rows = Math.max(1, Math.ceil(count / columns));\n      const cell = (width - (columns - 1) * gap) / columns;\n      const gridHeight = rows * (cell / aspect) + (rows - 1) * gap;\n      const room = Math.max(gridHeight, Math.min(width * 0.5, window.innerHeight * 0.6));\n      const labelsWidth = width < 520 ? 104 : 152;\n      const stackWidth = Math.min(width - labelsWidth - gap, (room - FAN_ROOM) * aspect);\n      const compareWidth = Math.min(width, room * aspect);\n      sizes.current = {\n        grid: gridHeight,\n        stack: Math.max(gridHeight, stackWidth / aspect + FAN_ROOM),\n        compare: Math.max(gridHeight, compareWidth / aspect),\n        labels: labelsWidth,\n      };\n      stage.style.setProperty(\"--sg-stack\", `${stackWidth}px`);\n      stage.style.setProperty(\"--sg-compare\", `${compareWidth}px`);\n      stage.style.setProperty(\"--sg-labels\", `${labelsWidth}px`);\n      stageHeight.stop();\n      stageHeight.jump(sizes.current[grid.settings.mode]);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(stage);\n    window.addEventListener(\"resize\", measure);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [grid, count, columns, aspect, stageHeight]);\n\n  // The stage grows or shrinks with the flight between modes.\n  const lastMode = React.useRef(mode);\n  React.useEffect(() => {\n    if (lastMode.current === mode) return;\n    lastMode.current = mode;\n    const height = sizes.current[mode];\n    if (reduce) stageHeight.jump(height);\n    else animate(stageHeight, height, SYNC.fly);\n  }, [mode, reduce, stageHeight]);\n\n  // Load videos once the grid is within a screen of the viewport; pause\n  // playback while it's off screen and pick it up again on the way back.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    let resume = false;\n    const near = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) grid.setNear(true);\n      },\n      { rootMargin: \"100% 0px\" },\n    );\n    const visible = new IntersectionObserver(([entry]) => {\n      if (!entry.isIntersecting && grid.state.playing) {\n        resume = true;\n        grid.setPlaying(false);\n      } else if (entry.isIntersecting && resume) {\n        resume = false;\n        grid.setPlaying(true);\n      }\n    });\n    near.observe(root);\n    visible.observe(root);\n    return () => {\n      near.disconnect();\n      visible.disconnect();\n    };\n  }, [grid]);\n\n  // ── Modes ─────────────────────────────────────────────────\n\n  /** Runs a mode change; with reduced motion, behind a quick fade instead of a flight. */\n  const shift = (apply: () => void) => {\n    if (!reduce) {\n      apply();\n      return;\n    }\n    animate(stageOpacity, 0, { duration: 0.1, ease: \"easeOut\" }).then(() => {\n      apply();\n      animate(stageOpacity, 1, { duration: 0.16, ease: \"easeOut\", delay: 0.03 });\n    });\n  };\n\n  const choose = (index: number) => {\n    if (index === active || index < 0 || index >= count) return;\n    window.clearTimeout(swapTimer.current);\n    setPrevious(active);\n    setActive(index);\n    grid.setLeader(index);\n    // Once the new front has faded in, the old one settles into the fan.\n    swapTimer.current = window.setTimeout(() => setPrevious(null), SYNC.swap.duration * 1000 + 20);\n  };\n\n  const stack = () => {\n    if (count < 2) return;\n    const leader = clamp(grid.state.leader, 0, count - 1);\n    setArmed(null);\n    grid.emphasize(null);\n    shift(() => {\n      setOrigin(leader);\n      setActive(leader);\n      setPrevious(null);\n      setMode(\"stack\");\n    });\n  };\n\n  const compare = (a: number, b: number) => {\n    if (a === b) return;\n    const left = Math.min(a, b);\n    const right = Math.max(a, b);\n    setArmed(null);\n    grid.emphasize(null);\n    grid.divider.jump(reduce ? 0.5 : 0);\n    shift(() => {\n      setOrigin(b);\n      setPair([left, right]);\n      setView(\"split\");\n      setMode(\"compare\");\n    });\n    // The top card lands whole, then the divider opens to show the other.\n    if (!reduce) animate(grid.divider, 0.5, { ...SYNC.wipe, delay: 0.32 });\n  };\n\n  const unstack = () => {\n    const from = mode === \"stack\" ? active : (pair?.[1] ?? 0);\n    grid.divider.jump(0);\n    shift(() => {\n      setOrigin(from);\n      setMode(\"grid\");\n      setPair(null);\n      setPrevious(null);\n    });\n  };\n\n  // ── Pointer ───────────────────────────────────────────────\n\n  const cardAt = (target: EventTarget | null) => {\n    const element = (target as Element | null)?.closest?.(\"[data-sync-card]\");\n    if (!element || !stageRef.current?.contains(element)) return null;\n    return Number(element.getAttribute(\"data-sync-card\"));\n  };\n\n  /** The box time is read from: the card in a grid, the one frame otherwise. */\n  const frameFor = (index: number | null) => {\n    if (mode === \"compare\") return frameRef.current;\n    if (mode === \"stack\") return grid.cards.get(active)?.element ?? null;\n    return index === null ? null : (grid.cards.get(index)?.element ?? null);\n  };\n\n  const progressAt = (element: HTMLElement, clientX: number) => {\n    const rect = element.getBoundingClientRect();\n    const span = Math.max(rect.width - SYNC.inset * 2, 1);\n    return clamp((clientX - rect.left - SYNC.inset) / span, 0, 1);\n  };\n\n  const hover = (event: React.PointerEvent) => {\n    if (grid.state.playing || lifted !== null) return;\n    // Buttons and the variant list are for clicking, not scrubbing.\n    if ((event.target as Element).closest(\"[data-sync-labels], button\")) return;\n    const index = mode === \"grid\" ? cardAt(event.target) : mode === \"stack\" ? active : (pair?.[0] ?? 0);\n    const element = frameFor(index);\n    if (index === null || !element) return;\n    const rect = element.getBoundingClientRect();\n    if (\n      mode !== \"grid\" &&\n      (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)\n    ) {\n      return;\n    }\n    grid.scrub(index, progressAt(element, event.clientX));\n    grid.emphasize(mode === \"compare\" ? null : index);\n\n    // In a stack, height picks the variant, with a little stickiness at the edges.\n    if (mode === \"stack\") {\n      const position = clamp((event.clientY - rect.top) / rect.height, 0, 0.9999) * count;\n      const current = band.current ?? active;\n      const next = Math.floor(position);\n      if (next !== current && Math.abs(position - (current + 0.5)) > 0.5 + SYNC.hysteresis) {\n        band.current = next;\n        choose(next);\n      } else if (band.current === null) {\n        band.current = current;\n      }\n    }\n  };\n\n  const settleLift = (g: Gesture, target: number | null) => {\n    const card = grid.cards.get(g.card);\n    if (g.target !== null) animate(grid.cards.get(g.target)!.glow, 0, SYNC.fade);\n    if (!card) return;\n    animate(card.aim, 0, target === null ? SYNC.lift : SYNC.fly);\n    if (target !== null) {\n      compare(g.card, target);\n      animate(card.dragX, 0, reduce ? SYNC.instant : SYNC.fly);\n      animate(card.dragY, 0, reduce ? SYNC.instant : SYNC.fly);\n      animate(card.lift, 0, SYNC.fly).then(() => setLifted(null));\n      return;\n    }\n    animate(card.dragX, 0, SYNC.lift);\n    animate(card.dragY, 0, SYNC.lift);\n    animate(card.lift, 0, SYNC.lift).then(() => setLifted((current) => (current === g.card ? null : current)));\n  };\n\n  const beginLift = (g: Gesture) => {\n    const card = grid.cards.get(g.card);\n    if (!card) return;\n    g.phase = \"lift\";\n    g.rects = new Map();\n    for (const other of grid.cards.values()) {\n      if (other.index !== g.card && other.element) g.rects.set(other.index, other.element.getBoundingClientRect());\n    }\n    window.clearTimeout(g.timer);\n    setLifted(g.card);\n    grid.emphasize(null);\n    card.dragX.stop();\n    card.dragY.stop();\n    g.offsetX = card.dragX.get();\n    g.offsetY = card.dragY.get();\n    animate(card.lift, 1, SYNC.lift);\n    if (g.type === \"touch\") navigator.vibrate?.(8);\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    grid.interrupt();\n    if (gesture.current || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    if ((event.target as Element).closest(\"button, [data-sync-labels]\")) return;\n    const index = mode === \"grid\" ? cardAt(event.target) : mode === \"stack\" ? active : (pair?.[0] ?? 0);\n    if (index === null) return;\n    const type = event.pointerType === \"touch\" ? \"touch\" : event.pointerType === \"pen\" ? \"pen\" : \"mouse\";\n    const g: Gesture = {\n      id: event.pointerId,\n      type,\n      card: index,\n      startX: event.clientX,\n      startY: event.clientY,\n      offsetX: 0,\n      offsetY: 0,\n      phase: \"press\",\n      from: grid.time.get(),\n      width: 1,\n      timer: 0,\n      rects: new Map(),\n      target: null,\n    };\n    gesture.current = g;\n    const canLift = comparable && mode === \"grid\" && count > 1;\n    if (type === \"touch\" && canLift) {\n      g.timer = window.setTimeout(() => {\n        if (gesture.current === g && g.phase === \"press\") {\n          event.currentTarget?.setPointerCapture?.(g.id);\n          beginLift(g);\n        }\n      }, SYNC.longPress);\n    }\n    if (type !== \"touch\") event.currentTarget.setPointerCapture(event.pointerId);\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    grid.interrupt();\n    const g = gesture.current;\n    if (!g || g.id !== event.pointerId) {\n      if (event.pointerType !== \"touch\" && event.buttons === 0) hover(event);\n      return;\n    }\n    const dx = event.clientX - g.startX;\n    const dy = event.clientY - g.startY;\n\n    if (g.phase === \"press\") {\n      if (g.type === \"touch\") {\n        if (Math.abs(dx) > SYNC.slop && Math.abs(dx) > Math.abs(dy)) {\n          // Sideways: scrub, relative to where the finger went down.\n          window.clearTimeout(g.timer);\n          const element = frameFor(g.card);\n          if (!element) return;\n          g.phase = \"scrub\";\n          g.from = grid.time.get();\n          g.width = Math.max(element.getBoundingClientRect().width - SYNC.inset * 2, 1);\n          g.startX = event.clientX;\n          grid.emphasize(mode === \"grid\" ? g.card : null);\n        } else if (Math.hypot(dx, dy) > SYNC.slop) {\n          // Up or down: the page scrolls.\n          window.clearTimeout(g.timer);\n          gesture.current = null;\n        }\n        return;\n      }\n      if (Math.hypot(dx, dy) < SYNC.press) return;\n      if (comparable && mode === \"grid\" && count > 1) beginLift(g);\n      else {\n        // Nothing to lift: a press and drag scrubs like hovering.\n        g.phase = \"hover\";\n      }\n    }\n\n    if (g.phase === \"hover\") {\n      hover(event);\n      return;\n    }\n\n    if (g.phase === \"scrub\") {\n      grid.scrub(mode === \"grid\" ? g.card : grid.state.leader, g.from + (event.clientX - g.startX) / g.width);\n      return;\n    }\n\n    if (g.phase === \"lift\") {\n      const card = grid.cards.get(g.card);\n      if (!card) return;\n      card.dragX.set(g.offsetX + dx);\n      card.dragY.set(g.offsetY + dy);\n      let target: number | null = null;\n      for (const [index, rect] of g.rects) {\n        if (\n          event.clientX >= rect.left &&\n          event.clientX <= rect.right &&\n          event.clientY >= rect.top &&\n          event.clientY <= rect.bottom\n        ) {\n          target = index;\n        }\n      }\n      if (target !== g.target) {\n        if (g.target !== null) animate(grid.cards.get(g.target)!.glow, 0, SYNC.fade);\n        if (target !== null) animate(grid.cards.get(target)!.glow, 1, SYNC.fade);\n        animate(card.aim, target === null ? 0 : 1, SYNC.lift);\n        g.target = target;\n      }\n    }\n  };\n\n  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    const g = gesture.current;\n    if (!g || g.id !== event.pointerId) return;\n    gesture.current = null;\n    window.clearTimeout(g.timer);\n    const cancelled = event.type === \"pointercancel\";\n    if (g.phase === \"lift\") {\n      settleLift(g, cancelled ? null : g.target);\n      return;\n    }\n    if (g.phase === \"press\" && !cancelled && mode === \"grid\") {\n      // A click: the card takes keyboard focus, and completes a pending compare.\n      if (armed !== null && armed !== g.card) compare(armed, g.card);\n      else surfaceOf(g.card)?.focus({ preventScroll: true });\n    }\n    if (g.type === \"touch\" && g.phase === \"scrub\") grid.emphasize(null);\n  };\n\n  const onPointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"touch\" || gesture.current?.phase === \"lift\") return;\n    band.current = null;\n    // A card with keyboard focus keeps its ring.\n    const focused = document.activeElement?.matches(\":focus-visible\") ? cardAt(document.activeElement) : null;\n    grid.emphasize(mode === \"grid\" ? focused : null);\n  };\n\n  // A lifted card on touch must not scroll the page under it.\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const onTouchMove = (event: TouchEvent) => {\n      if (gesture.current?.phase === \"lift\" && event.cancelable) event.preventDefault();\n    };\n    stage.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    return () => stage.removeEventListener(\"touchmove\", onTouchMove);\n  }, []);\n\n  /** Esc: one step back. Returns whether there was anything to undo. */\n  const back = () => {\n    const g = gesture.current;\n    if (g?.phase === \"lift\") {\n      settleLift(g, null);\n      gesture.current = null;\n      return true;\n    }\n    if (armed !== null) {\n      setArmed(null);\n      return true;\n    }\n    if (mode !== \"grid\") {\n      unstack();\n      return true;\n    }\n    return false;\n  };\n\n  // Esc works while the pointer is over the grid, not only when it has focus.\n  const backRef = React.useRef(back);\n  React.useLayoutEffect(() => {\n    backRef.current = back;\n  });\n  const escapable = mode !== \"grid\" || armed !== null || lifted !== null;\n  React.useEffect(() => {\n    if (!escapable) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.defaultPrevented) return;\n      if (rootRef.current?.contains(event.target as Node)) return; // handled below\n      if (backRef.current()) event.preventDefault();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [escapable]);\n\n  // ── Keyboard ──────────────────────────────────────────────\n\n  const surfaceOf = (index: number) =>\n    grid.cards.get(index)?.element?.querySelector<HTMLElement>(\"[data-sync-surface]\") ?? null;\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    grid.interrupt();\n    const target = event.target as HTMLElement;\n    const key = event.key;\n    if (key === \"Escape\") {\n      if (back()) event.preventDefault();\n      return;\n    }\n    const onButton = target.closest(\"button\") !== null;\n    const onRadio = target.getAttribute(\"role\") === \"radio\";\n    if (onButton && !onRadio && (key === \" \" || key === \"Enter\")) return;\n    if (target.closest(\"[data-sync-divider]\")) return;\n    const focused = cardAt(target);\n    const index =\n      mode === \"grid\" ? (focused ?? grid.state.leader) : mode === \"stack\" ? active : grid.state.leader;\n\n    if (key === \"ArrowLeft\" || key === \"ArrowRight\") {\n      event.preventDefault();\n      const direction = key === \"ArrowLeft\" ? -1 : 1;\n      grid.step(index, direction * (event.shiftKey ? 10 : 1));\n      if (mode === \"grid\") grid.emphasize(focused);\n    } else if (key === \"Home\" || key === \"End\") {\n      event.preventDefault();\n      grid.scrub(index, key === \"Home\" ? 0 : 1);\n    } else if (key === \"ArrowUp\" || key === \"ArrowDown\") {\n      const direction = key === \"ArrowUp\" ? -1 : 1;\n      if (mode === \"stack\") {\n        event.preventDefault();\n        const next = clamp(active + direction, 0, count - 1);\n        choose(next);\n        if (onRadio) (labelsRef.current?.children[next] as HTMLElement | undefined)?.focus();\n      } else if (mode === \"grid\" && focused !== null) {\n        event.preventDefault();\n        const next = clamp(focused + direction, 0, count - 1);\n        setTabStop(next);\n        surfaceOf(next)?.focus();\n      }\n    } else if (key === \" \") {\n      event.preventDefault();\n      grid.toggle();\n    } else if (key === \"Enter\" && armed !== null && focused !== null && focused !== armed) {\n      event.preventDefault();\n      compare(armed, focused);\n    }\n  };\n\n  // ── Hint ──────────────────────────────────────────────────\n\n  const ghostX = useMotionValue(0);\n  const ghostY = useMotionValue(0);\n  const ghostOpacity = useMotionValue(0);\n  const [coarse, setCoarse] = React.useState(false);\n\n  React.useEffect(() => {\n    if (!hint || reduce || count === 0) return;\n    const stage = stageRef.current;\n    if (!stage) return;\n    const run = {\n      timer: 0,\n      sweep: null as AnimationPlaybackControlsWithThen | null,\n      unsubscribe: () => {},\n      done: false,\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        observer.disconnect();\n        run.timer = window.setTimeout(start, 450);\n      },\n      { threshold: 0.6 },\n    );\n\n    const cancel = () => {\n      if (run.done) return;\n      run.done = true;\n      observer.disconnect();\n      window.clearTimeout(run.timer);\n      run.sweep?.stop();\n      run.unsubscribe();\n      animate(ghostOpacity, 0, SYNC.fade);\n    };\n\n    const start = () => {\n      const card = grid.cards.get(0)?.element;\n      if (run.done || !card || grid.settings.mode !== \"grid\" || grid.state.playing) return;\n      setCoarse(window.matchMedia(\"(pointer: coarse)\").matches);\n      const s = stage.getBoundingClientRect();\n      const r = card.getBoundingClientRect();\n      const left = r.left - s.left + SYNC.inset;\n      const width = r.width - SYNC.inset * 2;\n      const at = (share: number) => left + width * share;\n      ghostX.jump(at(0.1));\n      ghostY.jump(r.top - s.top + r.height * 0.58);\n      run.unsubscribe = ghostX.on(\"change\", (x) => grid.scrub(0, (x - left) / width));\n      grid.emphasize(0);\n      animate(ghostOpacity, 1, { duration: 0.25, ease: \"easeOut\" });\n      run.sweep = animate(ghostX, [at(0.1), at(0.92), at(0.58)], {\n        duration: 2.1,\n        times: [0, 0.62, 1],\n        ease: [\"easeInOut\", \"easeInOut\"],\n        delay: 0.25,\n      });\n      run.sweep.then(() => {\n        if (run.done) return;\n        run.done = true;\n        run.unsubscribe();\n        animate(ghostOpacity, 0, { duration: 0.3, ease: \"easeOut\", delay: 0.15 });\n        window.setTimeout(() => grid.emphasize(null), 450);\n      });\n    };\n\n    observer.observe(stage);\n    const unlisten = grid.listen({ interrupt: cancel });\n    return () => {\n      unlisten();\n      cancel();\n    };\n  }, [grid, hint, reduce, count, ghostX, ghostY, ghostOpacity]);\n\n  // ── Render ────────────────────────────────────────────────\n\n  const context: GridContextValue = {\n    grid,\n    id,\n    mode,\n    active,\n    previous,\n    pair,\n    view,\n    origin,\n    lifted,\n    armed,\n    tabStop,\n    count,\n    columns,\n    aspect,\n    duration: total,\n    reduce,\n    comparable,\n    labels,\n    cardClassName,\n    arm: setArmed,\n    compare,\n  };\n\n  const onStageFocus = (event: React.FocusEvent) => {\n    const index = cardAt(event.target);\n    if (index === null || mode !== \"grid\") return;\n    setTabStop(index);\n    grid.setLeader(index);\n    if ((event.target as Element).matches(\":focus-visible\")) grid.emphasize(index);\n  };\n\n  return (\n    <GridContext.Provider value={context}>\n      <div\n        ref={rootRef}\n        role=\"group\"\n        aria-label=\"Synced timeline\"\n        className={cn(\"relative w-full\", className)}\n        onKeyDown={handleKeyDown}\n        {...props}\n      >\n        <motion.div\n          ref={stageRef}\n          className={cn(\n            \"relative isolate grid gap-3 touch-pan-y select-none [-webkit-touch-callout:none]\",\n            mode === \"grid\" ? \"content-start\" : \"content-center\",\n            lifted !== null && \"cursor-grabbing\",\n          )}\n          style={{\n            height: stageHeight,\n            opacity: stageOpacity,\n            gridTemplateColumns: mode === \"grid\" ? `repeat(${columns}, minmax(0, 1fr))` : \"minmax(0, 1fr)\",\n          }}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerUp}\n          onPointerLeave={onPointerLeave}\n          onFocus={onStageFocus}\n          onBlur={(event) => {\n            if (!event.currentTarget.contains(event.relatedTarget as Node)) grid.emphasize(null);\n          }}\n        >\n          {items.map((item, i) => (\n            <IndexContext.Provider key={item.key ?? i} value={i}>\n              {item}\n            </IndexContext.Provider>\n          ))}\n\n          <AnimatePresence>\n            {mode === \"stack\" ? (\n              <motion.div\n                key=\"labels\"\n                ref={labelsRef}\n                data-sync-labels=\"\"\n                role=\"radiogroup\"\n                aria-label=\"Variant in front\"\n                className=\"absolute top-1/2 right-0 z-[60] flex -translate-y-1/2 flex-col gap-0.5\"\n                style={{ width: \"var(--sg-labels)\" }}\n                initial={{ opacity: 0, x: reduce ? 0 : 8 }}\n                animate={{ opacity: 1, x: 0, transition: { ...SYNC.fade, delay: reduce ? 0 : 0.22 } }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                onPointerLeave={() => (band.current = null)}\n              >\n                {labels.map((label, i) => (\n                  <button\n                    key={i}\n                    type=\"button\"\n                    role=\"radio\"\n                    aria-checked={i === active}\n                    tabIndex={i === active ? 0 : -1}\n                    onPointerEnter={(event) => {\n                      if (event.pointerType === \"mouse\") choose(i);\n                    }}\n                    onClick={() => choose(i)}\n                    className={cn(\n                      \"relative flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs outline-none\",\n                      \"transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-foreground\",\n                      i === active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                    )}\n                  >\n                    {i === active ? (\n                      <motion.span\n                        aria-hidden\n                        layoutId={`${id}-variant`}\n                        className=\"absolute inset-0 rounded-lg bg-foreground/[0.07]\"\n                        transition={reduce ? SYNC.instant : SYNC.glide}\n                      />\n                    ) : null}\n                    <span className=\"relative font-mono text-[10px] text-muted-foreground tabular-nums\">\n                      {String(i + 1).padStart(2, \"0\")}\n                    </span>\n                    <span className=\"relative truncate font-medium\">{label}</span>\n                  </button>\n                ))}\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n\n          <AnimatePresence>\n            {mode === \"compare\" && pair ? (\n              <CompareFrame\n                key=\"compare\"\n                ref={frameRef}\n                grid={grid}\n                id={id}\n                left={labels[pair[0]]}\n                right={labels[pair[1]]}\n                view={view}\n                aspect={aspect}\n                duration={total}\n                reduce={reduce}\n              />\n            ) : null}\n          </AnimatePresence>\n\n          {/* Rendered whenever hint is on, so server and client markup match; it only runs without reduced motion. */}\n          {hint ? <Ghost x={ghostX} y={ghostY} opacity={ghostOpacity} coarse={coarse} /> : null}\n        </motion.div>\n\n        <div className=\"mt-3 flex min-h-8 flex-wrap items-center gap-x-3 gap-y-2\">\n          <button\n            type=\"button\"\n            aria-label={playing ? \"Pause\" : \"Play\"}\n            aria-keyshortcuts=\"Space\"\n            className={TOOL}\n            onClick={() => grid.toggle()}\n          >\n            {playing ? <Icon path=\"M7 5v14M17 5v14\" /> : <Icon path=\"M7 4.5v15l12-7.5-12-7.5Z\" fill />}\n            <span className=\"hidden sm:inline\">{playing ? \"Pause\" : \"Play\"}</span>\n          </button>\n          <p className=\"font-mono text-[11px] text-muted-foreground tabular-nums\">\n            <span ref={timeRef} className=\"text-foreground\">\n              {formatTime(0, total)}\n            </span>\n            <span aria-hidden> / {formatTime(total, total)}</span>\n          </p>\n          {armed !== null ? (\n            <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n              Pick a card to compare with <span className=\"text-foreground\">{labels[armed]}</span>\n            </p>\n          ) : null}\n          <div className=\"ml-auto flex items-center gap-2\">\n            {mode === \"compare\" ? (\n              <>\n                <Segmented\n                  id={id}\n                  value={view}\n                  reduce={reduce}\n                  options={[\n                    { value: \"split\", label: \"Split\" },\n                    { value: \"difference\", label: \"Difference\" },\n                  ]}\n                  onChange={(next) => setView(next)}\n                />\n                <button type=\"button\" aria-label=\"Close compare\" className={TOOL} onClick={unstack}>\n                  <Icon path=\"M6 6l12 12M18 6L6 18\" />\n                </button>\n              </>\n            ) : stackable && count > 1 ? (\n              <button\n                type=\"button\"\n                aria-pressed={mode === \"stack\"}\n                className={cn(TOOL, mode === \"stack\" && \"bg-foreground text-background hover:bg-foreground/90\")}\n                onClick={() => (mode === \"stack\" ? unstack() : stack())}\n              >\n                <Icon path=\"M4 8.5 12 4l8 4.5-8 4.5-8-4.5ZM4 12.5l8 4.5 8-4.5M4 16.5l8 4.5 8-4.5\" />\n                {mode === \"stack\" ? \"Unstack\" : \"Stack\"}\n              </button>\n            ) : null}\n          </div>\n        </div>\n\n        <p id={`${id}-keys`} hidden>\n          {KEYS[mode]}\n        </p>\n\n        {/* Shared videos decode here, out of sight, and draw into the cards. */}\n        <div ref={parkRef} aria-hidden className=\"pointer-events-none absolute top-0 left-0 size-px overflow-hidden opacity-0\" />\n      </div>\n    </GridContext.Provider>\n  );\n}\n\ntype Gesture = {\n  id: number;\n  type: \"mouse\" | \"touch\" | \"pen\";\n  card: number;\n  startX: number;\n  startY: number;\n  offsetX: number;\n  offsetY: number;\n  phase: \"press\" | \"hover\" | \"scrub\" | \"lift\";\n  from: number;\n  width: number;\n  timer: number;\n  rects: Map<number, DOMRect>;\n  target: number | null;\n};\n\nconst KEYS: Record<Mode, string> = {\n  grid: \"Left and right arrows step through frames, Shift for ten. Up and down move between cards. Space plays.\",\n  stack: \"Left and right arrows step through frames. Up and down switch the variant in front. Space plays.\",\n  compare: \"Left and right arrows step through frames. Space plays. Escape closes the comparison.\",\n};\n\nconst TOOL = cn(\n  \"inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground/[0.06] px-3 text-xs font-medium text-foreground outline-none\",\n  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-foreground/10 active:scale-[0.97]\",\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n);\n\n// ── SyncGridItem ────────────────────────────────────────────\n\nexport interface SyncGridItemProps {\n  /** Names the card: shown on it, in the stack's list, and to screen readers. */\n  label: string;\n  /**\n   * Draws the card from the clock. Gets this card's progress, 0–1, as a\n   * MotionValue: bind it to `motion` styles or `useTransform`, so it never\n   * renders React per frame.\n   */\n  render?: (progress: MotionValue<number>) => React.ReactNode;\n  /** A video. Matched to the clock by percentage, so lengths can differ. */\n  src?: string;\n  /** Shown until the video's first frame is ready. */\n  poster?: string;\n  /** A sheet of preview frames used while scrubbing. With `src`, the video only loads to play. */\n  sprite?: SyncGridSprite;\n  /** 0–1. Draws the video into a lower-resolution canvas and scales it up, pixelated. */\n  resolution?: number;\n  /** Classes for this card. */\n  className?: string;\n  /** Overlay content, such as stat chips. Position it yourself; it doesn't take pointer events. */\n  children?: React.ReactNode;\n}\n\nexport function SyncGridItem({\n  label,\n  render,\n  src,\n  poster,\n  sprite,\n  resolution = 1,\n  className,\n  children,\n}: SyncGridItemProps) {\n  const {\n    grid,\n    id,\n    mode,\n    active,\n    previous,\n    pair,\n    view,\n    origin,\n    lifted,\n    armed,\n    tabStop,\n    count,\n    aspect,\n    duration,\n    reduce,\n    comparable,\n    labels,\n    cardClassName,\n    arm,\n    compare,\n  } = useGridContext();\n  const index = React.useContext(IndexContext);\n\n  const shown = useMotionValue(0);\n  const lead = useMotionValue(0);\n  const glow = useMotionValue(0);\n  const lift = useMotionValue(0);\n  const dragX = useMotionValue(0);\n  const dragY = useMotionValue(0);\n  const fanX = useMotionValue(0);\n  const fanY = useMotionValue(0);\n  const rotate = useMotionValue(0);\n  const shade = useMotionValue(0);\n  const opacity = useMotionValue(1);\n  const clipped = useMotionValue(0);\n\n  const x = useTransform(() => dragX.get() + fanX.get());\n  const y = useTransform(() => dragY.get() + fanY.get());\n  const aim = useMotionValue(0); // 0–1: lifted and over a card it can drop on\n  const scale = useTransform(() => 1 + 0.04 * lift.get() - 0.07 * aim.get());\n  const liftedOpacity = useTransform(() => opacity.get() * (1 - 0.18 * aim.get()));\n  const clipPath = useTransform(() =>\n    clipped.get() ? `inset(0 0 0 ${(grid.divider.get() * 100).toFixed(3)}%)` : \"none\",\n  );\n\n  const cardRef = React.useRef<HTMLDivElement>(null);\n\n  React.useLayoutEffect(() => {\n    shown.jump(grid.time.get());\n    return grid.register(index, { shown, lead, glow, lift, aim, dragX, dragY, element: cardRef.current });\n  }, [grid, index, shown, lead, glow, lift, aim, dragX, dragY]);\n\n  // Where this card sits: its fan pose in a stack, hidden behind a comparison.\n  const depth = mode !== \"stack\" || index === active ? 0 : index < active ? index + 1 : index;\n  const pose = FAN[Math.min(depth, FAN.length - 1)];\n  const inPair = pair?.includes(index) ?? false;\n  const hidden = (mode === \"compare\" && !inPair) || (mode === \"stack\" && depth >= FAN.length);\n  const isTop = mode === \"compare\" && pair?.[1] === index;\n  const delay = reduce ? 0 : grid.distance(index, origin) * SYNC.stagger;\n  const flight: Transition = reduce ? SYNC.instant : { ...SYNC.fly, delay };\n\n  const seen = React.useRef({ mode, active });\n  React.useEffect(() => {\n    const last = seen.current;\n    seen.current = { mode, active };\n    const modeChanged = last.mode !== mode;\n    const swapped = mode === \"stack\" && !modeChanged && last.active !== active;\n\n    if (swapped && index === active) {\n      // The new front: in place at once, faded in over the old one.\n      for (const value of [fanX, fanY, rotate, shade]) value.stop();\n      fanX.jump(0);\n      fanY.jump(0);\n      rotate.jump(0);\n      shade.jump(0);\n      opacity.jump(0);\n      animate(opacity, 1, SYNC.swap);\n      return;\n    }\n    const spring: Transition = modeChanged\n      ? flight\n      : reduce\n        ? SYNC.instant\n        : { ...SYNC.fan, delay: swapped && index === last.active ? SYNC.swap.duration : 0 };\n    animate(fanX, pose.x, spring);\n    animate(fanY, pose.y, spring);\n    animate(rotate, pose.rotate, spring);\n    animate(shade, pose.shade, spring);\n    animate(opacity, hidden ? 0 : 1, hidden ? SYNC.fade : { ...SYNC.fade, delay: modeChanged ? delay : 0 });\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- pose and flight follow mode and active\n  }, [mode, active, depth, hidden, reduce]);\n\n  React.useEffect(() => {\n    clipped.set(isTop && view === \"split\" ? 1 : 0);\n  }, [clipped, isTop, view]);\n\n  const zIndex =\n    lifted === index\n      ? 50\n      : mode === \"stack\"\n        ? index === active\n          ? count + 2\n          : index === previous\n            ? count + 1\n            : count - depth\n        : mode === \"compare\"\n          ? isTop\n            ? 3\n            : inPair\n              ? 2\n              : 1\n          : 1;\n\n  const kind = render ? \"render\" : sprite ? \"sprite\" : \"video\";\n  const onMedia = kind !== \"render\";\n  const front = mode === \"grid\" || (mode === \"stack\" && index === active);\n  const surfaceTabbable = (mode === \"grid\" && index === tabStop) || (mode === \"stack\" && index === active);\n  const showChrome = mode === \"grid\" || (mode === \"stack\" && depth === 0);\n\n  return (\n    <motion.div\n      ref={cardRef}\n      layout\n      layoutDependency={`${mode}:${pair?.join(\",\") ?? \"\"}`}\n      transition={{ layout: flight }}\n      data-sync-card={index}\n      className={cn(\n        \"group/card relative rounded-xl\",\n        mode === \"stack\" &&\n          \"justify-self-start shadow-[0_1px_2px_rgb(0_0_0/0.06),0_10px_30px_-12px_rgb(0_0_0/0.3)] ring-1 ring-black/[0.06] dark:ring-white/10\",\n        mode === \"compare\" && \"justify-self-center\",\n        mode !== \"grid\" && \"self-center\",\n        isTop && view === \"difference\" && \"mix-blend-difference\",\n        cardClassName,\n        className,\n      )}\n      style={{\n        aspectRatio: aspect,\n        width: mode === \"stack\" ? \"var(--sg-stack)\" : mode === \"compare\" ? \"var(--sg-compare)\" : undefined,\n        marginTop: mode === \"stack\" ? FAN_ROOM : undefined,\n        gridColumn: mode === \"grid\" ? undefined : 1,\n        gridRow: mode === \"grid\" ? undefined : 1,\n        zIndex,\n        x,\n        y,\n        rotate,\n        scale,\n        opacity: liftedOpacity,\n        clipPath,\n      }}\n    >\n      {/* Lifted: a soft shadow under the card. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit] shadow-2xl shadow-black/30\"\n        style={{ opacity: lift }}\n      />\n      {/* A lifted card is over this one. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute -inset-1.5 rounded-[calc(0.75rem+6px)] bg-foreground/[0.06] ring-2 ring-foreground/70\"\n        style={{ opacity: glow }}\n      />\n      {/* Leader. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit] ring-[1.5px] ring-foreground\"\n        style={{ opacity: showChrome ? lead : 0 }}\n      />\n\n      <div\n        data-sync-surface=\"\"\n        role=\"slider\"\n        tabIndex={surfaceTabbable ? 0 : -1}\n        aria-hidden={front ? undefined : true}\n        aria-label={`${label} timeline`}\n        aria-valuemin={0}\n        aria-valuemax={round(duration, 2)}\n        aria-valuenow={0}\n        aria-valuetext={spoken(0, duration)}\n        aria-keyshortcuts=\"ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight Space\"\n        aria-describedby={`${id}-keys`}\n        ref={(element) => {\n          if (!element) return;\n          grid.sliders.add(element);\n          return () => {\n            grid.sliders.delete(element);\n          };\n        }}\n        className={cn(\n          \"absolute inset-0 overflow-hidden rounded-[inherit] bg-muted outline-none\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          lifted === index ? \"cursor-grabbing\" : \"cursor-col-resize\",\n        )}\n      >\n        {render ? (\n          <div className=\"absolute inset-0\">{render(shown)}</div>\n        ) : sprite ? (\n          <SpriteMedia\n            sprite={sprite}\n            shown={shown}\n            src={src}\n            poster={poster}\n            index={index}\n            resolution={resolution}\n          />\n        ) : src ? (\n          <VideoMedia src={src} poster={poster} index={index} shown={shown} resolution={resolution} />\n        ) : null}\n\n        {/* Behind the front of a stack. */}\n        <motion.div aria-hidden className=\"pointer-events-none absolute inset-0 bg-black\" style={{ opacity: shade }} />\n\n        {children ? (\n          <div\n            className={cn(\n              \"pointer-events-none absolute inset-0 transition-opacity duration-200\",\n              (mode === \"stack\" && depth > 0) || (mode === \"compare\" && view === \"difference\") ? \"opacity-0\" : \"opacity-100\",\n            )}\n          >\n            {children}\n          </div>\n        ) : null}\n\n        <div\n          className={cn(\n            \"pointer-events-none absolute inset-0 transition-opacity duration-200\",\n            onMedia ? \"text-white\" : \"text-foreground\",\n            showChrome ? \"opacity-100\" : \"opacity-0\",\n          )}\n        >\n          {onMedia ? (\n            <div aria-hidden className=\"absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/45 to-transparent\" />\n          ) : null}\n          <span className=\"absolute top-2.5 left-2.5 max-w-[70%] truncate rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-sm ring-1 ring-foreground/5 backdrop-blur-md\">\n            {label}\n          </span>\n          <Timeline shown={shown} lead={lead} duration={duration} />\n        </div>\n      </div>\n\n      {comparable && mode === \"grid\" && count > 1 ? (\n        <CompareButton\n          label={label}\n          armedLabel={armed === null ? null : labels[armed]}\n          state={armed === null ? \"idle\" : armed === index ? \"armed\" : \"target\"}\n          tabbable={index === tabStop || armed !== null}\n          onPress={() => {\n            if (armed === null) arm(index);\n            else if (armed === index) arm(null);\n            else compare(armed, index);\n          }}\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n\nfunction CompareButton({\n  label,\n  armedLabel,\n  state,\n  tabbable,\n  onPress,\n}: {\n  label: string;\n  armedLabel: string | null;\n  state: \"idle\" | \"armed\" | \"target\";\n  tabbable: boolean;\n  onPress: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      tabIndex={tabbable ? 0 : -1}\n      aria-label={\n        state === \"idle\"\n          ? `Compare ${label} with another card`\n          : state === \"armed\"\n            ? \"Cancel compare\"\n            : `Compare ${armedLabel} with ${label}`\n      }\n      aria-pressed={state === \"armed\"}\n      onClick={onPress}\n      className={cn(\n        \"absolute top-2 right-2 z-10 inline-flex h-7 items-center gap-1 rounded-full px-2 text-[11px] font-medium outline-none backdrop-blur-md\",\n        \"transition-[opacity,background-color,scale] duration-150 ease-out active:scale-95\",\n        \"focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        state === \"armed\"\n          ? \"bg-foreground text-background opacity-100\"\n          : \"bg-background/80 text-foreground shadow-sm ring-1 ring-foreground/5 hover:bg-background\",\n        state === \"idle\" && \"opacity-0 group-hover/card:opacity-100 pointer-coarse:hidden\",\n        state === \"target\" && \"opacity-100\",\n      )}\n    >\n      <Icon path=\"M12 4v16M5 7h4M5 12h4M5 17h4M15 7h4M15 12h4M15 17h4\" small />\n      {state === \"idle\" ? <span className=\"sr-only\">Compare</span> : state === \"armed\" ? \"Cancel\" : \"Compare\"}\n    </button>\n  );\n}\n\n// ── Media ───────────────────────────────────────────────────\n\nfunction VideoMedia({\n  src,\n  poster,\n  index,\n  shown,\n  resolution,\n  lazy = false,\n}: {\n  src: string;\n  poster?: string;\n  index: number;\n  shown: MotionValue<number>;\n  resolution: number;\n  lazy?: boolean;\n}) {\n  const { grid } = useGridContext();\n  const holderRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const posterRef = React.useRef<HTMLImageElement>(null);\n  const res = clamp(resolution, 0.01, 1);\n\n  React.useLayoutEffect(() => {\n    const holder = holderRef.current;\n    const canvas = canvasRef.current;\n    if (!holder || !canvas) return;\n    const consumer: Consumer = {\n      index,\n      shown,\n      resolution: res,\n      lazy,\n      holder,\n      canvas,\n      context: canvas.getContext(\"2d\"),\n      drawn: -1,\n      onReady: () => {\n        const image = posterRef.current;\n        if (image && image.style.opacity !== \"0\") animate(image, { opacity: 0 }, { duration: 0.2, ease: \"easeOut\" });\n      },\n      refit: () => {\n        if (size()) grid.decoderOf(src)?.resized(consumer);\n      },\n    };\n    // Layout size, not the transformed one: flights scale the card. Below\n    // full resolution, the canvas is that share of the source's own pixels\n    // (1/8 of 1280 × 720 is 160 × 90) on any screen.\n    const size = () => {\n      const width = holder.offsetWidth;\n      const height = holder.offsetHeight;\n      if (!width || !height) return false;\n      const dpr = window.devicePixelRatio || 1;\n      const source = grid.decoderOf(src)?.dimensions();\n      const visible = source ? Math.min(source.width, source.height * (width / height)) : width * dpr;\n      const pixels = Math.max(1, Math.round(Math.min(width * dpr, visible * res)));\n      const rows = Math.max(1, Math.round((pixels * height) / width));\n      if (canvas.width === pixels && canvas.height === rows) return false;\n      canvas.width = pixels;\n      canvas.height = rows;\n      return true;\n    };\n    size();\n    const detach = grid.attach(src, consumer);\n    const decoder = () => grid.decoderOf(src);\n    const observer = new ResizeObserver(() => {\n      if (size()) decoder()?.resized(consumer);\n    });\n    observer.observe(holder);\n    const unsubscribe = shown.on(\"change\", () => decoder()?.update(consumer));\n    return () => {\n      observer.disconnect();\n      unsubscribe();\n      detach();\n    };\n  }, [grid, src, index, shown, res, lazy]);\n\n  return (\n    <>\n      <div ref={holderRef} className=\"absolute inset-0\" />\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className={cn(\"absolute inset-0 size-full\", res < 1 && \"[image-rendering:pixelated]\")}\n      />\n      {poster ? (\n        // eslint-disable-next-line @next/next/no-img-element -- a remote poster in an installable component\n        <img\n          ref={posterRef}\n          src={poster}\n          alt=\"\"\n          aria-hidden\n          draggable={false}\n          decoding=\"async\"\n          className={cn(\"absolute inset-0 size-full object-cover\", res < 1 && \"[image-rendering:pixelated]\")}\n        />\n      ) : null}\n    </>\n  );\n}\n\nfunction SpriteMedia({\n  sprite,\n  shown,\n  src,\n  poster,\n  index,\n  resolution,\n}: {\n  sprite: SyncGridSprite;\n  shown: MotionValue<number>;\n  src?: string;\n  poster?: string;\n  index: number;\n  resolution: number;\n}) {\n  const { grid } = useGridContext();\n  const sheetRef = React.useRef<HTMLDivElement>(null);\n  const { url, columns, rows } = sprite;\n  const frames = Math.max(1, Math.min(sprite.frames ?? columns * rows, columns * rows));\n\n  React.useEffect(() => {\n    const sheet = sheetRef.current;\n    if (!sheet) return;\n    let last = -1;\n    const place = (progress: number) => {\n      const index = Math.round(clamp(progress, 0, 1) * (frames - 1));\n      if (index === last) return;\n      last = index;\n      const column = index % columns;\n      const row = Math.floor(index / columns);\n      const x = columns > 1 ? (column / (columns - 1)) * 100 : 0;\n      const y = rows > 1 ? (row / (rows - 1)) * 100 : 0;\n      sheet.style.backgroundPosition = `${x}% ${y}%`;\n    };\n    place(shown.get());\n    return shown.on(\"change\", place);\n  }, [shown, frames, columns, rows]);\n\n  return (\n    <>\n      <div\n        ref={sheetRef}\n        aria-hidden\n        className=\"absolute inset-0 bg-no-repeat\"\n        style={{ backgroundImage: `url(\"${url}\")`, backgroundSize: `${columns * 100}% ${rows * 100}%` }}\n      />\n      {src ? (\n        // The video takes over while playing, and keeps its frame once paused.\n        <motion.div className=\"absolute inset-0\" style={{ opacity: grid.live }}>\n          <VideoMedia src={src} poster={poster} index={index} shown={shown} resolution={resolution} lazy />\n        </motion.div>\n      ) : null}\n    </>\n  );\n}\n\n// ── Pieces ──────────────────────────────────────────────────\n\n/** The line along a card's bottom edge, with its playhead and, on the leader, the time. */\nfunction Timeline({\n  shown,\n  lead,\n  duration,\n}: {\n  shown: MotionValue<number>;\n  lead: MotionValue<number>;\n  duration: number;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const chipRef = React.useRef<HTMLSpanElement>(null);\n  const width = useMotionValue(0);\n  const head = useTransform(shown, (v) => `${v * 100}%`);\n  const thickness = useTransform(lead, [0, 1], [0.5, 1]);\n  const chipX = useTransform(() => {\n    const w = width.get();\n    return clamp(shown.get() * w, 22, Math.max(w - 22, 22));\n  });\n\n  React.useLayoutEffect(() => {\n    const element = ref.current;\n    if (!element) return;\n    const observer = new ResizeObserver(() => width.set(element.offsetWidth));\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [width]);\n\n  React.useEffect(() => {\n    const write = (progress: number) => {\n      if (chipRef.current) chipRef.current.textContent = formatTime(progress * duration, duration);\n    };\n    write(shown.get());\n    return shown.on(\"change\", write);\n  }, [shown, duration]);\n\n  return (\n    <div ref={ref} aria-hidden className=\"absolute inset-x-3 bottom-2.5 h-2\">\n      <motion.div className=\"absolute inset-x-0 bottom-0 h-0.5 origin-bottom\" style={{ scaleY: thickness }}>\n        <div className=\"absolute inset-0 rounded-full bg-current opacity-25\" />\n        <motion.div className=\"absolute inset-0 origin-left rounded-full bg-current\" style={{ scaleX: shown }} />\n      </motion.div>\n      <motion.div className=\"absolute inset-0\" style={{ x: head }}>\n        <div className=\"absolute bottom-[-3px] left-0 h-2 w-0.5 -translate-x-1/2 rounded-full bg-current\" />\n      </motion.div>\n      <motion.span\n        ref={chipRef}\n        className=\"absolute bottom-3 left-0 min-w-11 -translate-x-1/2 rounded-full bg-foreground px-1.5 py-0.5 text-center font-mono text-[10px] leading-none text-background tabular-nums shadow-sm\"\n        style={{ x: chipX, opacity: lead }}\n      />\n    </div>\n  );\n}\n\nconst CompareFrame = React.forwardRef<\n  HTMLDivElement,\n  {\n    grid: Grid;\n    id: string;\n    left: string;\n    right: string;\n    view: View;\n    aspect: number;\n    duration: number;\n    reduce: boolean;\n  }\n>(function CompareFrame({ grid, id, left, right, view, aspect, duration, reduce }, ref) {\n  const head = useTransform(grid.divider, (v) => `${v * 100}%`);\n  const one = useMotionValue(1);\n  const shown = useMotionValue(grid.time.get());\n  const sliderRef = React.useRef<HTMLDivElement>(null);\n  const handleRef = React.useRef<HTMLButtonElement>(null);\n  const dragging = React.useRef<number | null>(null);\n\n  React.useEffect(() => {\n    shown.jump(grid.time.get());\n    return grid.time.on(\"change\", (v) => shown.set(v));\n  }, [grid, shown]);\n\n  React.useEffect(() => {\n    const slider = sliderRef.current;\n    if (!slider) return;\n    grid.sliders.add(slider);\n    if (slider.closest(\"[role=group]\")?.contains(document.activeElement)) slider.focus({ preventScroll: true });\n    return () => {\n      grid.sliders.delete(slider);\n    };\n  }, [grid]);\n\n  React.useEffect(() => {\n    const handle = handleRef.current;\n    if (!handle) return;\n    const write = (v: number) => {\n      handle.setAttribute(\"aria-valuenow\", String(Math.round(v * 100)));\n      handle.setAttribute(\"aria-valuetext\", `${Math.round(v * 100)}% ${left}`);\n    };\n    write(grid.divider.get());\n    return grid.divider.on(\"change\", write);\n  }, [grid, left, view]);\n\n  const place = (event: React.PointerEvent<HTMLElement>) => {\n    const box = sliderRef.current?.getBoundingClientRect();\n    if (!box) return;\n    grid.divider.stop();\n    grid.divider.set(clamp((event.clientX - box.left) / box.width, 0.02, 0.98));\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      data-sync-frame=\"\"\n      className=\"pointer-events-none absolute top-1/2 left-1/2 z-[60] -translate-x-1/2 -translate-y-1/2\"\n      style={{ width: \"var(--sg-compare)\", aspectRatio: aspect }}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1, transition: { ...SYNC.fade, delay: reduce ? 0 : 0.3 } }}\n      exit={{ opacity: 0, transition: { duration: 0.08 } }}\n    >\n      <div\n        ref={sliderRef}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={`${left} and ${right} timeline`}\n        aria-valuemin={0}\n        aria-valuemax={round(duration, 2)}\n        aria-valuenow={round(grid.time.get() * duration, 2)}\n        aria-valuetext={spoken(grid.time.get() * duration, duration)}\n        aria-describedby={`${id}-keys`}\n        className=\"pointer-events-auto absolute inset-0 cursor-col-resize rounded-xl outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n      />\n      <span className={cn(CHIP, \"top-2.5 left-2.5\")}>{left}</span>\n      <span className={cn(CHIP, \"top-2.5 right-2.5\")}>{right}</span>\n\n      {view === \"split\" ? (\n        <motion.div data-sync-divider=\"\" className=\"pointer-events-none absolute inset-0\" style={{ x: head }}>\n          <div className=\"absolute inset-y-0 left-0 w-0.5 -translate-x-1/2 bg-white shadow-[0_0_0_0.5px_rgb(0_0_0/0.2),0_0_12px_rgb(0_0_0/0.25)]\" />\n          <button\n            ref={handleRef}\n            type=\"button\"\n            role=\"slider\"\n            aria-label=\"Divider\"\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={50}\n            aria-orientation=\"horizontal\"\n            className=\"pointer-events-auto absolute top-1/2 left-0 grid size-8 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none place-items-center rounded-full bg-white text-zinc-900 shadow-lg ring-1 ring-black/10 outline-none transition-[scale] duration-150 active:scale-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n            onPointerDown={(event) => {\n              event.stopPropagation();\n              dragging.current = event.pointerId;\n              event.currentTarget.setPointerCapture(event.pointerId);\n            }}\n            onPointerMove={(event) => {\n              if (dragging.current !== event.pointerId) return;\n              event.stopPropagation();\n              place(event);\n            }}\n            onPointerUp={(event) => {\n              if (dragging.current === event.pointerId) dragging.current = null;\n            }}\n            onPointerCancel={() => (dragging.current = null)}\n            onKeyDown={(event) => {\n              const step = event.shiftKey ? 0.1 : 0.02;\n              const moves: Record<string, number> = {\n                ArrowLeft: -step,\n                ArrowRight: step,\n                ArrowDown: -step,\n                ArrowUp: step,\n              };\n              if (event.key in moves) {\n                event.preventDefault();\n                grid.divider.stop();\n                grid.divider.set(clamp(grid.divider.get() + moves[event.key], 0.02, 0.98));\n              } else if (event.key === \"Home\" || event.key === \"End\") {\n                event.preventDefault();\n                grid.divider.set(event.key === \"Home\" ? 0.02 : 0.98);\n              }\n            }}\n          >\n            <Icon path=\"M9 7l-4 5 4 5M15 7l4 5-4 5\" small />\n          </button>\n        </motion.div>\n      ) : null}\n\n      <div className=\"pointer-events-none absolute inset-0 text-white\">\n        <div aria-hidden className=\"absolute inset-x-0 bottom-0 h-12 rounded-b-xl bg-gradient-to-t from-black/45 to-transparent\" />\n        <Timeline shown={shown} lead={one} duration={duration} />\n      </div>\n    </motion.div>\n  );\n});\n\nconst CHIP =\n  \"pointer-events-none absolute max-w-[45%] truncate rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-sm ring-1 ring-foreground/5 backdrop-blur-md\";\n\nfunction Segmented<T extends string>({\n  id,\n  value,\n  options,\n  reduce,\n  onChange,\n}: {\n  id: string;\n  value: T;\n  options: { value: T; label: string }[];\n  reduce: boolean;\n  onChange: (value: T) => void;\n}) {\n  return (\n    <div role=\"radiogroup\" aria-label=\"Compare view\" className=\"flex h-8 items-center rounded-full bg-foreground/[0.06] p-0.5\">\n      {options.map((option) => {\n        const checked = option.value === value;\n        return (\n          <button\n            key={option.value}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            tabIndex={checked ? 0 : -1}\n            onClick={() => onChange(option.value)}\n            onKeyDown={(event) => {\n              if (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\") return;\n              event.preventDefault();\n              event.stopPropagation();\n              const i = options.findIndex((o) => o.value === value);\n              const next = options[(i + (event.key === \"ArrowLeft\" ? -1 : 1) + options.length) % options.length];\n              onChange(next.value);\n              (event.currentTarget.parentElement?.children[options.indexOf(next)] as HTMLElement | undefined)?.focus();\n            }}\n            className={cn(\n              \"relative h-7 rounded-full px-3 text-xs font-medium outline-none transition-colors duration-150\",\n              \"focus-visible:outline-2 focus-visible:outline-foreground\",\n              checked ? \"text-background\" : \"text-muted-foreground hover:text-foreground\",\n            )}\n          >\n            {checked ? (\n              <motion.span\n                aria-hidden\n                layoutId={`${id}-view`}\n                className=\"absolute inset-0 rounded-full bg-foreground\"\n                transition={reduce ? SYNC.instant : SYNC.glide}\n              />\n            ) : null}\n            <span className=\"relative\">{option.label}</span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/** The first-run hint: a cursor that sweeps the first card. */\nfunction Ghost({\n  x,\n  y,\n  opacity,\n  coarse,\n}: {\n  x: MotionValue<number>;\n  y: MotionValue<number>;\n  opacity: MotionValue<number>;\n  coarse: boolean;\n}) {\n  return (\n    <motion.div aria-hidden className=\"pointer-events-none absolute top-0 left-0 z-[70]\" style={{ x, y, opacity }}>\n      {coarse ? (\n        <div className=\"size-7 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/70 shadow-lg ring-1 ring-black/10 backdrop-blur-sm\" />\n      ) : (\n        <svg viewBox=\"0 0 24 24\" className=\"size-5 -translate-x-[3px] -translate-y-[2px] drop-shadow-md\">\n          <path\n            d=\"M4 2.5v16.2l4.3-4.1 2.8 6.3 2.7-1.2-2.8-6.2h6L4 2.5Z\"\n            fill=\"white\"\n            stroke=\"black\"\n            strokeWidth=\"1.2\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      )}\n    </motion.div>\n  );\n}\n\nfunction Icon({ path, fill, small }: { path: string; fill?: boolean; small?: boolean }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 24 24\"\n      className={small ? \"size-3.5\" : \"size-4\"}\n      fill={fill ? \"currentColor\" : \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth={fill ? 0 : 1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d={path} />\n    </svg>\n  );\n}\n\n// ── Helpers ─────────────────────────────────────────────────\n\nfunction formatTime(seconds: number, total: number) {\n  if (total >= 60) {\n    const minutes = Math.floor(seconds / 60);\n    return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, \"0\")}`;\n  }\n  return `${seconds.toFixed(2)}s`;\n}\n\n/** \"2.4 of 6 seconds\" */\nfunction spoken(seconds: number, total: number) {\n  return `${round(seconds, 1)} of ${round(total, 1)} seconds`;\n}\n\nfunction round(value: number, digits: number) {\n  const f = 10 ** digits;\n  return Math.round(value * f) / f;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n"
    }
  ]
}