{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spotlight-tour",
  "type": "registry:ui",
  "title": "Spotlight Tour",
  "description": "A guided tour that dims the page and cuts a hole around one element at a time. The hole glides to each next element and takes its shape, with the explanation card riding along beside it.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/spotlight-tour.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  useMotionValue,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * SPOTLIGHT TOUR STORYBOARD\n *\n *  open      the page dims, then a hole irises open out of the\n *            dark around the first element and the card rises\n *            in beside it\n *  next      the hole glides to the next element on a spring\n *            and takes its shape: round for an avatar, a long\n *            pill for a toolbar, a rounded panel for a sidebar.\n *            The card rides along, moving to another side when\n *            it has to, and its text slides over to the new step\n *  scroll    an element out of view scrolls in on the same\n *            spring the hole travels on, so it's one motion\n *  arrive    one soft ring pulses out from the hole\n *  redirect  press again mid-flight and it bends toward the new\n *            element without stopping\n *  stay      scrolling, resizing and layout shifts keep the hole\n *            on its element\n *  finish    the hole widens past the edges of the screen as the\n *            dark lifts, and the card fades away\n * ───────────────────────────────────────────────────────── */\n\nconst TOUR = {\n  open: { type: \"spring\", visualDuration: 0.5, bounce: 0.2, delay: 0.12 }, // the iris on the first step\n  travel: { type: \"spring\", visualDuration: 0.5, bounce: 0.14 },\n  close: { type: \"spring\", visualDuration: 0.6, bounce: 0 },\n  resize: { type: \"spring\", visualDuration: 0.32, bounce: 0.04 }, // the card's height following its text\n  dots: { type: \"spring\", visualDuration: 0.4, bounce: 0.24 },\n  pulse: { duration: 0.9, ease: [0.22, 1, 0.36, 1] },\n  dimIn: { duration: 0.3, ease: \"easeOut\" },\n  dimOut: { duration: 0.45, ease: [0.55, 0, 0.75, 0.4] }, // lingers, then lifts, so the iris reads\n  cardIn: { duration: 0.32, ease: [0.22, 1, 0.36, 1], delay: 0.2 },\n  fadeOut: { duration: 0.16, ease: \"easeOut\" },\n  padding: 8, // px between an element and the edge of its hole\n  gap: 14, // px between the hole and the card; the arrow sits in it\n  margin: 12, // px kept clear of the viewport edges\n  wait: 1500, // ms to wait for a step's element to appear\n} as const;\n\nconst DIM = \"bg-black/50 dark:bg-black/65\";\n\nexport interface SpotlightTourStep {\n  /** The element to highlight: a CSS selector, an element or a ref. Leave it out for a step with no highlight. */\n  target?: string | Element | React.RefObject<Element | null> | null;\n  title: React.ReactNode;\n  body?: React.ReactNode;\n  /** Side of the element the card sits on. Defaults to the side with the most room. */\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** Room between the element and the edge of the hole, in px. */\n  padding?: number;\n  /** Corner radius of the hole, in px. Defaults to the element's own, plus the padding. */\n  radius?: number;\n  /** Let people click and type inside the hole. */\n  interactive?: boolean;\n  /** Go to the next step when the element is clicked. The click still reaches it. */\n  advanceOnClick?: boolean;\n}\n\nexport interface SpotlightTourProps {\n  steps: SpotlightTourStep[];\n  open: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** The current step, to control it yourself. Otherwise the tour starts at the first step each time it opens. */\n  step?: number;\n  onStepChange?: (step: number) => void;\n  /** Called when Done is pressed on the last step, not when the tour is skipped. */\n  onComplete?: () => void;\n  /** Default room around each element, in px. */\n  padding?: number;\n  /** Classes for the card. */\n  className?: string;\n  /** Classes for the dimmed layer, e.g. its color. */\n  overlayClassName?: string;\n}\n\ntype Side = NonNullable<SpotlightTourStep[\"side\"]>;\ntype Placement = Side | \"center\";\ntype Point = { x: number; y: number };\ntype Size = { width: number; height: number };\ntype Radii = [number, number, number, number]; // top-left, top-right, bottom-right, bottom-left\ntype Hole = Point & Size & { radii: Radii };\ntype Corner = { px: number; pct: number };\ntype ScrollMove = { element: Element; axis: \"x\" | \"y\"; from: number; to: number };\ntype Thenable = { then: (onResolve: () => void) => unknown };\ntype View = Size & { bottom: number }; // `bottom` is the visible bottom edge, above an on-screen keyboard\ntype Flight = {\n  // Where the trip started. The start rides along with the element it\n  // left, so a page scrolling under it carries it too.\n  anchor: Element | null;\n  anchorAt: Point;\n  shift: Point;\n  hole: Hole;\n  card: Point | null; // null: the card starts where it ends\n  side: Placement;\n};\n\nconst ORIGIN: Point = { x: 0, y: 0 };\n// Covers the screen. The hole is cut out of it with the even-odd rule.\nconst OUTSIDE = \"M-99999 -99999H99999V99999H-99999Z\";\n\nexport function SpotlightTour({\n  steps,\n  open,\n  onOpenChange,\n  step,\n  onStepChange,\n  onComplete,\n  padding = TOUR.padding,\n  className,\n  overlayClassName,\n}: SpotlightTourProps) {\n  const id = React.useId();\n  const reduceMotion = useReducedMotion() ?? false;\n  const [mounted, setMounted] = React.useState(false);\n  const [ownIndex, setOwnIndex] = React.useState(0);\n  const count = steps.length;\n  const index = clamp(step ?? ownIndex, 0, Math.max(count - 1, 0));\n  // Which way the text slides: forward or back.\n  const [paged, setPaged] = React.useState({ index, direction: 1 });\n  if (paged.index !== index) setPaged({ index, direction: index > paged.index ? 1 : -1 });\n  const direction = paged.direction;\n  const current = steps[index];\n  const last = index === count - 1;\n  const passThrough = passThroughFor(current);\n\n  const layerRef = React.useRef<HTMLDivElement>(null);\n  const dimRef = React.useRef<HTMLDivElement>(null);\n  const plugRef = React.useRef<HTMLDivElement>(null);\n  const blockerRef = React.useRef<HTMLDivElement>(null);\n  const outlineRef = React.useRef<HTMLDivElement>(null);\n  const ringRef = React.useRef<HTMLDivElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const arrowRef = React.useRef<SVGSVGElement>(null);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n\n  const live = React.useRef({\n    phase: \"closed\" as \"closed\" | \"open\" | \"closing\",\n    index: 0,\n    started: false, // the first trip, which irises open, has begun\n    trip: 0, // bumps with every trip, so a stale trip's callbacks do nothing\n    generation: 0, // bumps on every open and close\n    target: null as Element | null,\n    corners: null as Corner[] | null,\n    waiting: 0, // when we began looking for the step's element; 0 when not looking\n    flight: null as Flight | null,\n    side: \"center\" as Placement, // the card's side for this step\n    drawnSide: \"center\" as Placement, // the side its arrow is on right now\n    hole: zeroHole(ORIGIN),\n    lastEnd: null as Hole | null,\n    exit: null as Hole | null,\n    card: ORIGIN,\n    drive: [] as ScrollMove[],\n    arrived: false,\n    resting: false,\n    bodyTarget: 0,\n    chrome: 0, // the card's height without its text\n    velocity: [0, 0, 0, 0], // the hole's x, y, width and height speeds, px/s\n    time: 0,\n    reduce: false,\n    wide: null as MediaQueryList | null,\n    returnFocus: null as HTMLElement | null,\n  });\n\n  const progress = useMotionValue(0); // 0 where a trip starts, 1 where it ends\n  const veil = useMotionValue(0); // the dim\n  const shown = useMotionValue(0); // the card\n  const plug = useMotionValue(0); // fills the hole, to fade between steps with reduced motion\n  const pulse = useMotionValue(0);\n  const bodyHeight = useMotionValue(0);\n\n  // ── Trips ───────────────────────────────────────────────\n\n  // A trip that ran its course: stop driving the scroll.\n  const settle = (run: Thenable, trip: number) => {\n    run.then(() => {\n      const s = live.current;\n      if (s.trip !== trip) return;\n      s.drive = [];\n      s.resting = true;\n    });\n  };\n\n  const depart = (target: Element | null, options: { quiet?: boolean; scroll?: boolean } = {}) => {\n    const s = live.current;\n    const config = steps[s.index];\n    if (!config) return;\n    const view = viewport();\n    const docked = !s.wide?.matches;\n    const opening = !s.started;\n    const trip = ++s.trip;\n    const previous = s.target;\n    const pad = config.padding ?? padding;\n    s.started = true;\n    s.waiting = 0;\n    s.target = target;\n    s.corners = target ? readCorners(target) : null;\n    s.lastEnd = null;\n    s.arrived = !!options.quiet;\n    s.resting = false;\n\n    // Bring the element into view, clear of the card when it's docked.\n    const cardHeight = s.chrome + s.bodyTarget;\n    const moves =\n      target && options.scroll !== false\n        ? planScroll(target, pad, docked ? cardHeight + TOUR.margin * 2 : 0, view)\n        : [];\n    const offset = { x: 0, y: 0 };\n    for (const move of moves) offset[move.axis] += move.to - move.from;\n\n    // Where the element will be once that's done picks the card's side.\n    const now = target && s.corners ? holeAround(target, s.corners, pad, config.radius) : null;\n    const end = now ? moveHole(now, -offset.x, -offset.y) : null;\n    const width = cardRef.current?.offsetWidth ?? 320;\n    s.side = chooseSide(end, config.side, { width, height: cardHeight }, view);\n\n    if (opening) {\n      // Nothing is on screen yet, so with reduced motion it can just jump.\n      if (s.reduce) for (const move of moves) scrollMove(move, move.to);\n      s.drive = s.reduce ? [] : moves;\n      s.flight =\n        target && now && !s.reduce\n          ? { anchor: target, anchorAt: topLeft(target) ?? ORIGIN, shift: ORIGIN, hole: collapse(now), card: null, side: s.side }\n          : null;\n      progress.jump(s.reduce ? 1 : 0);\n      animate(shown, 1, s.reduce ? TOUR.dimIn : TOUR.cardIn);\n      if (s.reduce) s.resting = true;\n      else settle(animate(progress, 1, TOUR.open), trip);\n      return;\n    }\n\n    const anchor = previous && topLeft(previous) ? previous : null;\n    s.flight = {\n      anchor,\n      anchorAt: (anchor && topLeft(anchor)) || ORIGIN,\n      shift: ORIGIN,\n      hole: s.hole,\n      card: s.card,\n      side: s.drawnSide,\n    };\n    progress.jump(0);\n\n    if (s.reduce) {\n      // Fade the hole shut, move, then fade it open on the new element.\n      s.drive = [];\n      animate(plug, 1, { duration: 0.12, ease: \"easeOut\" }).then(() => {\n        if (s.trip !== trip) return;\n        for (const move of moves) scrollMove(move, move.to);\n        s.flight = null;\n        progress.jump(1);\n        s.resting = true;\n        animate(plug, 0, { duration: 0.2, ease: \"easeOut\" });\n      });\n      return;\n    }\n\n    // Carry the hole's speed into the new trip, so pressing again\n    // mid-flight bends its path instead of stopping it dead.\n    const goal = end ?? collapse(s.hole);\n    const path = [goal.x - s.hole.x, goal.y - s.hole.y, goal.width - s.hole.width, goal.height - s.hole.height];\n    const length = dot(path, path);\n    const velocity = length > 1 ? clamp(dot(s.velocity, path) / length, -2, 8) : 0;\n    s.drive = moves;\n    settle(animate(progress, 1, { ...TOUR.travel, velocity }), trip);\n  };\n\n  // Head for the current step's element, or wait for it to appear.\n  const seek = () => {\n    const s = live.current;\n    const config = steps[s.index];\n    if (!config) return;\n    if (!config.target) return depart(null);\n    const element = resolve(config.target);\n    if (element && topLeft(element)) depart(element);\n    else s.waiting = performance.now();\n  };\n\n  // ── Open and close ──────────────────────────────────────\n\n  const start = () => {\n    const s = live.current;\n    if (!steps.length || s.phase === \"open\") return;\n    if (s.phase === \"closing\") {\n      // Opened again on the way out: close back in on the step.\n      s.phase = \"open\";\n      s.generation++;\n      s.exit = null;\n      animate(veil, 1, TOUR.dimIn);\n      animate(shown, 1, TOUR.dimIn);\n      cardRef.current?.focus({ preventScroll: true });\n      seek();\n      return;\n    }\n    const view = viewport();\n    s.phase = \"open\";\n    s.generation++;\n    s.started = false;\n    s.target = null;\n    s.corners = null;\n    s.flight = null;\n    s.exit = null;\n    s.waiting = 0;\n    s.drive = [];\n    s.side = \"center\";\n    s.drawnSide = \"center\";\n    s.hole = zeroHole({ x: view.width / 2, y: view.height / 2 });\n    s.velocity = [0, 0, 0, 0];\n    s.reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    s.wide = window.matchMedia(\"(min-width: 40rem)\");\n    s.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    progress.jump(0);\n    veil.jump(0);\n    shown.jump(0);\n    plug.jump(0);\n    pulse.jump(0);\n    if (step === undefined) setOwnIndex(0);\n    setMounted(true);\n  };\n\n  const close = () => {\n    const s = live.current;\n    if (s.phase !== \"open\") return;\n    s.phase = \"closing\";\n    s.waiting = 0;\n    s.drive = [];\n    s.trip++;\n    const generation = ++s.generation;\n    const runs: Thenable[] = [animate(shown, 0, TOUR.fadeOut)];\n    if (s.reduce) {\n      runs.push(animate(veil, 0, TOUR.dimIn));\n    } else {\n      // The hole widens past the edges of the screen as the dark lifts.\n      const anchor = s.target && topLeft(s.target) ? s.target : null;\n      s.flight = {\n        anchor,\n        anchorAt: (anchor && topLeft(anchor)) || ORIGIN,\n        shift: ORIGIN,\n        hole: s.hole,\n        card: s.card,\n        side: s.drawnSide,\n      };\n      s.exit = widen(s.hole, viewport());\n      progress.jump(0);\n      // Once the dark is gone the rest of the widening can't be seen, so\n      // the layer goes away then, without waiting for the spring to rest.\n      animate(progress, 1, TOUR.close);\n      runs.push(animate(veil, 0, TOUR.dimOut));\n    }\n    // The page is usable straight away: hand focus back now.\n    const layer = layerRef.current;\n    const focus = document.activeElement;\n    if ((!focus || focus === document.body || layer?.contains(focus)) && s.returnFocus?.isConnected) {\n      s.returnFocus.focus({ preventScroll: true });\n    }\n    Promise.all(runs).then(() => {\n      if (live.current.generation === generation) finish();\n    });\n  };\n\n  // Gone: put the layer away.\n  const finish = () => {\n    const s = live.current;\n    if (s.phase !== \"closing\") return;\n    s.phase = \"closed\";\n    s.target = null;\n    s.flight = null;\n    s.exit = null;\n    const layer = layerRef.current;\n    if (layer?.matches(\":popover-open\")) layer.hidePopover();\n    setMounted(false);\n  };\n\n  const go = (to: number) => {\n    const s = live.current;\n    if (s.phase !== \"open\" || to < 0 || to === s.index) return;\n    if (to >= steps.length) {\n      onComplete?.();\n      onOpenChange?.(false);\n      return;\n    }\n    if (step === undefined) setOwnIndex(to);\n    onStepChange?.(to);\n  };\n\n  const skip = () => onOpenChange?.(false);\n\n  const clicked = (event: MouseEvent) => {\n    const s = live.current;\n    if (s.phase !== \"open\" || !steps[s.index]?.advanceOnClick || !s.target) return;\n    if (event.target instanceof Node && s.target.contains(event.target)) go(s.index + 1);\n  };\n\n  // A click on the dark: pulse the ring again to point at the element.\n  const nudge = (event: React.PointerEvent) => {\n    const s = live.current;\n    if (!event.isPrimary || s.phase !== \"open\" || s.reduce || !s.target) return;\n    pulse.jump(0);\n    animate(pulse, 1, TOUR.pulse);\n  };\n\n  // Tab moves through the card and, on interactive steps, the element.\n  const cycleFocus = (event: KeyboardEvent) => {\n    const s = live.current;\n    const card = cardRef.current;\n    const config = steps[s.index];\n    if (!card) return;\n    const inside = passThroughFor(config) && s.target ? focusables(s.target) : [];\n    const items = [...inside, ...focusables(card)];\n    if (!items.length) return;\n    event.preventDefault();\n    const at = items.indexOf(document.activeElement as HTMLElement);\n    const next =\n      at === -1\n        ? event.shiftKey\n          ? items.length - 1\n          : 0\n        : (at + (event.shiftKey ? -1 : 1) + items.length) % items.length;\n    items[next].focus({ preventScroll: true });\n  };\n\n  const measureCard = () => {\n    const s = live.current;\n    const card = cardRef.current;\n    const body = bodyRef.current;\n    const content = contentRef.current;\n    if (!card || !body || !content) return;\n    s.chrome = card.offsetHeight - body.offsetHeight;\n    const height = content.offsetHeight;\n    if (s.started && Math.abs(height - s.bodyTarget) < 0.5) return;\n    s.bodyTarget = height;\n    if (!s.started || s.reduce) bodyHeight.jump(height);\n    else animate(bodyHeight, height, TOUR.resize);\n  };\n\n  // ── Every frame ─────────────────────────────────────────\n\n  const tick = () => {\n    const s = live.current;\n    const dim = dimRef.current;\n    const plugElement = plugRef.current;\n    const blocker = blockerRef.current;\n    const outline = outlineRef.current;\n    const ring = ringRef.current;\n    const card = cardRef.current;\n    const arrow = arrowRef.current;\n    const body = bodyRef.current;\n    if (s.phase === \"closed\" || !dim || !plugElement || !blocker || !outline || !ring || !card || !arrow || !body) {\n      return;\n    }\n    const config = steps[s.index];\n    const view = viewport();\n    const docked = !s.wide?.matches;\n    const width = card.offsetWidth;\n\n    // A step whose element hasn't appeared yet: keep looking, for a while.\n    if (s.waiting && config) {\n      const element = resolve(config.target);\n      if (element && topLeft(element)) depart(element);\n      else if (performance.now() - s.waiting > TOUR.wait) depart(null);\n    }\n    // The element was swapped for a new one, as re-renders can do: follow it.\n    if (s.target && !s.target.isConnected && config) {\n      const element = resolve(config.target);\n      if (element && element !== s.target && topLeft(element)) {\n        s.target = element;\n        s.corners = readCorners(element);\n      }\n    }\n\n    const p = progress.get();\n    for (const move of s.drive) scrollMove(move, lerp(move.from, move.to, clamp(p, 0, 1)));\n\n    // The hole runs from where the trip started to the element it's headed for.\n    const f = s.flight;\n    if (f?.anchor) {\n      const at = topLeft(f.anchor);\n      if (at) f.shift = { x: at.x - f.anchorAt.x, y: at.y - f.anchorAt.y };\n    }\n    const start = f ? moveHole(f.hole, f.shift.x, f.shift.y) : null;\n    let end: Hole;\n    if (s.phase === \"closing\" && s.exit) {\n      end = s.exit;\n    } else if (s.target && s.corners && config) {\n      const measured = holeAround(s.target, s.corners, config.padding ?? padding, config.radius);\n      if (measured) s.lastEnd = measured;\n      end = measured ?? s.lastEnd ?? s.hole;\n    } else {\n      end = collapse(start ?? s.hole);\n    }\n    const hole = start ? mixHole(start, end, p) : end;\n    const holeOpen = hole.width > 1 && hole.height > 1;\n\n    const time = performance.now();\n    const dt = (time - s.time) / 1000;\n    s.velocity =\n      dt > 0.001 && dt < 0.1\n        ? [(hole.x - s.hole.x) / dt, (hole.y - s.hole.y) / dt, (hole.width - s.hole.width) / dt, (hole.height - s.hole.height) / dt]\n        : [0, 0, 0, 0];\n    s.time = time;\n    s.hole = hole;\n\n    // The card rides along, from where it was to beside the element.\n    const size = { width, height: s.chrome + bodyHeight.get() };\n    let side = s.side;\n    let arrowOpacity = 1;\n    if (s.phase === \"open\") {\n      if (docked) {\n        s.card = { x: (view.width - width) / 2, y: view.bottom - TOUR.margin - size.height };\n      } else {\n        const goal = placeCard(s.side === \"center\" ? null : end, s.side, size, view);\n        s.card = f?.card ? mixPoint(movePoint(f.card, f.shift), goal, p) : goal;\n      }\n      // Changing sides: the arrow fades out on the old one, then in on the new.\n      if (f?.card && f.side !== s.side) {\n        if (p < 0.5) {\n          side = f.side;\n          arrowOpacity = 1 - p * 2;\n        } else arrowOpacity = Math.min(p * 2 - 1, 1);\n      }\n      s.drawnSide = side;\n    }\n\n    // Pulse once on arrival.\n    if (s.phase === \"open\" && !s.arrived && s.target && p >= 0.96) {\n      s.arrived = true;\n      if (!s.reduce) {\n        pulse.jump(0);\n        animate(pulse, 1, TOUR.pulse);\n      }\n    }\n\n    // Draw.\n    const dark = veil.get();\n    const plugged = plug.get();\n    const active = s.phase === \"open\";\n    write(dim, {\n      clipPath: `path(evenodd, \"${OUTSIDE}${holePath(hole)}\")`,\n      opacity: str(dark),\n      pointerEvents: active ? \"auto\" : \"none\",\n    });\n    write(blocker, { ...boxStyle(hole), pointerEvents: active && !passThroughFor(config) ? \"auto\" : \"none\" });\n    write(outline, { ...boxStyle(hole), opacity: holeOpen ? str(dark) : \"0\" });\n    write(plugElement, { ...boxStyle(hole), opacity: holeOpen ? str(plugged) : \"0\" });\n    const q = pulse.get();\n    write(\n      ring,\n      q > 0 && q < 1 && holeOpen && active\n        ? { ...boxStyle(growHole(hole, 2 + q * 12)), opacity: str((1 - q) * 0.75 * dark) }\n        : { opacity: \"0\" },\n    );\n\n    const presence = shown.get();\n    const showArrow = active && !docked && side !== \"center\" && holeOpen;\n    const tip = showArrow ? arrowAt(hole, side as Side, s.card, size) : null;\n    const lift = s.reduce\n      ? \"\"\n      : docked\n        ? `translateY(${num((1 - presence) * 20)}px)`\n        : `scale(${num(0.94 + 0.06 * presence)})`;\n    write(card, {\n      transform: `translate3d(${num(s.card.x)}px, ${num(s.card.y)}px, 0) ${lift}`,\n      opacity: str(presence * (1 - plugged)),\n      pointerEvents: active ? \"auto\" : \"none\",\n      ...(active ? { transformOrigin: tip ? `${num(tip.x)}px ${num(tip.y)}px` : \"50% 50%\" } : null),\n    });\n    write(body, { height: `${num(bodyHeight.get())}px` });\n    if (active) {\n      write(\n        arrow,\n        tip\n          ? { transform: `translate(${num(tip.x - 8)}px, ${num(tip.y - 8)}px) rotate(${tip.rotate}deg)`, opacity: str(arrowOpacity) }\n          : { opacity: \"0\" },\n      );\n    }\n\n    // At rest, with the card squeezed out of its side (the page scrolled,\n    // or the window shrank): move it to a side that has room.\n    if (active && s.resting && !docked && s.target && s.side !== \"center\" && config) {\n      const need = { width, height: s.chrome + s.bodyTarget };\n      const room = roomAround(end, need, view);\n      if (room[s.side] < 0) {\n        const better = chooseSide(end, config.side, need, view);\n        if (better !== s.side && better !== \"center\" && room[better] >= 0) {\n          depart(s.target, { quiet: true, scroll: false });\n        }\n      }\n    }\n  };\n\n  // Global listeners and the frame loop call the latest versions of these.\n  const actions = React.useRef({ start, close, go, skip, clicked, tick, measureCard, seek, cycleFocus });\n  React.useLayoutEffect(() => {\n    actions.current = { start, close, go, skip, clicked, tick, measureCard, seek, cycleFocus };\n  });\n\n  React.useEffect(() => {\n    if (open) actions.current.start();\n    else actions.current.close();\n  }, [open]);\n\n  // Mounted: lift the layer into the top layer, move focus to the card and\n  // start drawing.\n  React.useLayoutEffect(() => {\n    const layer = layerRef.current;\n    if (!mounted || !layer) return;\n    if (!layer.matches(\":popover-open\")) {\n      layer.popover = \"manual\";\n      layer.showPopover();\n    }\n    cardRef.current?.focus({ preventScroll: true });\n    animate(veil, 1, TOUR.dimIn);\n    const draw = () => actions.current.tick();\n    frame.render(draw, true);\n    return () => cancelFrame(draw);\n  }, [mounted, veil]);\n\n  // Each step: size the card to its text, then head for the step's element.\n  React.useLayoutEffect(() => {\n    if (!mounted) return;\n    const s = live.current;\n    const fresh = s.index !== index || !s.started;\n    s.index = index;\n    actions.current.measureCard();\n    if (fresh && s.phase === \"open\") actions.current.seek();\n    // Keep focus in the card, say when the button that had it went away.\n    const card = cardRef.current;\n    const focus = document.activeElement;\n    if (card && !card.contains(focus) && !(passThrough && s.target?.contains(focus))) {\n      card.focus({ preventScroll: true });\n    }\n  }, [mounted, index, passThrough]);\n\n  // The card's height follows its text.\n  React.useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!mounted || !content) return;\n    const observer = new ResizeObserver(() => actions.current.measureCard());\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [mounted]);\n\n  React.useEffect(() => {\n    if (!mounted) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      const s = live.current;\n      if (s.phase !== \"open\" || event.defaultPrevented || event.isComposing) return;\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        actions.current.skip();\n        return;\n      }\n      if (event.key === \"Tab\") {\n        actions.current.cycleFocus(event);\n        return;\n      }\n      // Arrows and Enter steer the tour only from the card, so they still\n      // type and pick inside an interactive element.\n      const focus = document.activeElement;\n      const free = !focus || focus === document.body || !!cardRef.current?.contains(focus);\n      if (!free || event.repeat || event.metaKey || event.ctrlKey || event.altKey) return;\n      const onControl = focus instanceof Element && !!focus.closest(\"button, a[href], input, select, textarea\");\n      if (event.key === \"ArrowRight\" || (event.key === \"Enter\" && !onControl)) {\n        event.preventDefault();\n        actions.current.go(s.index + 1);\n      } else if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        actions.current.go(s.index - 1);\n      }\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [mounted]);\n\n  // Steps that move on when their element is clicked.\n  React.useEffect(() => {\n    if (!mounted) return;\n    const onClick = (event: MouseEvent) => actions.current.clicked(event);\n    document.addEventListener(\"click\", onClick);\n    return () => document.removeEventListener(\"click\", onClick);\n  }, [mounted]);\n\n  // The wheel over the dark scrolls whatever is under it, nested scroll\n  // boxes included. Taking over the scroll stops the tour from driving it.\n  React.useEffect(() => {\n    const layer = layerRef.current;\n    if (!mounted || !layer) return;\n    const release = () => {\n      live.current.drive = [];\n    };\n    const onWheel = (event: WheelEvent) => {\n      if (event.ctrlKey) return; // pinch to zoom\n      event.preventDefault();\n      const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;\n      const dx = event.deltaX * scale;\n      const dy = event.deltaY * scale;\n      const under = document.elementsFromPoint(event.clientX, event.clientY).find((element) => !layer.contains(element));\n      scrollerFor(under ?? null, dx, dy).scrollBy({ left: dx, top: dy, behavior: \"instant\" });\n    };\n    window.addEventListener(\"wheel\", release, { passive: true, capture: true });\n    window.addEventListener(\"touchstart\", release, { passive: true, capture: true });\n    layer.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      window.removeEventListener(\"wheel\", release, { capture: true });\n      window.removeEventListener(\"touchstart\", release, { capture: true });\n      layer.removeEventListener(\"wheel\", onWheel);\n    };\n  }, [mounted]);\n\n  if (!mounted || !current) return null;\n\n  const button = cn(\n    \"inline-flex h-8 cursor-pointer items-center justify-center rounded-full px-3 text-[13px] font-medium outline-none select-none [-webkit-tap-highlight-color:transparent]\",\n    \"transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.97] motion-reduce:active:scale-100\",\n    \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n  );\n  const text = reduceMotion ? FADE : SLIDE;\n\n  return (\n    <div\n      ref={layerRef}\n      className=\"pointer-events-none fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground\"\n    >\n      <div\n        ref={dimRef}\n        aria-hidden\n        className={cn(\"absolute inset-0 opacity-0\", DIM, overlayClassName)}\n        onPointerDown={nudge}\n        // Keep focus in the card.\n        onMouseDown={(event) => event.preventDefault()}\n      />\n      <div ref={plugRef} aria-hidden className={cn(\"absolute top-0 left-0 opacity-0\", DIM, overlayClassName)} />\n      <div\n        ref={blockerRef}\n        aria-hidden\n        className=\"absolute top-0 left-0\"\n        onPointerDown={nudge}\n        onMouseDown={(event) => event.preventDefault()}\n      />\n      <div ref={outlineRef} aria-hidden className=\"absolute top-0 left-0 opacity-0 ring-1 ring-white/15 dark:ring-white/25\" />\n      <div ref={ringRef} aria-hidden className=\"absolute top-0 left-0 border-[1.5px] border-white opacity-0\" />\n\n      <div\n        ref={cardRef}\n        role=\"dialog\"\n        aria-modal={passThrough ? undefined : true}\n        aria-labelledby={`${id}-title-${index}`}\n        aria-describedby={current.body ? `${id}-body-${index}` : undefined}\n        tabIndex={-1}\n        className={cn(\n          \"pointer-events-auto absolute top-0 left-0 w-[calc(100%-1.5rem)] rounded-2xl bg-background p-4 text-foreground opacity-0 outline-none sm:w-80\",\n          \"shadow-[0_24px_60px_-16px_rgb(0_0_0/0.35),0_6px_18px_-8px_rgb(0_0_0/0.15)] ring-1 ring-foreground/10 dark:shadow-[0_24px_60px_-16px_rgb(0_0_0/0.8)]\",\n          className,\n        )}\n      >\n        <svg\n          ref={arrowRef}\n          aria-hidden\n          viewBox=\"0 0 16 9\"\n          className=\"pointer-events-none absolute top-0 left-0 h-[9px] w-4 overflow-visible opacity-0\"\n          style={{ transformOrigin: \"8px 8px\" }}\n        >\n          <path d=\"M0 8.5 6.6 1.9Q8 .5 9.4 1.9L16 8.5V9H0Z\" className=\"fill-background\" />\n          <path d=\"M0 8.5 6.6 1.9Q8 .5 9.4 1.9L16 8.5\" fill=\"none\" className=\"stroke-foreground/10\" />\n        </svg>\n\n        <div className=\"flex h-5 items-center justify-between text-xs text-muted-foreground\">\n          <span aria-hidden className=\"flex items-center tabular-nums\">\n            <span className=\"relative inline-flex overflow-hidden\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n                <motion.span key={index} custom={direction} variants={reduceMotion ? FADE : COUNT} initial=\"enter\" animate=\"center\" exit=\"exit\">\n                  {index + 1}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            &nbsp;of {count}\n          </span>\n          <AnimatePresence initial={false}>\n            {last ? null : (\n              <motion.button\n                key=\"skip\"\n                type=\"button\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.15 }}\n                onClick={skip}\n                className=\"-mr-1.5 cursor-pointer rounded-md px-1.5 py-0.5 transition-colors outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-foreground\"\n              >\n                Skip\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <div ref={bodyRef} className=\"-mx-4 overflow-hidden px-4\">\n          <div ref={contentRef} aria-live=\"polite\" className=\"relative pt-2.5\">\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n              <motion.div key={index} custom={direction} variants={text} initial=\"enter\" animate=\"center\" exit=\"exit\">\n                <span className=\"sr-only\">\n                  Step {index + 1} of {count}.{\" \"}\n                </span>\n                <h2\n                  id={`${id}-title-${index}`}\n                  className=\"text-[15px] leading-snug font-medium tracking-[-0.01em] text-balance\"\n                >\n                  {current.title}\n                </h2>\n                {current.body ? (\n                  <div id={`${id}-body-${index}`} className=\"mt-1 text-sm leading-relaxed text-pretty text-muted-foreground\">\n                    {current.body}\n                  </div>\n                ) : null}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </div>\n\n        <div className=\"mt-4 flex items-center justify-between gap-3\">\n          <div aria-hidden className=\"flex items-center gap-1\">\n            {steps.map((_, dot) => (\n              <motion.span\n                key={dot}\n                className=\"h-1.5 w-1.5 rounded-full bg-foreground\"\n                initial={false}\n                animate={{\n                  width: dot === index ? 18 : 6,\n                  opacity: dot === index ? 1 : dot < index ? 0.4 : 0.15,\n                }}\n                transition={reduceMotion ? { duration: 0 } : TOUR.dots}\n              />\n            ))}\n          </div>\n          <div className=\"flex items-center gap-1\">\n            <AnimatePresence initial={false}>\n              {index > 0 ? (\n                <motion.button\n                  key=\"back\"\n                  type=\"button\"\n                  initial={{ opacity: 0, scale: 0.9 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: 0.9 }}\n                  transition={{ duration: 0.16, ease: \"easeOut\" }}\n                  onClick={() => go(index - 1)}\n                  className={cn(button, \"text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground\")}\n                >\n                  Back\n                </motion.button>\n              ) : null}\n            </AnimatePresence>\n            <button\n              type=\"button\"\n              onClick={() => go(index + 1)}\n              className={cn(button, \"min-w-16 bg-foreground px-3.5 text-background hover:bg-foreground/85\")}\n            >\n              {last ? \"Done\" : \"Next\"}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst SLIDE: Variants = {\n  enter: (direction: number) => ({ opacity: 0, x: direction * 12, filter: \"blur(3px)\" }),\n  center: {\n    opacity: 1,\n    x: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: 0.32, ease: [0.22, 1, 0.36, 1], delay: 0.05 },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    x: direction * -12,\n    filter: \"blur(3px)\",\n    transition: { duration: 0.16, ease: \"easeIn\" },\n  }),\n};\n\nconst COUNT: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: direction * 8 }),\n  center: { opacity: 1, y: 0, transition: { type: \"spring\", visualDuration: 0.3, bounce: 0.2 } },\n  exit: (direction: number) => ({ opacity: 0, y: direction * -8, transition: { duration: 0.12 } }),\n};\n\nconst FADE: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1, transition: { duration: 0.2 } },\n  exit: { opacity: 0, transition: { duration: 0.12 } },\n};\n\nfunction passThroughFor(config: SpotlightTourStep | undefined) {\n  return !!(config?.interactive || config?.advanceOnClick);\n}\n\n// ── Elements ──────────────────────────────────────────────\n\nfunction resolve(target: SpotlightTourStep[\"target\"]): Element | null {\n  if (!target) return null;\n  if (typeof target === \"string\") return document.querySelector(target);\n  return target instanceof Element ? target : target.current;\n}\n\n/** Where the element's top-left corner is on screen, or null when it isn't laid out. */\nfunction topLeft(element: Element): Point | null {\n  if (!element.isConnected) return null;\n  const rect = element.getBoundingClientRect();\n  return rect.width || rect.height ? { x: rect.left, y: rect.top } : null;\n}\n\nfunction readCorners(element: Element): Corner[] {\n  const style = getComputedStyle(element);\n  return [\n    style.borderTopLeftRadius,\n    style.borderTopRightRadius,\n    style.borderBottomRightRadius,\n    style.borderBottomLeftRadius,\n  ].map((value) => {\n    const first = value.split(\" \")[0] ?? \"\";\n    const amount = parseFloat(first) || 0;\n    // `rounded-full` computes to an enormous length; fitRadii scales it down.\n    return first.endsWith(\"%\") ? { px: 0, pct: amount / 100 } : { px: Math.min(amount, 1e5), pct: 0 };\n  });\n}\n\nconst FOCUSABLE =\n  'a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"]), [contenteditable]:not([contenteditable=\"false\"])';\n\nfunction focusables(root: Element) {\n  return [root, ...root.querySelectorAll(FOCUSABLE)].filter(\n    (element): element is HTMLElement =>\n      element instanceof HTMLElement && element.matches(FOCUSABLE) && element.getClientRects().length > 0,\n  );\n}\n\n// ── The hole ──────────────────────────────────────────────\n\nfunction zeroHole(at: Point): Hole {\n  return { ...at, width: 0, height: 0, radii: [0, 0, 0, 0] };\n}\n\n/** An element's box plus padding, with its corners grown by the same padding so the curves stay parallel. */\nfunction holeAround(element: Element, corners: Corner[], padding: number, radius?: number): Hole | null {\n  if (!element.isConnected) return null;\n  const rect = element.getBoundingClientRect();\n  if (!rect.width && !rect.height) return null;\n  const short = Math.min(rect.width, rect.height);\n  const width = rect.width + padding * 2;\n  const height = rect.height + padding * 2;\n  const radii = corners.map((corner) => radius ?? corner.px + corner.pct * short + padding) as Radii;\n  return { x: rect.left - padding, y: rect.top - padding, width, height, radii: fitRadii(radii, width, height) };\n}\n\n/** Scales corners down together when they don't fit, the way CSS does. */\nfunction fitRadii(radii: Radii, width: number, height: number): Radii {\n  const [tl, tr, br, bl] = radii;\n  const ratio = (length: number, sum: number) => (sum > 0 ? length / sum : Infinity);\n  const f = clamp(\n    Math.min(ratio(width, tl + tr), ratio(width, bl + br), ratio(height, tl + bl), ratio(height, tr + br)),\n    0,\n    1,\n  );\n  return [tl * f, tr * f, br * f, bl * f];\n}\n\nfunction collapse(hole: Hole): Hole {\n  return { ...zeroHole({ x: hole.x + hole.width / 2, y: hole.y + hole.height / 2 }), radii: hole.radii };\n}\n\nfunction moveHole(hole: Hole, dx: number, dy: number): Hole {\n  return { ...hole, x: hole.x + dx, y: hole.y + dy };\n}\n\nfunction growHole(hole: Hole, by: number): Hole {\n  return {\n    x: hole.x - by,\n    y: hole.y - by,\n    width: hole.width + by * 2,\n    height: hole.height + by * 2,\n    radii: hole.radii.map((radius) => radius + by) as Radii,\n  };\n}\n\nfunction mixHole(a: Hole, b: Hole, t: number): Hole {\n  const width = Math.max(lerp(a.width, b.width, t), 0);\n  const height = Math.max(lerp(a.height, b.height, t), 0);\n  const radii = a.radii.map((radius, i) => Math.max(lerp(radius, b.radii[i], t), 0)) as Radii;\n  return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), width, height, radii: fitRadii(radii, width, height) };\n}\n\n/** A circle centered on the hole, big enough to clear every corner of the screen. */\nfunction widen(hole: Hole, view: View): Hole {\n  const open = hole.width > 1 && hole.height > 1;\n  const cx = open ? hole.x + hole.width / 2 : view.width / 2;\n  const cy = open ? hole.y + hole.height / 2 : view.height / 2;\n  const reach = Math.hypot(Math.max(cx, view.width - cx), Math.max(cy, view.height - cy)) + 40;\n  return { x: cx - reach, y: cy - reach, width: reach * 2, height: reach * 2, radii: [reach, reach, reach, reach] };\n}\n\nfunction holePath({ x, y, width: w, height: h, radii }: Hole) {\n  if (w < 0.5 || h < 0.5) return \"\";\n  const [tl, tr, br, bl] = radii.map(num);\n  return (\n    `M${num(x + tl)} ${num(y)}H${num(x + w - tr)}A${tr} ${tr} 0 0 1 ${num(x + w)} ${num(y + tr)}` +\n    `V${num(y + h - br)}A${br} ${br} 0 0 1 ${num(x + w - br)} ${num(y + h)}` +\n    `H${num(x + bl)}A${bl} ${bl} 0 0 1 ${num(x)} ${num(y + h - bl)}` +\n    `V${num(y + tl)}A${tl} ${tl} 0 0 1 ${num(x + tl)} ${num(y)}Z`\n  );\n}\n\nfunction boxStyle({ x, y, width, height, radii }: Hole) {\n  return {\n    transform: `translate3d(${num(x)}px, ${num(y)}px, 0)`,\n    width: `${num(width)}px`,\n    height: `${num(height)}px`,\n    borderRadius: radii.map((radius) => `${num(radius)}px`).join(\" \"),\n  };\n}\n\n// ── The card ──────────────────────────────────────────────\n\nfunction roomAround(hole: Hole, size: Size, view: View): Record<Side, number> {\n  const { gap, margin } = TOUR;\n  return {\n    top: hole.y - gap - margin - size.height,\n    bottom: view.height - margin - (hole.y + hole.height + gap) - size.height,\n    left: hole.x - gap - margin - size.width,\n    right: view.width - margin - (hole.x + hole.width + gap) - size.width,\n  };\n}\n\n/**\n * The asked-for side if the card fits there, otherwise the side with the most\n * room, counted in cards: a card is wider than it is tall, so it needs more\n * room beside an element than above or below it. Above or below reads more\n * naturally, so those win a close call.\n */\nfunction chooseSide(hole: Hole | null, preferred: Side | undefined, size: Size, view: View): Placement {\n  if (!hole || hole.width < 1) return \"center\";\n  const room = roomAround(hole, size, view);\n  if (preferred && room[preferred] >= 0) return preferred;\n  const sides: Side[] = [\"bottom\", \"top\", \"right\", \"left\"];\n  const score = (side: Side) => {\n    const vertical = side === \"top\" || side === \"bottom\";\n    const need = vertical ? size.height : size.width;\n    return ((room[side] + need) / need) * (vertical ? 1 : 0.85);\n  };\n  const fitting = sides.filter((side) => room[side] >= 0);\n  return (fitting.length ? fitting : sides).reduce((best, side) => (score(side) > score(best) ? side : best));\n}\n\nfunction placeCard(hole: Hole | null, side: Placement, size: Size, view: View): Point {\n  const { gap, margin } = TOUR;\n  let x = (view.width - size.width) / 2;\n  let y = (view.height - size.height) / 2;\n  if (hole && (side === \"top\" || side === \"bottom\")) {\n    x = hole.x + hole.width / 2 - size.width / 2;\n    y = side === \"bottom\" ? hole.y + hole.height + gap : hole.y - gap - size.height;\n  } else if (hole && (side === \"left\" || side === \"right\")) {\n    x = side === \"right\" ? hole.x + hole.width + gap : hole.x - gap - size.width;\n    y = hole.y + hole.height / 2 - size.height / 2;\n  }\n  return {\n    x: clamp(x, margin, view.width - margin - size.width),\n    y: clamp(y, margin, view.height - margin - size.height),\n  };\n}\n\n/** Where the arrow meets the card's edge, aimed at the hole, and which way it turns. */\nfunction arrowAt(hole: Hole, side: Side, card: Point, size: Size) {\n  const inset = 20; // clear of the card's corners\n  if (side === \"top\" || side === \"bottom\") {\n    const x = clamp(hole.x + hole.width / 2 - card.x, inset, size.width - inset);\n    return side === \"bottom\" ? { x, y: 0, rotate: 0 } : { x, y: size.height, rotate: 180 };\n  }\n  const y = clamp(hole.y + hole.height / 2 - card.y, inset, size.height - inset);\n  return side === \"right\" ? { x: 0, y, rotate: -90 } : { x: size.width, y, rotate: 90 };\n}\n\n// ── Scrolling ─────────────────────────────────────────────\n\nconst SCROLLS = /(auto|scroll|overlay)/;\n\nfunction viewport(): View {\n  const width = document.documentElement.clientWidth;\n  const height = document.documentElement.clientHeight;\n  const visual = window.visualViewport;\n  return { width, height, bottom: visual ? Math.min(visual.offsetTop + visual.height, height) : height };\n}\n\n/**\n * How each scroll box around the element should move to bring it into view,\n * innermost first, ending with the page. `reserve` is room kept free at the\n * bottom of the screen.\n */\nfunction planScroll(target: Element, padding: number, reserve: number, view: View): ScrollMove[] {\n  const rect = target.getBoundingClientRect();\n  let box = { top: rect.top - padding, bottom: rect.bottom + padding, left: rect.left - padding, right: rect.right + padding };\n  const root = document.scrollingElement ?? document.documentElement;\n  const moves: ScrollMove[] = [];\n  for (let element = target.parentElement; element; element = element.parentElement) {\n    const isRoot = element === root;\n    const style = getComputedStyle(element);\n    const scrollsY = isRoot || (SCROLLS.test(style.overflowY) && element.scrollHeight > element.clientHeight);\n    const scrollsX = isRoot || (SCROLLS.test(style.overflowX) && element.scrollWidth > element.clientWidth);\n    if (!scrollsY && !scrollsX) continue;\n\n    const bounds = isRoot ? { top: 0, left: 0, bottom: view.bottom - reserve, right: view.width } : clientBox(element);\n    const margin = isRoot ? 24 : 12;\n    const inset = (value: string) => (parseFloat(value) || 0) + margin;\n    let dx = 0;\n    let dy = 0;\n    if (scrollsY) {\n      const delta = scrollDelta(box.top, box.bottom, bounds.top + inset(style.scrollPaddingTop), bounds.bottom - inset(style.scrollPaddingBottom));\n      const to = clamp(element.scrollTop + delta, 0, element.scrollHeight - element.clientHeight);\n      if (Math.abs(to - element.scrollTop) > 0.5) {\n        dy = to - element.scrollTop;\n        moves.push({ element, axis: \"y\", from: element.scrollTop, to });\n      }\n    }\n    if (scrollsX) {\n      const delta = scrollDelta(box.left, box.right, bounds.left + inset(style.scrollPaddingLeft), bounds.right - inset(style.scrollPaddingRight));\n      const to = clamp(element.scrollLeft + delta, 0, element.scrollWidth - element.clientWidth);\n      if (Math.abs(to - element.scrollLeft) > 0.5) {\n        dx = to - element.scrollLeft;\n        moves.push({ element, axis: \"x\", from: element.scrollLeft, to });\n      }\n    }\n    box = { top: box.top - dy, bottom: box.bottom - dy, left: box.left - dx, right: box.right - dx };\n    if (isRoot) break;\n  }\n  return moves;\n}\n\n/** Nothing if it's already in view; otherwise center it, or line up its start if it's too big to fit. */\nfunction scrollDelta(start: number, end: number, min: number, max: number) {\n  if (start >= min && end <= max) return 0;\n  if (end - start > max - min) return start - min;\n  return (start + end) / 2 - (min + max) / 2;\n}\n\nfunction clientBox(element: Element) {\n  const rect = element.getBoundingClientRect();\n  const top = rect.top + element.clientTop;\n  const left = rect.left + element.clientLeft;\n  return { top, left, bottom: top + element.clientHeight, right: left + element.clientWidth };\n}\n\nfunction scrollMove(move: ScrollMove, value: number) {\n  move.element.scrollTo(\n    move.axis === \"y\" ? { top: value, behavior: \"instant\" } : { left: value, behavior: \"instant\" },\n  );\n}\n\n/** The box a wheel over `start` should scroll, as the browser would pick it. */\nfunction scrollerFor(start: Element | null, dx: number, dy: number): Element {\n  const root = document.scrollingElement ?? document.documentElement;\n  for (let element = start; element && element !== root; element = element.parentElement) {\n    const style = getComputedStyle(element);\n    const y = dy !== 0 && SCROLLS.test(style.overflowY) && element.scrollHeight > element.clientHeight;\n    const x = dx !== 0 && SCROLLS.test(style.overflowX) && element.scrollWidth > element.clientWidth;\n    if (y && canScroll(element.scrollTop, element.scrollHeight - element.clientHeight, dy)) return element;\n    if (x && canScroll(element.scrollLeft, element.scrollWidth - element.clientWidth, dx)) return element;\n    // At its end and not letting the scroll chain out.\n    if ((y && style.overscrollBehaviorY !== \"auto\") || (x && style.overscrollBehaviorX !== \"auto\")) return element;\n  }\n  return root;\n}\n\nfunction canScroll(position: number, max: number, delta: number) {\n  return delta > 0 ? position < max - 1 : position > 1;\n}\n\n// ── Utilities ─────────────────────────────────────────────\n\n// What was last written to each element, so unchanged styles aren't rewritten every frame.\nconst written = new WeakMap<Element, Record<string, string>>();\n\nfunction write(element: HTMLElement | SVGElement, styles: Record<string, string>) {\n  let last = written.get(element);\n  if (!last) written.set(element, (last = {}));\n  const style = element.style as unknown as Record<string, string>;\n  for (const key in styles) {\n    if (last[key] === styles[key]) continue;\n    last[key] = styles[key];\n    style[key] = styles[key];\n  }\n}\n\nfunction movePoint(point: Point, by: Point): Point {\n  return { x: point.x + by.x, y: point.y + by.y };\n}\n\nfunction mixPoint(a: Point, b: Point, t: number): Point {\n  return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) };\n}\n\nfunction dot(a: number[], b: number[]) {\n  return a.reduce((sum, value, i) => sum + value * b[i], 0);\n}\n\nfunction num(value: number) {\n  return Math.round(value * 100) / 100;\n}\n\nfunction str(value: number) {\n  return String(num(value));\n}\n\nfunction lerp(from: number, to: number, t: number) {\n  return from + (to - from) * t;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n"
    }
  ]
}