{"slug":"bump-chart","name":"Bump Chart","description":"Animated rankings with curved paths, interactive rank dots, and tooltips.","category":"charts","source_url":"https://beui.dev/r/bump-chart/raw","detail_url":"https://beui.dev/r/bump-chart","raw_url":"https://beui.dev/r/bump-chart/raw","page_url":"https://beui.dev/charts/bump-chart","markdown_url":"https://beui.dev/charts/bump-chart.md","published_at":"2026-09-20","updated_at":"2026-09-25","dependencies":["@floating-ui/dom","clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["../magnetic","./base","./bump-chart/context","./bump-chart/legend","./bump-chart/model","./bump-chart/plot","./context","./magnetic","./metallic","./model","./point","./series","./stateful","./tooltip/positioner","./tooltip/use-position","./use-geometry","./use-position","@/components/charts/bump-chart","@/components/motion/button","@/components/motion/tooltip","@/components/motion/tooltip-surface","@/lib/ease","@/lib/hooks/use-dismiss","@/lib/hooks/use-hover-capable","@/lib/hooks/use-hover-gesture","@/lib/hooks/use-tap-gesture","@/lib/touch","@/lib/utils"],"files":[{"path":"components/charts/bump-chart.tsx","type":"component","content":"\"use client\";\n// beui.dev/charts/bump-chart\n\nimport { cn } from \"@/lib/utils\";\nimport { BumpChartContext, useBumpChartModel, type BumpChartProps } from \"./bump-chart/context\";\nimport { BumpChartLegend } from \"./bump-chart/legend\";\nimport { BumpChartPlot } from \"./bump-chart/plot\";\n\n/** Compose the plot and legend, or supply children for your own arrangement. */\nexport function BumpChart({ children, className, ...props }: BumpChartProps) {\n  const model = useBumpChartModel(props);\n  return (\n    <BumpChartContext.Provider value={model}>\n      <section aria-label={model.label} className={cn(\"w-full space-y-5\", className)}>\n        {children === undefined ? (\n          <>\n            <BumpChartPlot />\n            <BumpChartLegend />\n          </>\n        ) : (\n          children\n        )}\n      </section>\n    </BumpChartContext.Provider>\n  );\n}\n\nexport { BumpChartLegend } from \"./bump-chart/legend\";\nexport { BumpChartPlot } from \"./bump-chart/plot\";\nexport { useBumpChart } from \"./bump-chart/context\";\nexport type { BumpChartProps } from \"./bump-chart/context\";\nexport type BumpChartSeries = import(\"./bump-chart/model\").BumpSeries;\n"},{"path":"components/charts/bump-chart/context.tsx","type":"util","content":"\"use client\";\n\nimport { createContext, useContext, useState, type ReactNode } from \"react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { buildBumpChart, type BumpSeries } from \"./model\";\n\nexport interface BumpChartProps {\n  series: readonly BumpSeries[];\n  /** Unique period labels, in chronological order. */\n  periods: readonly string[];\n  /** Pinned series ID. Null clears the selection. */\n  active?: string | null;\n  defaultActive?: string | null;\n  onActiveChange?: (id: string | null) => void;\n  label?: string;\n  children?: ReactNode;\n  className?: string;\n}\n\nexport function useBumpChartModel({\n  series,\n  periods,\n  active,\n  defaultActive = null,\n  onActiveChange,\n  label = \"Rankings over time\",\n}: BumpChartProps) {\n  const model = buildBumpChart(series, periods.length);\n  const [internal, setInternal] = useState(defaultActive);\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string | null>(null);\n  const valid = (id: string | null) => id != null && model.rows.some((row) => row.id === id);\n  // Clear removed identities in this render so reappearing data cannot revive them.\n  if (internal !== null && !valid(internal)) setInternal(null);\n  if (hovered !== null && !valid(hovered)) setHovered(null);\n  if (focused !== null && !valid(focused)) setFocused(null);\n  const selected = active === undefined ? internal : active;\n  const pinned = valid(selected) ? selected : null;\n  const highlighted = valid(hovered) ? hovered : valid(focused) ? focused : pinned;\n  const select = (id: string | null) => {\n    if (active === undefined) setInternal(id);\n    onActiveChange?.(id);\n  };\n  return {\n    ...model,\n    periods,\n    label,\n    pinned,\n    highlighted,\n    select,\n    setHovered,\n    setFocused,\n    reduce: useReducedMotion(),\n    canHover: useHoverCapable(),\n  };\n}\n\nexport const BumpChartContext = createContext<ReturnType<typeof useBumpChartModel> | null>(null);\nexport function useBumpChart() {\n  const context = useContext(BumpChartContext);\n  if (!context) throw new Error(\"Bump chart parts must be rendered inside BumpChart.\");\n  return context;\n}\n"},{"path":"components/charts/bump-chart/legend.tsx","type":"util","content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useBumpChart } from \"./context\";\n\nexport function BumpChartLegend({ className }: { className?: string }) {\n  const { rows, pinned, highlighted, select, setHovered, setFocused, canHover } = useBumpChart();\n  return (\n    <div className={cn(\"grid grid-cols-2 gap-x-5 gap-y-1 sm:grid-cols-3\", className)}>\n      {rows.map((row) => {\n        const first = row.ranks[0];\n        const last = row.ranks.at(-1);\n        const gain = first != null && last != null ? first - last : null;\n        return (\n          <button\n            key={row.id}\n            type=\"button\"\n            aria-label={`Highlight ${row.name}`}\n            aria-pressed={pinned === row.id}\n            onClick={() => select(pinned === row.id ? null : row.id)}\n            onPointerEnter={() => {\n              if (canHover) setHovered(row.id);\n            }}\n            onPointerLeave={() => setHovered(null)}\n            onFocus={() => setFocused(row.id)}\n            onBlur={() => setFocused(null)}\n            onKeyDown={(event) => {\n              if (event.key === \"Escape\") {\n                select(null);\n                setFocused(null);\n                setHovered(null);\n              }\n            }}\n            className={cn(\n              \"flex min-w-0 items-center gap-2 rounded-md px-2 py-2.5 text-xs text-foreground transition-opacity duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n              highlighted && highlighted !== row.id && \"opacity-35\",\n              pinned === row.id && \"bg-muted/60\",\n            )}\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"size-2 shrink-0 rounded-full\"\n              style={{ backgroundColor: row.color }}\n            />\n            <span className=\"truncate\">{row.name}</span>\n            <span className=\"ml-auto font-mono tabular-nums\">\n              {last == null ? \"—\" : `#${last}`}\n            </span>\n            <span\n              className=\"min-w-5 text-right font-mono text-[10px] tabular-nums text-muted-foreground\"\n              title={\n                gain == null\n                  ? \"Change unavailable\"\n                  : gain === 0\n                    ? \"Unchanged\"\n                    : `${gain > 0 ? \"Up\" : \"Down\"} ${Math.abs(gain)} places`\n              }\n            >\n              {gain == null || gain === 0 ? \"—\" : `${gain > 0 ? \"↑\" : \"↓\"}${Math.abs(gain)}`}\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n"},{"path":"components/charts/bump-chart/model.ts","type":"util","content":"export interface BumpSeries {\n  id: string;\n  name: string;\n  /** One rank per period, starting at 1. Null leaves a gap in the path. */\n  ranks: readonly (number | null)[];\n  color?: string;\n}\n\nexport const BUMP_COLORS = [\"#8b5cf6\", \"#0d9488\", \"#f59e0b\", \"#3b82f6\", \"#f43f5e\", \"#a3a33a\"];\nexport const PLOT = { width: 560, left: 32, right: 100, top: 28, bottom: 36, rowHeight: 44 };\n\nexport function buildBumpChart(series: readonly BumpSeries[], periodCount: number) {\n  const seen = new Set<string>();\n  const rows = series\n    .filter((row) => {\n      if (seen.has(row.id)) return false;\n      seen.add(row.id);\n      return true;\n    })\n    .map((row, index) => ({\n      ...row,\n      color: row.color ?? BUMP_COLORS[index % BUMP_COLORS.length],\n      ranks: Array.from({ length: periodCount }, (_, i) => {\n        const rank = row.ranks[i];\n        return rank != null && Number.isSafeInteger(rank) && rank > 0 ? rank : null;\n      }),\n    }));\n  const ranks = [\n    ...new Set(rows.flatMap((row) => row.ranks.filter((rank): rank is number => rank != null))),\n  ].sort((a, b) => a - b);\n  const maxRank = ranks.at(-1) ?? 1;\n  // Preserve numeric rank distances, including skipped ranks, without unbounded chart height.\n  const plotHeight = Math.max(1, Math.min(maxRank - 1, 8)) * PLOT.rowHeight;\n  const height = PLOT.top + plotHeight + PLOT.bottom;\n  const x = (index: number) =>\n    periodCount <= 1\n      ? (PLOT.left + PLOT.width - PLOT.right) / 2\n      : PLOT.left + (index / (periodCount - 1)) * (PLOT.width - PLOT.left - PLOT.right);\n  const y = (rank: number) =>\n    maxRank === 1\n      ? PLOT.top + plotHeight / 2\n      : PLOT.top + ((rank - 1) / (maxRank - 1)) * plotHeight;\n  return { rows, ranks, maxRank, height, x, y };\n}\n\n/** Missing periods break the path rather than inventing continuity. */\nexport function bumpPath(\n  ranks: readonly (number | null)[],\n  x: (i: number) => number,\n  y: (rank: number) => number,\n) {\n  let path = \"\";\n  let previous: { x: number; y: number } | null = null;\n  ranks.forEach((rank, index) => {\n    if (rank == null) {\n      previous = null;\n      return;\n    }\n    const point = { x: x(index), y: y(rank) };\n    if (previous) {\n      const middle = (previous.x + point.x) / 2;\n      path += ` C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`;\n    } else path += ` M ${point.x} ${point.y}`;\n    previous = point;\n  });\n  return path.trim();\n}\n"},{"path":"components/charts/bump-chart/plot.tsx","type":"util","content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useBumpChart } from \"./context\";\nimport { PLOT } from \"./model\";\nimport { BumpChartPoint } from \"./point\";\nimport { BumpChartSeriesPath } from \"./series\";\nimport { pointKey, useBumpGeometry } from \"./use-geometry\";\n\nexport function BumpChartPlot({ className }: { className?: string }) {\n  const { rows, ranks, height, x, y, periods, label, reduce } = useBumpChart();\n  const positions = useBumpGeometry(\n    rows.flatMap((row) =>\n      row.ranks.flatMap((rank, index) =>\n        rank == null ? [] : [{ key: pointKey(row.id, periods[index]), x: x(index), y: y(rank) }],\n      ),\n    ),\n    !!reduce,\n  );\n  if (!periods.length || !ranks.length)\n    return (\n      <div className={cn(\"py-16 text-center text-sm text-muted-foreground\", className)}>\n        No rankings yet\n      </div>\n    );\n  // Keep labels and rank dots readable inside narrow preview containers.\n  const width = Math.max(PLOT.width, periods.length * 64);\n  return (\n    <section\n      aria-label={`${label} plot, scroll horizontally for more periods`}\n      // biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users need to scroll this overflow region.\n      tabIndex={0}\n      className={cn(\n        \"overflow-x-auto rounded-lg focus-visible:outline-2 focus-visible:outline-ring\",\n        className,\n      )}\n    >\n      <div className=\"relative\" style={{ minWidth: width }}>\n        <svg\n          role=\"img\"\n          aria-label={label}\n          viewBox={`0 0 ${PLOT.width} ${height}`}\n          className=\"block w-full overflow-visible\"\n        >\n          <title>{label}</title>\n          <desc>\n            Ranks run from best at the top to lowest at the bottom. Use the legend to highlight or\n            pin a series. Exact values follow in a table.\n          </desc>\n          {ranks.map((rank) => (\n            <line\n              key={rank}\n              x1={PLOT.left}\n              x2={PLOT.width - PLOT.right}\n              y1={y(rank)}\n              y2={y(rank)}\n              className=\"stroke-border\"\n              strokeDasharray=\"2 5\"\n            />\n          ))}\n          {periods.map((period, index) => (\n            <text\n              key={period}\n              x={x(index)}\n              y={height - 8}\n              textAnchor=\"middle\"\n              className=\"fill-muted-foreground font-mono text-[10px]\"\n            >\n              {period}\n            </text>\n          ))}\n          {rows.map((row, index) => (\n            <BumpChartSeriesPath\n              key={row.id}\n              row={row}\n              index={index}\n              positions={periods.map((period) => positions.get(pointKey(row.id, period)) ?? null)}\n            />\n          ))}\n        </svg>\n        <div className=\"pointer-events-none absolute inset-0 overflow-clip\">\n          {rows.map((row, seriesIndex) =>\n            row.ranks.map((rank, point) =>\n              rank == null ? null : (\n                <BumpChartPoint\n                  key={`${row.id}-${periods[point]}`}\n                  seriesId={row.id}\n                  period={periods[point]}\n                  seriesIndex={seriesIndex}\n                  position={positions.get(pointKey(row.id, periods[point]))}\n                />\n              ),\n            ),\n          )}\n        </div>\n      </div>\n      <table className=\"sr-only\">\n        <caption>{label} — exact ranks</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Series</th>\n            {periods.map((period) => (\n              <th key={period} scope=\"col\">\n                {period}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row) => (\n            <tr key={row.id}>\n              <th scope=\"row\">{row.name}</th>\n              {row.ranks.map((rank, index) => (\n                <td key={periods[index]}>{rank ?? \"No data\"}</td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </section>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/charts/bump-chart/point.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useTransform } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useBumpChart } from \"./context\";\nimport { PLOT } from \"./model\";\nimport type { BumpPosition } from \"./use-geometry\";\n\nexport function BumpChartPoint({\n  seriesId,\n  period,\n  seriesIndex,\n  position,\n}: {\n  seriesId: string;\n  period: string;\n  seriesIndex: number;\n  position: BumpPosition | undefined;\n}) {\n  const { rows, periods, highlighted, pinned, select, setHovered, setFocused, canHover, reduce } =\n    useBumpChart();\n  const row = rows.find((item) => item.id === seriesId);\n  const index = periods.indexOf(period);\n  const rank = row?.ranks[index];\n  if (!row || rank == null || !position) return null;\n  const previous = row.ranks[index - 1];\n  const change = previous == null ? null : previous - rank;\n  const active = highlighted === row.id;\n  const dimmed = highlighted !== null && !active;\n  const changeLabel =\n    change == null\n      ? \"No previous rank\"\n      : change === 0\n        ? \"No change\"\n        : `${change > 0 ? \"Up\" : \"Down\"} ${Math.abs(change)} ${Math.abs(change) === 1 ? \"place\" : \"places\"}`;\n\n  return (\n    <MovingPoint position={position}>\n      <div\n        className=\"pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2\"\n        onPointerEnter={() => {\n          if (canHover) setHovered(row.id);\n        }}\n        onPointerLeave={() => setHovered(null)}\n      >\n        <Tooltip\n          delay={60}\n          className=\"min-w-[156px]\"\n          content={\n            <>\n              <span className=\"flex items-center justify-between gap-4 text-[10px] text-muted-foreground\">\n                <span>{row.name}</span>\n                <span>{period}</span>\n              </span>\n              <span className=\"mt-2 flex items-baseline justify-between gap-4\">\n                <span className=\"font-mono text-lg font-medium\" style={{ color: row.color }}>\n                  #{rank}\n                </span>\n                <span className=\"text-xs\">{changeLabel}</span>\n              </span>\n              {previous != null && (\n                <span className=\"mt-1 block text-[10px] text-muted-foreground\">\n                  Previously #{previous} in {periods[index - 1]}\n                </span>\n              )}\n            </>\n          }\n        >\n          <motion.button\n            type=\"button\"\n            aria-label={`${row.name}, ${period}: rank ${rank}`}\n            aria-pressed={pinned === row.id}\n            onFocus={() => setFocused(row.id)}\n            onBlur={() => setFocused(null)}\n            onClick={() => select(pinned === row.id ? null : row.id)}\n            onKeyDown={(event) => {\n              if (event.key === \"Escape\") {\n                select(null);\n                setFocused(null);\n                setHovered(null);\n              }\n            }}\n            initial={reduce ? false : { transform: \"scale(0.85)\", opacity: 0 }}\n            animate={{ transform: \"scale(1)\", opacity: dimmed ? 0.22 : 1 }}\n            transition={{ transform: SPRING_PRESS, opacity: { duration: 0.18, ease: EASE_OUT } }}\n            whileHover={canHover && !reduce ? { transform: \"scale(1.12)\" } : undefined}\n            className=\"relative flex size-7 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n            style={{ color: row.color }}\n          >\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 rounded-full bg-current\"\n              initial={false}\n              animate={{ opacity: active ? 0.12 : 0 }}\n              transition={{ duration: 0.15, ease: EASE_OUT }}\n            />\n            <motion.span\n              className=\"relative flex size-5 items-center justify-center rounded-full border-[1.5px] border-current bg-background font-mono text-[9px] font-medium\"\n              initial={reduce ? false : { transform: \"scale(0.85)\" }}\n              animate={{ transform: \"scale(1)\" }}\n              transition={{\n                ...SPRING_PRESS,\n                delay: reduce ? 0 : Math.min(index, 8) * 0.025 + Math.min(seriesIndex, 4) * 0.015,\n              }}\n            >\n              {rank}\n            </motion.span>\n          </motion.button>\n        </Tooltip>\n      </div>\n    </MovingPoint>\n  );\n}\n\nfunction MovingPoint({ position, children }: { position: BumpPosition; children: ReactNode }) {\n  const { height } = useBumpChart();\n  const transform = useTransform(\n    [position.x, position.y],\n    ([x, y]: number[]) => `translate(${(x / PLOT.width) * 100}%, ${(y / height) * 100}%)`,\n  );\n  return (\n    <motion.div className=\"pointer-events-none absolute inset-0\" style={{ transform }}>\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"components/charts/bump-chart/series.tsx","type":"util","content":"\"use client\";\n\nimport { useId, useRef } from \"react\";\nimport { motion, useTransform, useMotionValueEvent } from \"motion/react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useBumpChart } from \"./context\";\nimport { bumpPath, PLOT } from \"./model\";\nimport type { BumpPosition } from \"./use-geometry\";\n\ntype Row = ReturnType<typeof useBumpChart>[\"rows\"][number];\n\nexport function BumpChartSeriesPath({\n  row,\n  positions,\n  index,\n}: {\n  row: Row;\n  positions: (BumpPosition | null)[];\n  index: number;\n}) {\n  const { height, highlighted, setHovered, canHover, reduce } = useBumpChart();\n  const clip = useId();\n  const values = positions.flatMap((point) => (point ? [point.x, point.y] : []));\n  const path = useTransform(values, (coordinates: number[]) => {\n    let cursor = 0;\n    const points = positions.map((point) =>\n      point ? { x: coordinates[cursor++], y: coordinates[cursor++] } : null,\n    );\n    return bumpPath(\n      points.map((point) => point?.y ?? null),\n      (i) => points[i]?.x ?? 0,\n      (value) => value,\n    );\n  });\n  const last = positions.at(-1);\n  const labelTransform = useTransform(\n    values,\n    () => `translate(${last?.x.get() ?? 0} ${last?.y.get() ?? 0})`,\n  );\n  const labelRef = useRef<SVGGElement>(null);\n  useMotionValueEvent(labelTransform, \"change\", (transform) => {\n    labelRef.current?.setAttribute(\"transform\", transform);\n  });\n  const dimmed = highlighted !== null && highlighted !== row.id;\n  return (\n    <motion.g\n      initial={{ opacity: 0 }}\n      animate={{ opacity: dimmed ? 0.22 : 1 }}\n      transition={{ duration: 0.18, ease: EASE_OUT }}\n      onPointerEnter={() => {\n        if (canHover) setHovered(row.id);\n      }}\n      onPointerLeave={() => setHovered(null)}\n    >\n      <defs>\n        <clipPath id={clip}>\n          <motion.rect\n            x=\"0\"\n            y=\"0\"\n            width={PLOT.width}\n            height={height}\n            initial={reduce ? false : { transform: \"scaleX(0)\" }}\n            animate={{ transform: \"scaleX(1)\" }}\n            transition={{ duration: 0.28, delay: Math.min(index, 4) * 0.025, ease: EASE_OUT }}\n          />\n        </clipPath>\n      </defs>\n      <g clipPath={`url(#${clip})`}>\n        {/* SVG geometry must reshape with the animated points; transforming a whole path cannot express new ranks. */}\n        <motion.path d={path} stroke={row.color} strokeWidth=\"2\" fill=\"none\" />\n        <motion.path\n          d={path}\n          stroke={row.color}\n          strokeWidth=\"5\"\n          fill=\"none\"\n          initial={false}\n          animate={{ opacity: highlighted === row.id ? 0.16 : 0 }}\n          transition={{ duration: 0.18, ease: EASE_OUT }}\n        />\n        <motion.path d={path} stroke=\"transparent\" strokeWidth=\"16\" fill=\"none\" />\n      </g>\n      {last && (\n        <g ref={labelRef} transform={labelTransform.get()}>\n          <text\n            x=\"18\"\n            dominantBaseline=\"central\"\n            fill={row.color}\n            className=\"text-[10px] font-medium\"\n          >\n            {row.name.length > 12 ? `${row.name.slice(0, 11)}…` : row.name}\n            <title>{row.name}</title>\n          </text>\n        </g>\n      )}\n    </motion.g>\n  );\n}\n"},{"path":"components/charts/bump-chart/use-geometry.ts","type":"util","content":"\"use client\";\n\nimport { animate, motionValue, type MotionValue } from \"motion/react\";\nimport { useLayoutEffect, useState } from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\n\nexport type BumpPosition = { x: MotionValue<number>; y: MotionValue<number> };\nexport type BumpTarget = { key: string; x: number; y: number };\nexport const pointKey = (series: string, period: string) => JSON.stringify([series, period]);\n\n/** The curves, dots, and end labels all read these exact same animated coordinates. */\nexport function useBumpGeometry(targets: BumpTarget[], reduce: boolean) {\n  const shape = JSON.stringify(targets.map((target) => target.key));\n  const create = (previous?: Map<string, BumpPosition>) =>\n    new Map(\n      targets.map((target) => [\n        target.key,\n        previous?.get(target.key) ?? { x: motionValue(target.x), y: motionValue(target.y) },\n      ]),\n    );\n  const [stored, setStored] = useState(() => ({ shape, positions: create() }));\n  let positions = stored.positions;\n  if (stored.shape !== shape) {\n    positions = create(stored.positions);\n    setStored({ shape, positions });\n  }\n  // Hover/focus renders do not restart a running transition. Only new geometry does.\n  const snapshot = JSON.stringify(targets);\n  useLayoutEffect(() => {\n    const next: BumpTarget[] = JSON.parse(snapshot);\n    const controls: ReturnType<typeof animate>[] = [];\n    for (const target of next) {\n      const position = positions.get(target.key);\n      if (!position) continue;\n      for (const axis of [\"x\", \"y\"] as const) {\n        if (reduce) position[axis].jump(target[axis]);\n        else controls.push(animate(position[axis], target[axis], SPRING_LAYOUT));\n      }\n    }\n    return () => {\n      for (const control of controls) control.stop();\n    };\n  }, [snapshot, reduce, positions]);\n  return positions;\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport { TooltipPositioner } from \"./tooltip/positioner\";\nimport { useTooltipPointer } from \"./tooltip/use-position\";\nimport {\n  cloneElement,\n  isValidElement,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children?: ReactElement;\n  /** Existing trigger for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<HTMLElement | SVGElement | null>;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: { x: number; y: number };\n  /** Follow real pointer coordinates; keyboard focus still uses the anchor. */\n  followCursor?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  id?: string;\n  side?: Side;\n  /** Delay before showing (ms). Default 120. */\n  delay?: number;\n  className?: string;\n  /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */\n  wrapperClassName?: string;\n}\n\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delay — moving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({\n  content,\n  children,\n  side = \"top\",\n  delay = 120,\n  className,\n  wrapperClassName,\n  anchorRef: externalAnchorRef,\n  anchorPoint,\n  followCursor = false,\n  open: controlledOpen,\n  onOpenChange,\n  id: providedId,\n}: TooltipProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const open = controlledOpen ?? internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlledOpen === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n  const generatedId = useId();\n  const id = providedId ?? generatedId;\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const anchorRef = externalAnchorRef ?? wrapperRef;\n  const hover = useHoverGesture();\n  const floatingRef = useRef<HTMLSpanElement | null>(null);\n  const pointer = useTooltipPointer(anchorRef, followCursor);\n  const focused = useRef(false);\n\n  const show = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    if (open) return;\n    const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n    if (warm) {\n      setOpen(true);\n      return;\n    }\n    timer.current = setTimeout(() => {\n      setOpen(true);\n    }, delay);\n  }, [delay, setOpen, open]);\n\n  const hide = useCallback(() => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n    if (open) lastHiddenAt = Date.now();\n    setOpen(false);\n  }, [open, setOpen]);\n\n  const leave = useCallback(() => {\n    if (focused.current) return;\n    if (timer.current) clearTimeout(timer.current);\n    // Bridge the small physical gap to a stationary, readable tooltip.\n    if (followCursor) hide();\n    else timer.current = setTimeout(hide, 100);\n  }, [followCursor, hide]);\n  const insideTooltip = useCallback(\n    (target: Element) => Boolean(floatingRef.current?.contains(target)),\n    [],\n  );\n\n  // A finger never hovers, and Safari does not focus a button on tap either, so\n  // the label is only reachable if the tap itself opens the tooltip. A click\n  // carries no pointerType, so the pointerdown that preceded it is what says\n  // whether this was a tap; keyboard activation arrives with no pointerdown at\n  // all, and focus has already shown the label there.\n  const tap = useTapGesture<boolean>();\n\n  const toggleOnTap = useCallback(() => {\n    const gesture = tap.take();\n    if (!gesture || gesture.pointerType === \"mouse\") return;\n    if (gesture.state) {\n      hide();\n      return;\n    }\n    if (timer.current) clearTimeout(timer.current);\n    setOpen(true);\n  }, [hide, tap, setOpen]);\n\n  // ...and closed again by the next tap that lands somewhere else. The label\n  // covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, hide, anchorRef, { ignore: insideTooltip });\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  if (!externalAnchorRef && !isValidElement(children)) return children;\n\n  // The label describes the trigger, so it has to name the trigger itself.\n  // Everything else the tooltip needs is read off the anchor below instead of\n  // cloned on: a handler written onto the child is the child's handler as far\n  // as that child can tell, and a component that owns its activation —\n  // hard-wiring onClick and spreading the rest of its props over it, as\n  // ThemeToggle does — then runs the tooltip's instead of its own. Composing\n  // with `props.onClick` cannot save it either, because a component element's\n  // props hold nothing the component does internally.\n  const trigger = isValidElement(children)\n    ? cloneElement(children as ReactElement<Record<string, unknown>>, {\n        \"aria-describedby\":\n          [(children.props as Record<string, unknown>)[\"aria-describedby\"], open ? id : undefined]\n            .filter(Boolean)\n            .join(\" \") || undefined,\n      })\n    : null;\n\n  return (\n    <>\n      {!externalAnchorRef ? (\n        // biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.\n        <span\n          ref={wrapperRef}\n          className={cn(\"relative inline-flex align-middle\", wrapperClassName)}\n          // Pointer events, not the mouse pair: a tap fires compatibility\n          // mouseenter/mouseleave that carry no pointerType, which raced the tap\n          // path into opening and closing the same label.\n          onPointerEnter={(event: PointerEvent) => {\n            if (hover.enter(event)) show();\n          }}\n          onPointerLeave={(event: PointerEvent) => {\n            if (hover.leave(event)) leave();\n          }}\n          onFocus={() => {\n            focused.current = true;\n            show();\n          }}\n          onBlur={() => {\n            focused.current = false;\n            hide();\n          }}\n          onPointerDown={(event: PointerEvent) => tap.start(event, open)}\n          // A gesture the platform took away sends no click, and a key press\n          // starts an activation that never had a pointer behind it. Either way\n          // the record has to go, or the next click reads a finger that has long\n          // since lifted.\n          onPointerCancel={tap.drop}\n          onKeyDown={(event) => {\n            tap.drop();\n            if (event.key === \"Escape\") hide();\n          }}\n          onClick={toggleOnTap}\n        >\n          {trigger}\n        </span>\n      ) : null}\n      {typeof document !== \"undefined\"\n        ? createPortal(\n            <AnimatePresence>\n              {open ? (\n                <TooltipPositioner\n                  key=\"tooltip\"\n                  anchorRef={anchorRef}\n                  floatingRef={floatingRef}\n                  anchorPoint={anchorPoint}\n                  followCursor={followCursor}\n                  side={side}\n                  onDismiss={hide}\n                  pointer={pointer}\n                >\n                  {(positioned, isPresent) => (\n                    <TooltipSurface\n                      id={id}\n                      ready={positioned}\n                      side={side}\n                      onPointerEnter={() => {\n                        if (timer.current) clearTimeout(timer.current);\n                      }}\n                      onPointerLeave={leave}\n                      style={{\n                        maxWidth: \"calc(100vw - 16px)\",\n                        whiteSpace: \"normal\",\n                        pointerEvents: isPresent && !followCursor ? \"auto\" : \"none\",\n                      }}\n                      className={cn(\"overflow-hidden\", className)}\n                    >\n                      {content}\n                    </TooltipSurface>\n                  )}\n                </TooltipPositioner>\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"components/motion/tooltip/positioner.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport { useCallback, useState, type ReactNode } from \"react\";\nimport { useTooltipPosition } from \"./use-position\";\n\ntype PositionProps = Parameters<typeof useTooltipPosition>[0];\n\n/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */\nexport function TooltipPositioner({\n  children,\n  ...position\n}: Omit<PositionProps, \"open\" | \"onPosition\"> & {\n  children: (ready: boolean, present: boolean) => ReactNode;\n}) {\n  const present = useIsPresent();\n  const [ready, setReady] = useState(false);\n  const onPosition = useCallback(() => setReady(true), []);\n  useTooltipPosition({ ...position, open: present, onPosition });\n  return (\n    <span\n      ref={position.floatingRef}\n      inert={!present}\n      aria-hidden={!present || undefined}\n      className=\"pointer-events-none fixed left-0 top-0 z-[9999] w-max\"\n      style={{ visibility: \"hidden\", maxWidth: \"calc(100vw - 16px)\" }}\n    >\n      {children(ready, present)}\n    </span>\n  );\n}\n"},{"path":"components/motion/tooltip/use-position.ts","type":"util","content":"\"use client\";\n\nimport {\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n  type Placement,\n  type VirtualElement,\n} from \"@floating-ui/dom\";\nimport { useCallback, useLayoutEffect, useRef, type RefObject } from \"react\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipPoint = { x: number; y: number };\n\n/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */\nexport function useTooltipPosition({\n  open,\n  anchorRef,\n  floatingRef,\n  anchorPoint,\n  followCursor,\n  side,\n  onDismiss,\n  onPosition,\n  pointer,\n}: {\n  open: boolean;\n  anchorRef: RefObject<HTMLElement | SVGElement | null>;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  anchorPoint?: TooltipPoint;\n  followCursor: boolean;\n  side: TooltipSide;\n  onDismiss: () => void;\n  onPosition: () => void;\n  pointer: ReturnType<typeof useTooltipPointer>;\n}) {\n  const { cursor, onMove } = pointer;\n  const cursorSide = useRef<Placement | null>(null);\n  useLayoutEffect(() => {\n    cursorSide.current = open ? side : null;\n  }, [open, side]);\n  const frame = useRef<number | null>(null);\n  const version = useRef(0);\n  const update = useRef<() => void>(() => {});\n  const schedule = useCallback(() => {\n    // Invalidate older async calculations as soon as new geometry is requested.\n    version.current++;\n    if (frame.current !== null) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = null;\n      update.current();\n    });\n  }, []);\n  useLayoutEffect(() => {\n    onMove.current = schedule;\n    return () => {\n      onMove.current = null;\n    };\n  }, [onMove, schedule]);\n  const pointX = anchorPoint?.x;\n  const pointY = anchorPoint?.y;\n\n  // Latest committed inputs are read without recreating observers for every period/content update.\n  useLayoutEffect(() => {\n    update.current = () => {\n      const anchor = anchorRef.current;\n      const floating = floatingRef.current;\n      if (!open || !anchor || !floating) return;\n      const revision = ++version.current;\n      const currentCursor = followCursor ? cursor.current : null;\n      const reference: Element | VirtualElement =\n        currentCursor || pointX !== undefined || pointY !== undefined\n          ? {\n              contextElement: anchor,\n              getBoundingClientRect: () => {\n                const rect = anchor.getBoundingClientRect();\n                const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);\n                const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);\n                return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };\n              },\n            }\n          : anchor;\n      void computePosition(reference, floating, {\n        strategy: \"fixed\",\n        placement: currentCursor ? (cursorSide.current ?? side) : side,\n        middleware: [offset(currentCursor ? 12 : 8), flip({ padding: 8 }), shift({ padding: 8 })],\n      }).then(({ x, y, placement }) => {\n        if (version.current !== revision || !floating.isConnected) return;\n        // Hold the chosen side for this hover session. Crossing a flip threshold\n        // repeatedly must not bounce the surface above and below the pointer.\n        if (currentCursor) cursorSide.current = placement;\n        else cursorSide.current = null;\n        const dpr = window.devicePixelRatio || 1;\n        floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;\n        const origin = {\n          top: \"center bottom\",\n          bottom: \"center top\",\n          left: \"right center\",\n          right: \"left center\",\n        };\n        floating.style.setProperty(\n          \"--tooltip-origin\",\n          origin[placement.split(\"-\")[0] as TooltipSide],\n        );\n        floating.style.visibility = \"visible\";\n        floating.dataset.placement = placement;\n        onPosition();\n      });\n    };\n    if (open) schedule();\n  }, [\n    open,\n    anchorRef,\n    floatingRef,\n    followCursor,\n    pointX,\n    pointY,\n    side,\n    schedule,\n    onPosition,\n    cursor,\n  ]);\n\n  useLayoutEffect(() => {\n    const anchor = anchorRef.current;\n    const floating = floatingRef.current;\n    if (!open || !anchor || !floating) return;\n    const stop = autoUpdate(anchor, floating, schedule);\n    const onScroll = () => {\n      // A stationary pointer no longer describes the same chart point after scrolling.\n      if (followCursor && cursor.current) onDismiss();\n    };\n    window.addEventListener(\"scroll\", onScroll, true);\n    return () => {\n      stop();\n      window.removeEventListener(\"scroll\", onScroll, true);\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n      frame.current = null;\n    };\n  }, [open, anchorRef, floatingRef, followCursor, onDismiss, schedule, cursor]);\n\n  useLayoutEffect(\n    () => () => {\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n}\n\n/** Pointer lifetime belongs to the trigger, including the opening delay. */\nexport function useTooltipPointer(\n  anchorRef: RefObject<HTMLElement | SVGElement | null>,\n  followCursor: boolean,\n) {\n  const cursor = useRef<TooltipPoint | null>(null);\n  const onMove = useRef<(() => void) | null>(null);\n  const pointerFocus = useRef(false);\n  useLayoutEffect(() => {\n    const anchor = anchorRef.current;\n    if (!anchor || !followCursor) return;\n    const point = (event: PointerEvent) => {\n      if (event.type === \"pointermove\" && event.pointerType === \"touch\" && !event.buttons) return;\n      cursor.current = { x: event.clientX, y: event.clientY };\n      if (event.type === \"pointerdown\") pointerFocus.current = true;\n      onMove.current?.();\n    };\n    const keyboard = () => {\n      cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const focus = () => {\n      if (!pointerFocus.current) cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const leave = () => {\n      cursor.current = null;\n    };\n    anchor.addEventListener(\"pointerenter\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointermove\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerdown\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerleave\", leave);\n    anchor.addEventListener(\"pointercancel\", leave);\n    anchor.addEventListener(\"keydown\", keyboard);\n    anchor.addEventListener(\"focusin\", focus);\n    return () => {\n      cursor.current = null;\n      anchor.removeEventListener(\"pointerenter\", point as EventListener);\n      anchor.removeEventListener(\"pointermove\", point as EventListener);\n      anchor.removeEventListener(\"pointerdown\", point as EventListener);\n      anchor.removeEventListener(\"pointerleave\", leave);\n      anchor.removeEventListener(\"pointercancel\", leave);\n      anchor.removeEventListener(\"keydown\", keyboard);\n      anchor.removeEventListener(\"focusin\", focus);\n    };\n  }, [anchorRef, followCursor]);\n\n  return { cursor, onMove };\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/** Presentation only: the unanimated parent owns measurement and positioning. */\nexport function TooltipSurface({\n  children,\n  side: _side = \"top\",\n  className,\n  ref,\n  ready = true,\n  style,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  /** Start the entrance only after the positioning layer has been measured. */\n  ready?: boolean;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const closed = { opacity: 0, scale: reduce ? 1 : 0.94 };\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      initial={closed}\n      animate={{\n        ...(ready ? { opacity: 1, scale: 1 } : closed),\n        transition: { duration: 0.18, ease: EASE_OUT },\n      }}\n      exit={{ ...closed, transition: { duration: 0.12, ease: EASE_OUT } }}\n      style={{ transformOrigin: \"var(--tooltip-origin, center)\", ...style }}\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/previews/charts/bump-chart.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { Shuffle } from \"lucide-react\";\nimport { Button } from \"@/components/motion/button\";\nimport { BumpChart, BumpChartLegend, BumpChartPlot } from \"@/components/charts/bump-chart\";\n\nconst periods = [\"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\"];\n// Illustrative product rankings; replace these with your own period-by-period ranks.\nconst initialSeries = [\n  { id: \"studio\", name: \"Studio\", ranks: [4, 3, 2, 3, 2, 1], color: \"#8b5cf6\" },\n  { id: \"canvas\", name: \"Canvas\", ranks: [1, 1, 3, 2, 1, 2], color: \"#0d9488\" },\n  { id: \"layers\", name: \"Layers\", ranks: [3, 2, 1, 1, 3, 3], color: \"#f59e0b\" },\n  { id: \"orbit\", name: \"Orbit\", ranks: [2, 4, 5, 4, 5, 4], color: \"#3b82f6\" },\n  { id: \"frame\", name: \"Frame\", ranks: [5, 5, 4, 5, 4, 5], color: \"#f43f5e\" },\n];\n\nexport function BumpChartPreview() {\n  const [series, setSeries] = useState(initialSeries);\n  const shuffle = () => {\n    // Each period is a permutation: exactly one product at each rank.\n    const columns = periods.map(() => {\n      const ranks = initialSeries.map((_, index) => index + 1);\n      for (let i = ranks.length - 1; i > 0; i--) {\n        const j = Math.floor(Math.random() * (i + 1));\n        [ranks[i], ranks[j]] = [ranks[j], ranks[i]];\n      }\n      return ranks;\n    });\n    setSeries((previous) =>\n      previous.map((row, index) => ({ ...row, ranks: columns.map((ranks) => ranks[index]) })),\n    );\n  };\n  return (\n    <div className=\"w-full space-y-6\">\n      <div className=\"flex flex-wrap items-center justify-between gap-4 px-2\">\n        <div>\n          <h3 className=\"text-sm font-medium text-foreground\">The leaderboard</h3>\n          <p className=\"mt-1 text-xs text-muted-foreground\">\n            Five products. Six months. Every move.\n          </p>\n        </div>\n        <Button variant=\"ghost\" size=\"sm\" onClick={shuffle} className=\"shrink-0 gap-2\">\n          <Shuffle className=\"size-3.5\" aria-hidden=\"true\" />\n          Shuffle rankings\n        </Button>\n      </div>\n      <BumpChart\n        series={series}\n        periods={periods}\n        label=\"Illustrative product rankings, April to September\"\n      >\n        <BumpChartPlot />\n        <BumpChartLegend />\n      </BumpChart>\n      <p className=\"text-center text-[11px] text-muted-foreground\">\n        Inspect a dot for details · Select to pin a product\n      </p>\n    </div>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"util","content":"export type {\n  ButtonLinkProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./base\";\nexport { Button, ButtonLink } from \"./base\";\nexport type { MagneticButtonProps } from \"./magnetic\";\nexport { MagneticButton } from \"./magnetic\";\nexport type { MetallicButtonProps } from \"./metallic\";\nexport { MetallicButton } from \"./metallic\";\nexport type { ButtonState, StatefulButtonProps } from \"./stateful\";\nexport { StatefulButton } from \"./stateful\";\n"},{"path":"components/motion/button/base.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-muted/60\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/metallic.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { forwardRef, useState } from \"react\";\nimport { EASE_IN_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MetallicButtonProps extends Omit<\n  ButtonProps,\n  \"ripple\" | \"variant\"\n> {\n  /** Stops the traveling reflection while preserving the chrome rim. */\n  paused?: boolean;\n}\n\n// The rim and highlight drift separately so the material stays quiet and reflective.\nconst SILVER_DRIFT = {\n  duration: 8,\n  ease: EASE_IN_OUT,\n  repeat: Infinity,\n};\n\nconst CHROME_SHIMMER = {\n  duration: 2.4,\n  ease: EASE_IN_OUT,\n};\n\nexport const MetallicButton = forwardRef<\n  HTMLButtonElement,\n  MetallicButtonProps\n>(function MetallicButton(\n  {\n    size = \"md\",\n    paused = false,\n    className,\n    children,\n    onHoverStart,\n    onHoverEnd,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const still = paused || Boolean(reduce);\n  const [hovered, setHovered] = useState(false);\n\n  return (\n    <Button\n      ref={ref}\n      variant=\"ghost\"\n      size={size}\n      onHoverStart={(event, info) => {\n        setHovered(true);\n        onHoverStart?.(event, info);\n      }}\n      onHoverEnd={(event, info) => {\n        setHovered(false);\n        onHoverEnd?.(event, info);\n      }}\n      className={cn(\n        \"group relative isolate overflow-hidden border-0 bg-transparent text-foreground\",\n        \"hover:bg-transparent hover:text-foreground\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        \"shadow-[0_8px_22px_rgba(0,0,0,0.16)]\",\n        size === \"icon\" && \"rounded-full\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]\"\n        animate={still ? undefined : { x: [\"0%\", \"13%\", \"0%\"] }}\n        transition={still ? undefined : SILVER_DRIFT}\n      />\n\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen\"\n        animate={still ? undefined : { x: hovered ? \"310%\" : \"0%\" }}\n        transition={still ? undefined : CHROME_SHIMMER}\n      />\n\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40\"\n      />\n\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]\"\n      />\n\n      <span className=\"relative z-10 inline-flex items-center justify-center gap-2\">\n        {children}\n      </span>\n    </Button>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"util","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"components/motion/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}