{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sankey-chart","type":"registry:component","title":"Sankey Chart","description":"Weighted flows between stages that draw in and morph with your data.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["@floating-ui/dom","clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/sankey-chart.tsx","type":"registry:component","target":"@components/charts/sankey-chart.tsx","content":"\"use client\";\n// beui.dev/charts/sankey-chart\n\nimport { cn } from \"@/lib/utils\";\nimport { SankeyContext, type SankeyChartProps, useSankeyModel } from \"./sankey-chart/context\";\nimport { SankeyChartPlot } from \"./sankey-chart/plot\";\nimport { SankeyChartTooltip } from \"./sankey-chart/tooltip\";\n\n/**\n * Weighted flows between nodes, laid out in columns from sources to outcomes.\n * Compose Plot and Tooltip, or omit children for the complete chart.\n *\n * ```tsx\n * <SankeyChart nodes={nodes} links={links} formatValue={usd}>\n *   <SankeyChartPlot />\n *   <SankeyChartTooltip />\n * </SankeyChart>\n * ```\n */\nexport function SankeyChart({\n  nodes,\n  links,\n  label,\n  formatValue,\n  colors,\n  nodeWidth,\n  nodePadding,\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  className,\n  children,\n  ...props\n}: SankeyChartProps) {\n  const model = useSankeyModel({\n    nodes,\n    links,\n    label,\n    formatValue,\n    colors,\n    nodeWidth,\n    nodePadding,\n    activeId,\n    defaultActiveId,\n    onActiveChange,\n  });\n  return (\n    <SankeyContext.Provider value={model}>\n      <section\n        aria-label={model.label}\n        {...props}\n        data-slot=\"sankey-chart\"\n        className={cn(\"w-full min-w-0\", className)}\n      >\n        {children === undefined ? (\n          <>\n            <SankeyChartPlot />\n            <SankeyChartTooltip />\n          </>\n        ) : (\n          children\n        )}\n        <div className=\"sr-only\">\n          <table>\n            <caption>{model.label} data</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Flow</th>\n                <th scope=\"col\">Value</th>\n                <th scope=\"col\">Share of source</th>\n              </tr>\n            </thead>\n            <tbody>\n              {model.flows.map((flow) => (\n                <tr key={flow.id}>\n                  <th scope=\"row\">\n                    {flow.source} to {flow.target}\n                  </th>\n                  <td>{model.formatValue(flow.value)}</td>\n                  <td>{flow.share ? `${(flow.share * 100).toFixed(1)}%` : \"\"}</td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </section>\n    </SankeyContext.Provider>\n  );\n}\n\nexport {\n  type SankeyActive,\n  type SankeyChartProps,\n  useSankeyChart,\n} from \"./sankey-chart/context\";\nexport type {\n  PlacedLink as SankeyPlacedLink,\n  PlacedNode as SankeyPlacedNode,\n  SankeyLink,\n  SankeyNode,\n} from \"./sankey-chart/model\";\nexport { SankeyChartPlot, type SankeyChartPlotProps } from \"./sankey-chart/plot\";\nexport { SankeyChartTooltip, type SankeyChartTooltipProps } from \"./sankey-chart/tooltip\";\n"},{"path":"components/charts/sankey-chart/context.tsx","type":"registry:component","target":"@components/charts/sankey-chart/context.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\nimport {\n  cleanSankey,\n  layoutSankey,\n  type PlacedLink,\n  type PlacedNode,\n  SANKEY_COLORS,\n  type SankeyLink,\n  type SankeyNode,\n} from \"./model\";\n\nexport interface SankeyChartProps extends ComponentProps<\"section\"> {\n  nodes: readonly SankeyNode[];\n  /** Weighted flows between node ids. Links that would close a loop are left out. */\n  links: readonly SankeyLink[];\n  label?: string;\n  formatValue?: (value: number) => string;\n  /** Palette for nodes without their own color, in node order. */\n  colors?: readonly string[];\n  /** Node bar width in px. */\n  nodeWidth?: number;\n  /** Space between nodes in a column, in px. */\n  nodePadding?: number;\n  /** Highlighted node or link id. Controlled. Link ids default to `source:target`. */\n  activeId?: string | null;\n  defaultActiveId?: string | null;\n  onActiveChange?: (id: string | null) => void;\n}\n\nexport type SankeyActive =\n  | { type: \"node\"; node: PlacedNode }\n  | { type: \"link\"; link: PlacedLink };\n\nconst defaultFormat = (value: number) =>\n  value.toLocaleString(\"en-US\", { maximumFractionDigits: 2 });\n\nexport function useSankeyModel({\n  nodes,\n  links,\n  label = \"Sankey chart\",\n  formatValue = defaultFormat,\n  colors = SANKEY_COLORS,\n  nodeWidth = 12,\n  nodePadding = 16,\n  activeId,\n  defaultActiveId = null,\n  onActiveChange,\n}: SankeyChartProps) {\n  const data = useMemo(() => cleanSankey(nodes, links), [nodes, links]);\n  useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    const { invalid, cycle } = data.dropped;\n    if (invalid.length || cycle.length)\n      console.warn(\n        `SankeyChart left out ${invalid.length} invalid and ${cycle.length} loop-closing link(s).`,\n        { invalid, cycle },\n      );\n  }, [data]);\n\n  const [size, setSize] = useState({ width: 640, height: 320 });\n  // A resize re-lays the chart at once; only a data change springs. The\n  // render that commits a new size carries `resized`, so its transitions are\n  // instant; the follow-up render has the same targets and starts nothing.\n  const sizeKey = `${size.width}x${size.height}`;\n  const [settledSize, setSettledSize] = useState(sizeKey);\n  const resized = settledSize !== sizeKey;\n  useLayoutEffect(() => {\n    if (resized) setSettledSize(sizeKey);\n  }, [resized, sizeKey]);\n\n  const layout = useMemo(\n    () =>\n      layoutSankey(data, {\n        width: size.width,\n        height: size.height,\n        nodeWidth,\n        nodePadding,\n        colors,\n      }),\n    [data, size, nodeWidth, nodePadding, colors],\n  );\n  const cursor = useRowCursor(layout.nodes, \"\");\n\n  const [internalId, setInternalId] = useState(defaultActiveId);\n  const requestedId = activeId === undefined ? internalId : activeId;\n  // Resolved during render by id: a node or link that left the data clears.\n  const node = layout.nodes.find((n) => n.id === requestedId);\n  const link = node ? undefined : layout.links.find((l) => l.id === requestedId);\n  const active: SankeyActive | null = node\n    ? { type: \"node\", node }\n    : link\n      ? { type: \"link\", link }\n      : null;\n  if (activeId === undefined && internalId !== null && !active) setInternalId(null);\n\n  const latest = useRef({ activeId, requestedId, onActiveChange });\n  useLayoutEffect(() => {\n    latest.current = { activeId, requestedId, onActiveChange };\n  });\n  const setActive = useCallback((id: string | null) => {\n    const current = latest.current;\n    if (current.activeId === undefined) setInternalId(id);\n    if (current.requestedId !== id) current.onActiveChange?.(id);\n  }, []);\n\n  const [tooltipOpen, setTooltipOpen] = useState(false);\n  const plotRef = useRef<HTMLDivElement>(null);\n  const buttons = useRef(new Map<string, HTMLButtonElement>());\n  const registerButton = useCallback((id: string, element: HTMLButtonElement | null) => {\n    if (element) buttons.current.set(id, element);\n    else buttons.current.delete(id);\n  }, []);\n\n  // Every drawable flow for the exact-data table, with shares from the\n  // cleaned data so the table does not wait on a measured size.\n  const totals = new Map<string, number>();\n  for (const l of data.links) {\n    totals.set(l.source, (totals.get(l.source) ?? 0) + l.value);\n  }\n  const incoming = new Map<string, number>();\n  for (const l of data.links) incoming.set(l.target, (incoming.get(l.target) ?? 0) + l.value);\n  const labels = new Map(data.nodes.map((n) => [n.id, n.label]));\n  const flows = data.links.map((l) => {\n    const sourceValue = Math.max(totals.get(l.source) ?? 0, incoming.get(l.source) ?? 0);\n    return {\n      id: l.id ?? `${l.source}:${l.target}`,\n      source: labels.get(l.source) ?? l.source,\n      target: labels.get(l.target) ?? l.target,\n      value: l.value,\n      share: sourceValue ? l.value / sourceValue : 0,\n    };\n  });\n  const empty = !data.links.length ? (nodes.length ? \"No flows to show\" : \"No sankey data\") : null;\n\n  return {\n    ...layout,\n    flows,\n    empty,\n    resized,\n    size,\n    setSize,\n    nodeWidth,\n    label,\n    formatValue,\n    active,\n    setActive,\n    cursor,\n    tooltipOpen,\n    setTooltipOpen,\n    tooltipId: useId(),\n    gradientId: useId(),\n    plotRef,\n    buttons,\n    registerButton,\n    reduce: useReducedMotion() ?? false,\n    canHover: useHoverCapable(),\n  };\n}\n\nexport type SankeyModel = ReturnType<typeof useSankeyModel>;\nexport const SankeyContext = createContext<SankeyModel | null>(null);\n\nexport function useSankeyChart() {\n  const model = useContext(SankeyContext);\n  if (!model) throw new Error(\"Sankey chart parts must be rendered inside SankeyChart.\");\n  return model;\n}\n\n/** Whether a node or link is part of what is highlighted. */\nexport function emphasis(active: SankeyActive | null) {\n  const node = (n: PlacedNode) => {\n    if (!active) return true;\n    if (active.type === \"link\") return active.link.source === n || active.link.target === n;\n    if (active.node === n) return true;\n    return false;\n  };\n  const link = (l: PlacedLink) => {\n    if (!active) return true;\n    if (active.type === \"link\") return active.link === l;\n    return l.source === active.node || l.target === active.node;\n  };\n  return { node, link };\n}\n"},{"path":"components/charts/sankey-chart/model.ts","type":"registry:component","target":"@components/charts/sankey-chart/model.ts","content":"export interface SankeyNode {\n  id: string;\n  label: string;\n  /** Any CSS color. Defaults to the palette, in node order. */\n  color?: string;\n  description?: string;\n}\n\nexport interface SankeyLink {\n  /** Defaults to `${source}:${target}`. */\n  id?: string;\n  source: string;\n  target: string;\n  /** Positive value. Zero, negative and invalid values are left out. */\n  value: number;\n}\n\nexport type PlacedNode = Required<Pick<SankeyNode, \"id\" | \"label\" | \"color\">> & {\n  description?: string;\n  /** Larger of the node's incoming and outgoing totals. */\n  value: number;\n  inValue: number;\n  outValue: number;\n  /** Column, counted from the sources. */\n  depth: number;\n  x0: number;\n  x1: number;\n  y0: number;\n  y1: number;\n};\n\nexport type PlacedLink = {\n  id: string;\n  source: PlacedNode;\n  target: PlacedNode;\n  value: number;\n  /** Share of the source node's value, from 0 to 1. */\n  share: number;\n  /** Band thickness. */\n  width: number;\n  /** Centerline ends: leaving the source and arriving at the target. */\n  x0: number;\n  y0: number;\n  x1: number;\n  y1: number;\n  /** Centerline cubic path; stroke it at `width`. */\n  path: string;\n};\n\nexport const SANKEY_COLORS = [\n  \"#1d4ed8\",\n  \"#7c3aed\",\n  \"#0f766e\",\n  \"#b45309\",\n  \"#be123c\",\n  \"#475569\",\n] as const;\n\n/** Relaxation passes; more settles crossings further at a small cost. */\nconst ITERATIONS = 6;\n\nexport const linkId = (link: SankeyLink) => link.id ?? `${link.source}:${link.target}`;\n\nexport type SankeyData = {\n  nodes: SankeyNode[];\n  links: SankeyLink[];\n  /** Links left out, by reason, for the development warning. */\n  dropped: { invalid: SankeyLink[]; cycle: SankeyLink[] };\n};\n\n/**\n * Cleans consumer data once per change, not per resize:\n * - repeated node ids keep the first node;\n * - links to unknown nodes, self links and non-positive or non-finite values\n *   are dropped;\n * - unnamed links between the same pair are summed into one band, while a\n *   repeated explicit id keeps the first link;\n * - a link closing a cycle is dropped: one whose target is still on the\n *   depth-first path that reached its source, so the first-listed direction\n *   of a loop survives.\n */\nexport function cleanSankey(\n  nodes: readonly SankeyNode[],\n  links: readonly SankeyLink[],\n): SankeyData {\n  const byId = new Map<string, SankeyNode>();\n  for (const node of nodes) if (!byId.has(node.id)) byId.set(node.id, node);\n  const unique = [...byId.values()];\n\n  const invalid: SankeyLink[] = [];\n  const merged = new Map<string, SankeyLink>();\n  for (const link of links) {\n    const ok =\n      byId.has(link.source) &&\n      byId.has(link.target) &&\n      link.source !== link.target &&\n      Number.isFinite(link.value) &&\n      link.value > 0;\n    if (!ok) {\n      invalid.push(link);\n      continue;\n    }\n    const id = linkId(link);\n    const existing = merged.get(id);\n    if (!existing) merged.set(id, { ...link, id });\n    else if (link.id === undefined && existing.source === link.source && existing.target === link.target)\n      existing.value += link.value;\n    else invalid.push(link);\n  }\n  const valid = [...merged.values()];\n\n  const outgoing = new Map<string, SankeyLink[]>();\n  for (const link of valid) {\n    outgoing.set(link.source, [...(outgoing.get(link.source) ?? []), link]);\n  }\n  const state = new Map<string, \"open\" | \"done\">();\n  const back = new Set<SankeyLink>();\n  // Iterative depth-first search, so a very long chain cannot overflow the stack.\n  for (const root of unique) {\n    if (state.has(root.id)) continue;\n    const stack: { id: string; next: number }[] = [{ id: root.id, next: 0 }];\n    state.set(root.id, \"open\");\n    while (stack.length) {\n      const frame = stack[stack.length - 1];\n      const edges = outgoing.get(frame.id) ?? [];\n      if (frame.next >= edges.length) {\n        state.set(frame.id, \"done\");\n        stack.pop();\n        continue;\n      }\n      const link = edges[frame.next++];\n      const seen = state.get(link.target);\n      if (seen === \"open\") back.add(link);\n      else if (seen === undefined) {\n        state.set(link.target, \"open\");\n        stack.push({ id: link.target, next: 0 });\n      }\n    }\n  }\n  return {\n    nodes: unique,\n    links: valid.filter((link) => !back.has(link)),\n    dropped: { invalid, cycle: valid.filter((link) => back.has(link)) },\n  };\n}\n\n/**\n * Lays nodes out in columns by their longest path from a source, with sinks\n * pushed to the last column, then sizes them to their value and relaxes each\n * column toward its neighbours to shorten the links. Each column keeps the\n * order the nodes were given in, so order them to avoid crossings.\n */\nexport function layoutSankey(\n  { nodes, links: kept }: SankeyData,\n  {\n    width,\n    height,\n    nodeWidth,\n    nodePadding,\n    colors,\n  }: {\n    width: number;\n    height: number;\n    nodeWidth: number;\n    nodePadding: number;\n    colors: readonly string[];\n  },\n) {\n  const inValue = new Map<string, number>();\n  const outValue = new Map<string, number>();\n  for (const link of kept) {\n    outValue.set(link.source, (outValue.get(link.source) ?? 0) + link.value);\n    inValue.set(link.target, (inValue.get(link.target) ?? 0) + link.value);\n  }\n\n  // Longest-path depth over the acyclic links, in topological order.\n  const indegree = new Map<string, number>();\n  for (const link of kept) indegree.set(link.target, (indegree.get(link.target) ?? 0) + 1);\n  const depth = new Map<string, number>();\n  const queue = nodes.filter((node) => !indegree.get(node.id)).map((node) => node.id);\n  for (const id of queue) depth.set(id, 0);\n  for (let i = 0; i < queue.length; i++) {\n    const id = queue[i];\n    for (const link of kept) {\n      if (link.source !== id) continue;\n      depth.set(link.target, Math.max(depth.get(link.target) ?? 0, (depth.get(id) ?? 0) + 1));\n      const left = (indegree.get(link.target) ?? 0) - 1;\n      indegree.set(link.target, left);\n      if (left === 0) queue.push(link.target);\n    }\n  }\n\n  const linked = nodes.filter((node) => inValue.has(node.id) || outValue.has(node.id));\n  if (!linked.length || width <= 0 || height <= 0) {\n    return { nodes: [] as PlacedNode[], links: [] as PlacedLink[], columns: 0, step: 0 };\n  }\n  const maxDepth = Math.max(0, ...linked.map((node) => depth.get(node.id) ?? 0));\n  const columns = maxDepth + 1;\n  const step = columns > 1 ? (width - nodeWidth) / (columns - 1) : 0;\n\n  const placed: PlacedNode[] = linked.map((node) => {\n    const index = nodes.indexOf(node);\n    const value = Math.max(inValue.get(node.id) ?? 0, outValue.get(node.id) ?? 0);\n    // Sinks sit in the last column, so every outcome lines up on the right.\n    const d = outValue.has(node.id) ? (depth.get(node.id) ?? 0) : maxDepth;\n    return {\n      id: node.id,\n      label: node.label,\n      description: node.description,\n      color: node.color ?? colors[index % colors.length],\n      value,\n      inValue: inValue.get(node.id) ?? 0,\n      outValue: outValue.get(node.id) ?? 0,\n      depth: d,\n      x0: d * step,\n      x1: d * step + nodeWidth,\n      y0: 0,\n      y1: 0,\n    };\n  });\n  const byId = new Map(placed.map((node) => [node.id, node]));\n  const byColumn = Array.from({ length: columns }, (_, d) =>\n    placed.filter((node) => node.depth === d),\n  );\n\n  // One scale for every column: the fullest column fills the height.\n  const padding = Math.min(\n    nodePadding,\n    ...byColumn.map((column) =>\n      column.length > 1 ? (height * 0.5) / (column.length - 1) : nodePadding,\n    ),\n  );\n  const ky = Math.min(\n    ...byColumn\n      .filter((column) => column.length)\n      .map(\n        (column) =>\n          (height - (column.length - 1) * padding) /\n          column.reduce((sum, node) => sum + node.value, 0),\n      ),\n  );\n  for (const column of byColumn) {\n    let y = 0;\n    for (const node of column) {\n      node.y0 = y;\n      node.y1 = y + node.value * ky;\n      y = node.y1 + padding;\n    }\n    // Center columns shorter than the fullest one.\n    const offset = (height - (y - padding)) / 2;\n    for (const node of column) {\n      node.y0 += offset;\n      node.y1 += offset;\n    }\n  }\n\n  // Columns keep the order nodes were given in, so the same data in a new\n  // period morphs in place instead of swapping nodes past each other.\n  // Relaxation only moves a node within the room its neighbours leave.\n  const resolve = (column: PlacedNode[]) => {\n    let y = 0;\n    for (const node of column) {\n      const dy = y - node.y0;\n      if (dy > 0) {\n        node.y0 += dy;\n        node.y1 += dy;\n      }\n      y = node.y1 + padding;\n    }\n    y = height;\n    for (let i = column.length - 1; i >= 0; i--) {\n      const node = column[i];\n      const dy = node.y1 - y;\n      if (dy > 0) {\n        node.y0 -= dy;\n        node.y1 -= dy;\n      }\n      y = node.y0 - padding;\n    }\n  };\n  const center = (node: PlacedNode) => (node.y0 + node.y1) / 2;\n  const relax = (column: PlacedNode[], alpha: number, side: \"source\" | \"target\") => {\n    for (const node of column) {\n      let weighted = 0;\n      let total = 0;\n      for (const link of kept) {\n        const self = side === \"source\" ? link.target : link.source;\n        if (self !== node.id) continue;\n        const other = byId.get(side === \"source\" ? link.source : link.target);\n        if (!other) continue;\n        weighted += center(other) * link.value;\n        total += link.value;\n      }\n      if (!total) continue;\n      const dy = (weighted / total - center(node)) * alpha;\n      node.y0 += dy;\n      node.y1 += dy;\n    }\n    resolve(column);\n  };\n  for (let i = 0; i < ITERATIONS; i++) {\n    const alpha = 0.99 ** i;\n    for (let d = 1; d < columns; d++) relax(byColumn[d], alpha, \"source\");\n    for (let d = columns - 2; d >= 0; d--) relax(byColumn[d], alpha, \"target\");\n  }\n\n  // Stack each node's links in the order of the nodes they reach, so bands\n  // leave and arrive without crossing at the node.\n  const placedLinks = kept.map((link) => ({\n    link,\n    source: byId.get(link.source) as PlacedNode,\n    target: byId.get(link.target) as PlacedNode,\n    sy: 0,\n    ty: 0,\n  }));\n  for (const node of placed) {\n    let sy = node.y0;\n    for (const entry of placedLinks\n      .filter((entry) => entry.source === node)\n      .sort((a, b) => a.target.y0 - b.target.y0)) {\n      entry.sy = sy;\n      sy += entry.link.value * ky;\n    }\n    let ty = node.y0;\n    for (const entry of placedLinks\n      .filter((entry) => entry.target === node)\n      .sort((a, b) => a.source.y0 - b.source.y0)) {\n      entry.ty = ty;\n      ty += entry.link.value * ky;\n    }\n  }\n\n  const result: PlacedLink[] = placedLinks.map(({ link, source, target, sy, ty }) => {\n    const w = link.value * ky;\n    const x0 = source.x1;\n    const x1 = target.x0;\n    const y0 = sy + w / 2;\n    const y1 = ty + w / 2;\n    const xm = (x0 + x1) / 2;\n    return {\n      id: linkId(link),\n      source,\n      target,\n      value: link.value,\n      share: source.value ? link.value / source.value : 0,\n      width: w,\n      x0,\n      y0,\n      x1,\n      y1,\n      path: `M ${x0} ${y0} C ${xm} ${y0} ${xm} ${y1} ${x1} ${y1}`,\n    };\n  });\n\n  // Reading order for keyboard focus: column by column, top to bottom.\n  const ordered = byColumn.flat();\n  return { nodes: ordered, links: result, columns, step };\n}\n"},{"path":"components/charts/sankey-chart/plot.tsx","type":"registry:component","target":"@components/charts/sankey-chart/plot.tsx","content":"\"use client\";\n\n/* Links draw in from left to right once the plot scrolls into view, a column\n * at a time, then nodes fade up at their full size. When the data changes,\n * links and nodes spring to their new geometry; a resize re-lays them at\n * once. Hovering or focusing a node lights it, its flows and the nodes they\n * reach; hovering a flow lights that flow and its two ends; everything else\n * fades. Reduced motion: the chart appears in place and changes without\n * easing. */\n\nimport { motion, useInView } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { emphasis, useSankeyChart } from \"./context\";\nimport type { PlacedNode } from \"./model\";\n\n// Link draw-in: each column starts this long after the one before it.\nconst COLUMN_DELAY = 0.12;\nconst DRAW_DURATION = 0.7;\n// Nodes shorter than this keep their name in the tooltip and table only.\nconst MIN_LABEL_HEIGHT = 12;\n// Gap between a node bar and its label, in px.\nconst LABEL_GAP = 8;\n// Average glyph width of the 12px label face, for fitting labels to a column.\nconst LABEL_CHAR_WIDTH = 6.6;\n// Pointer target around each node bar, and its smallest height (WCAG 2.5.8).\nconst HIT_SLOP = 4;\nconst MIN_HIT = 24;\n// Flows thinner than this still get this much hover target.\nconst MIN_LINK_HIT = 8;\n\nexport interface SankeyChartPlotProps {\n  /** Sets the plot height; default 20rem. The layout follows its measured size. */\n  className?: string;\n  /** Show each node's value under its label when there is room. */\n  showValues?: boolean;\n}\n\n/** Fits a label to `room` px, ending in an ellipsis; null when nothing fits. */\nfunction fit(label: string, room: number) {\n  const chars = Math.floor(room / LABEL_CHAR_WIDTH);\n  if (chars >= label.length) return label;\n  if (chars < 4) return null;\n  return `${label.slice(0, chars - 1).trimEnd()}…`;\n}\n\nexport function SankeyChartPlot({ className, showValues = true }: SankeyChartPlotProps) {\n  const {\n    nodes,\n    links,\n    columns,\n    step,\n    nodeWidth,\n    size,\n    setSize,\n    resized,\n    active,\n    setActive,\n    cursor,\n    tooltipOpen,\n    setTooltipOpen,\n    tooltipId,\n    gradientId,\n    plotRef,\n    buttons,\n    registerButton,\n    formatValue,\n    label,\n    empty,\n    reduce,\n    canHover,\n  } = useSankeyChart();\n  const inView = useInView(plotRef, { once: true, amount: 0.3 });\n  const shown = reduce || inView;\n  const [entered, setEntered] = useState(false);\n  const focusedId = useRef<string | null>(null);\n  // useId output can hold characters that break a url(#…) reference.\n  const gradient = gradientId.replace(/[^\\w-]/g, \"\");\n\n  useLayoutEffect(() => {\n    const plot = plotRef.current;\n    if (!plot) return;\n    const measure = () => {\n      const { width, height } = plot.getBoundingClientRect();\n      if (width > 0 && height > 0)\n        setSize((s) => (s.width === width && s.height === height ? s : { width, height }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(plot);\n    return () => observer.disconnect();\n  }, [plotRef, setSize]);\n\n  // A focused node that leaves the data hands focus to the tab stop, so\n  // keyboard users are not dropped to the top of the page.\n  useLayoutEffect(() => {\n    const id = focusedId.current;\n    if (!id || nodes.some((node) => node.id === id)) return;\n    focusedId.current = null;\n    const lost = document.activeElement;\n    if (lost === document.body || !plotRef.current?.contains(lost)) {\n      const replacement = nodes[Math.max(cursor.activeIndex, 0)];\n      if (replacement) buttons.current.get(replacement.id)?.focus();\n      else plotRef.current?.focus();\n    }\n  }, [nodes, cursor.activeIndex, buttons, plotRef]);\n\n  // A tap or click elsewhere dismisses what a tap opened; touch has no hover\n  // to leave, so the tooltip would otherwise stay up.\n  useEffect(() => {\n    if (!tooltipOpen) return;\n    const dismiss = (event: globalThis.PointerEvent) => {\n      if (plotRef.current?.contains(event.target as Node)) return;\n      setTooltipOpen(false);\n      setActive(null);\n    };\n    document.addEventListener(\"pointerdown\", dismiss);\n    return () => document.removeEventListener(\"pointerdown\", dismiss);\n  }, [tooltipOpen, plotRef, setTooltipOpen, setActive]);\n\n  // A node with any flow to or from the highlighted node lights up with it.\n  const lit = emphasis(active);\n  const neighbours = new Set<PlacedNode>();\n  if (active?.type === \"node\") {\n    for (const link of links) {\n      if (link.source === active.node) neighbours.add(link.target);\n      if (link.target === active.node) neighbours.add(link.source);\n    }\n  }\n  const nodeLit = (node: PlacedNode) => lit.node(node) || neighbours.has(node);\n\n  const tabStop = nodes[Math.max(cursor.activeIndex, 0)]?.id;\n  const ref = useCallback(\n    (id: string) => (element: HTMLButtonElement | null) => registerButton(id, element),\n    [registerButton],\n  );\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const target = event.target;\n    if (!(target instanceof HTMLButtonElement)) return;\n    const current = nodes.find((node) => node.id === target.dataset.sankeyNode);\n    if (!current) return;\n    if (event.key === \"Escape\") {\n      if (!tooltipOpen) return;\n      event.preventDefault();\n      setTooltipOpen(false);\n      return;\n    }\n    const center = (node: PlacedNode) => (node.y0 + node.y1) / 2;\n    const column = (depth: number) => nodes.filter((node) => node.depth === depth);\n    let next: PlacedNode | undefined;\n    if (event.key === \"Home\") next = nodes[0];\n    else if (event.key === \"End\") next = nodes[nodes.length - 1];\n    else if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") {\n      const same = column(current.depth);\n      next = same[same.indexOf(current) + (event.key === \"ArrowUp\" ? -1 : 1)];\n    } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n      // Nearest node by height in the next populated column that way.\n      const dir = event.key === \"ArrowLeft\" ? -1 : 1;\n      for (let d = current.depth + dir; d >= 0 && d < columns && !next; d += dir) {\n        next = column(d).reduce<PlacedNode | undefined>(\n          (best, node) =>\n            !best ||\n            Math.abs(center(node) - center(current)) < Math.abs(center(best) - center(current))\n              ? node\n              : best,\n          undefined,\n        );\n      }\n    } else return;\n    event.preventDefault();\n    setTooltipOpen(true);\n    if (next && next !== current) buttons.current.get(next.id)?.focus();\n    else setActive(current.id);\n  };\n\n  // Enter alone can be skipped when the pointer jumps straight from one part\n  // to another, so moves claim the highlight too; same-value updates bail out.\n  // A touch only claims it while pressed, so a scroll does not leave it lit.\n  const point = (event: PointerEvent<Element>, id: string) => {\n    if (event.pointerType === \"touch\" ? event.buttons !== 1 : !canHover) return;\n    setActive(id);\n    setTooltipOpen(true);\n  };\n  const press = (id: string) => {\n    setActive(id);\n    setTooltipOpen(true);\n  };\n\n  const geometry = reduce || resized ? { duration: 0 } : SPRING_LAYOUT;\n  const linkOpacity = (lightUp: boolean) => (active ? (lightUp ? 0.6 : 0.08) : 0.32);\n  // Room for a label between its node and the next column's nodes.\n  const labelRoom = columns > 1 ? step - nodeWidth - LABEL_GAP * 2 : size.width;\n  // Thin flows' hit paths go on top, so a thick neighbour cannot bury them.\n  const hitOrder = [...links].sort((a, b) => b.width - a.width);\n\n  return (\n    // Scrolls sideways under 32rem. The padding, cancelled by the margin,\n    // holds the node hit areas and focus rings that reach past the plot, so\n    // the scroller never clips them or scrolls vertically.\n    <div className=\"-m-2 overflow-x-auto overflow-y-hidden p-2\">\n      {/* biome-ignore lint/a11y/useSemanticElements: This group is a chart, not a collection of form fields. */}\n      <div\n        ref={plotRef}\n        role=\"group\"\n        aria-label={`${label} nodes`}\n        tabIndex={-1}\n        data-slot=\"sankey-chart-plot\"\n        className={cn(\"relative h-80 w-full min-w-[32rem] outline-none\", className)}\n        onKeyDown={onKeyDown}\n        onFocus={(event) => {\n          if (event.target instanceof HTMLButtonElement)\n            focusedId.current = event.target.dataset.sankeyNode ?? null;\n        }}\n        onBlur={(event) => {\n          if (event.currentTarget.contains(event.relatedTarget)) return;\n          focusedId.current = null;\n          setTooltipOpen(false);\n          setActive(null);\n        }}\n        onPointerLeave={(event) => {\n          // A lifted finger leaves too; touch dismisses by tapping elsewhere.\n          if (event.pointerType === \"touch\") return;\n          setTooltipOpen(false);\n          setActive(null);\n        }}\n      >\n        <svg\n          width={size.width}\n          height={size.height}\n          viewBox={`0 0 ${size.width} ${size.height}`}\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 overflow-visible\"\n        >\n          <defs>\n            {links.map((link, i) => (\n              <linearGradient\n                key={link.id}\n                id={`${gradient}-${i}`}\n                gradientUnits=\"userSpaceOnUse\"\n                x1={link.x0}\n                x2={link.x1}\n              >\n                <stop offset=\"0%\" stopColor={link.source.color} />\n                <stop offset=\"100%\" stopColor={link.target.color} />\n              </linearGradient>\n            ))}\n          </defs>\n          <g fill=\"none\" pointerEvents=\"none\">\n            {links.map((link, i) => (\n              <motion.path\n                key={link.id}\n                stroke={`url(#${gradient}-${i})`}\n                strokeWidth={Math.max(1, link.width)}\n                initial={reduce ? false : { d: link.path, pathLength: 0, opacity: 0.32 }}\n                animate={\n                  shown\n                    ? { d: link.path, pathLength: 1, opacity: linkOpacity(lit.link(link)) }\n                    : undefined\n                }\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        d: geometry,\n                        pathLength: {\n                          duration: DRAW_DURATION,\n                          ease: EASE_OUT,\n                          delay: entered ? 0 : link.source.depth * COLUMN_DELAY,\n                        },\n                        opacity: { duration: 0.16, ease: EASE_OUT },\n                      }\n                }\n                onAnimationComplete={() => setEntered(true)}\n              />\n            ))}\n          </g>\n          {/* Static hit paths own hover: never animated, at least MIN_LINK_HIT\n              wide, and on the final geometry so the target never drifts. */}\n          <g fill=\"none\" stroke=\"transparent\">\n            {hitOrder.map((link) => (\n              <path\n                key={link.id}\n                d={link.path}\n                strokeWidth={Math.max(MIN_LINK_HIT, link.width)}\n                pointerEvents={shown ? \"stroke\" : \"none\"}\n                className=\"cursor-pointer\"\n                onPointerEnter={(event) => point(event, link.id)}\n                onPointerMove={(event) => point(event, link.id)}\n                onPointerDown={() => press(link.id)}\n              />\n            ))}\n          </g>\n          <g pointerEvents=\"none\">\n            {nodes.map((node) => (\n              <motion.rect\n                key={node.id}\n                x={node.x0}\n                width={node.x1 - node.x0}\n                rx={2}\n                fill={node.color}\n                initial={reduce ? false : { y: node.y0, height: node.y1 - node.y0, opacity: 0 }}\n                animate={\n                  shown\n                    ? {\n                        y: node.y0,\n                        height: Math.max(1, node.y1 - node.y0),\n                        opacity: nodeLit(node) ? 1 : 0.3,\n                      }\n                    : undefined\n                }\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        y: geometry,\n                        height: geometry,\n                        opacity: {\n                          duration: entered ? 0.16 : 0.3,\n                          ease: EASE_OUT,\n                          delay: entered ? 0 : 0.1 + node.depth * COLUMN_DELAY,\n                        },\n                      }\n                }\n              />\n            ))}\n          </g>\n          <g className=\"text-xs\" pointerEvents=\"none\">\n            {nodes.map((node) => {\n              const h = node.y1 - node.y0;\n              if (h < MIN_LABEL_HEIGHT) return null;\n              const text = fit(node.label, labelRoom);\n              if (!text) return null;\n              const last = columns > 1 && node.depth === columns - 1;\n              const x = last ? node.x0 - LABEL_GAP : node.x1 + LABEL_GAP;\n              const value = formatValue(node.value);\n              const twoLines =\n                showValues &&\n                h >= MIN_LABEL_HEIGHT * 2.4 &&\n                value.length * LABEL_CHAR_WIDTH <= labelRoom;\n              return (\n                <motion.text\n                  key={node.id}\n                  x={x}\n                  textAnchor={last ? \"end\" : \"start\"}\n                  dominantBaseline=\"middle\"\n                  // A halo in the page colour keeps labels readable over the bands.\n                  className=\"fill-foreground stroke-background [paint-order:stroke]\"\n                  strokeWidth={3}\n                  strokeLinejoin=\"round\"\n                  initial={reduce ? false : { y: (node.y0 + node.y1) / 2, opacity: 0 }}\n                  animate={\n                    shown\n                      ? { y: (node.y0 + node.y1) / 2, opacity: nodeLit(node) ? 1 : 0.35 }\n                      : undefined\n                  }\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : {\n                          y: geometry,\n                          opacity: {\n                            duration: 0.2,\n                            ease: EASE_OUT,\n                            delay: entered ? 0 : 0.2 + node.depth * COLUMN_DELAY,\n                          },\n                        }\n                  }\n                >\n                  <tspan x={x} dy={twoLines ? \"-0.6em\" : 0} className=\"font-medium\">\n                    {text}\n                  </tspan>\n                  {twoLines ? (\n                    <tspan x={x} dy=\"1.2em\" className=\"fill-muted-foreground tabular-nums\">\n                      {value}\n                    </tspan>\n                  ) : null}\n                </motion.text>\n              );\n            })}\n          </g>\n        </svg>\n        {nodes.map((node) => {\n          const selected = tooltipOpen && active?.type === \"node\" && active.node === node;\n          const h = Math.max(1, node.y1 - node.y0);\n          const hit = Math.max(MIN_HIT, h + HIT_SLOP * 2);\n          return (\n            <button\n              key={node.id}\n              ref={ref(node.id)}\n              type=\"button\"\n              data-sankey-node={node.id}\n              tabIndex={node.id === tabStop ? 0 : -1}\n              aria-label={[\n                node.label,\n                formatValue(node.value),\n                node.inValue && node.outValue\n                  ? `in ${formatValue(node.inValue)}, out ${formatValue(node.outValue)}`\n                  : null,\n                node.description,\n              ]\n                .filter(Boolean)\n                .join(\". \")}\n              aria-describedby={selected ? tooltipId : undefined}\n              className=\"absolute rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n              style={{\n                left: node.x0 - HIT_SLOP,\n                // Centered on the bar, so a sliver of a node still gets a full target.\n                top: node.y0 + h / 2 - hit / 2,\n                width: node.x1 - node.x0 + HIT_SLOP * 2,\n                height: hit,\n                pointerEvents: shown ? \"auto\" : \"none\",\n              }}\n              onPointerEnter={(event) => point(event, node.id)}\n              onPointerMove={(event) => point(event, node.id)}\n              onPointerDown={() => press(node.id)}\n              onFocus={() => {\n                cursor.moveTo(node.id);\n                setActive(node.id);\n                setTooltipOpen(true);\n              }}\n            />\n          );\n        })}\n        {empty ? (\n          <p className=\"flex h-full items-center justify-center text-sm text-muted-foreground\">\n            {empty}\n          </p>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/charts/sankey-chart/tooltip.tsx","type":"registry:component","target":"@components/charts/sankey-chart/tooltip.tsx","content":"\"use client\";\n\nimport { MotionConfig } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { type SankeyActive, useSankeyChart } from \"./context\";\n\n// Flows listed per direction in a node's tooltip.\nconst MAX_FLOWS = 4;\n\nexport interface SankeyChartTooltipProps {\n  className?: string;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** Custom content for the highlighted node or flow. */\n  children?: ReactNode | ((active: SankeyActive) => ReactNode);\n}\n\nfunction Ticker({ value, format }: { value: number; format: (value: number) => string }) {\n  return (\n    <NumberTicker\n      value={value}\n      format={() => format(value)}\n      startOnView={false}\n      duration={0.2}\n      stagger={0}\n    />\n  );\n}\n\nexport function SankeyChartTooltip({ className, side = \"top\", children }: SankeyChartTooltipProps) {\n  const {\n    active,\n    nodes,\n    links,\n    size,\n    plotRef,\n    tooltipId,\n    tooltipOpen,\n    setTooltipOpen,\n    formatValue,\n  } = useSankeyChart();\n  if (!nodes.length) return null;\n  // Anchors keyboard focus to the top of the node, so the tooltip clears its\n  // label, or to the flow's midpoint; the pointer takes over while it moves.\n  const point =\n    active?.type === \"node\"\n      ? { x: (active.node.x0 + active.node.x1) / 2, y: active.node.y0 }\n      : active?.type === \"link\"\n        ? { x: (active.link.x0 + active.link.x1) / 2, y: (active.link.y0 + active.link.y1) / 2 }\n        : { x: size.width / 2, y: size.height / 2 };\n  const percent = (value: number) => `${(value * 100).toFixed(1)}%`;\n  return (\n    <Tooltip\n      id={tooltipId}\n      anchorRef={plotRef}\n      followCursor\n      anchorPoint={{ x: point.x / size.width, y: point.y / size.height }}\n      open={tooltipOpen && active !== null}\n      onOpenChange={setTooltipOpen}\n      side={side}\n      className={cn(\"max-w-64\", className)}\n      content={\n        active ? (\n          typeof children === \"function\" ? (\n            children(active)\n          ) : (\n            (children ?? (\n              <MotionConfig transition={{ type: \"tween\" }}>\n                {active.type === \"node\" ? (\n                  <span className=\"flex flex-col gap-1.5 text-xs\">\n                    <span className=\"inline-flex items-center gap-2 font-medium\">\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"size-2 rounded-full\"\n                        style={{ backgroundColor: active.node.color }}\n                      />\n                      {active.node.label}\n                    </span>\n                    <span className=\"font-mono tabular-nums\">\n                      <Ticker value={active.node.value} format={formatValue} />\n                    </span>\n                    {active.node.inValue && active.node.outValue ? (\n                      <span className=\"font-mono text-muted-foreground tabular-nums\">\n                        In {formatValue(active.node.inValue)} · Out{\" \"}\n                        {formatValue(active.node.outValue)}\n                      </span>\n                    ) : null}\n                    {([\"in\", \"out\"] as const).map((direction) => {\n                      // A node's largest flows that way; the table has the rest.\n                      const own = links\n                        .filter((l) =>\n                          direction === \"in\" ? l.target === active.node : l.source === active.node,\n                        )\n                        .sort((a, b) => b.value - a.value);\n                      if (!own.length) return null;\n                      return (\n                        <span key={direction} className=\"flex flex-col gap-0.5\">\n                          <span className=\"text-muted-foreground\">\n                            {direction === \"in\" ? \"From\" : \"To\"}\n                          </span>\n                          {own.slice(0, MAX_FLOWS).map((l) => (\n                            <span key={l.id} className=\"flex items-center justify-between gap-3\">\n                              <span className=\"truncate\">\n                                {direction === \"in\" ? l.source.label : l.target.label}\n                              </span>\n                              <span className=\"shrink-0 font-mono text-muted-foreground tabular-nums\">\n                                {formatValue(l.value)}\n                              </span>\n                            </span>\n                          ))}\n                          {own.length > MAX_FLOWS ? (\n                            <span className=\"text-muted-foreground\">\n                              {own.length - MAX_FLOWS} more\n                            </span>\n                          ) : null}\n                        </span>\n                      );\n                    })}\n                    {active.node.description ? (\n                      <span className=\"text-muted-foreground\">{active.node.description}</span>\n                    ) : null}\n                  </span>\n                ) : (\n                  <span className=\"flex flex-col gap-1.5 text-xs\">\n                    <span className=\"font-medium\">\n                      {active.link.source.label} → {active.link.target.label}\n                    </span>\n                    <span className=\"inline-flex items-center gap-2 font-mono tabular-nums\">\n                      <Ticker value={active.link.value} format={formatValue} />\n                      <span className=\"text-muted-foreground\">·</span>\n                      <span className=\"text-muted-foreground\">\n                        <Ticker value={active.link.share} format={percent} /> of{\" \"}\n                        {active.link.source.label}\n                      </span>\n                    </span>\n                  </span>\n                )}\n              </MotionConfig>\n            ))\n          )\n        ) : null\n      }\n    />\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","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":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","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":"lib/hooks/use-row-cursor.ts","type":"registry:hook","target":"@lib/hooks/use-row-cursor.ts","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to: the row's id, stamped with\n * the query it was placed under.\n */\ntype RowCursor = { id: string; query: string };\n\n/** The cursor's row, or -1 once the query has moved on or the row has left. */\nfunction indexOfCursor(\n  rows: readonly { id: string }[],\n  query: string,\n  cursor: RowCursor | null,\n) {\n  if (cursor === null || cursor.query !== query) return -1;\n  return rows.findIndex((row) => row.id === cursor.id);\n}\n\n/**\n * The highlighted row of a list whose rows can change under it.\n *\n * Which row is highlighted is resolved during render, never in a passive\n * effect: a passive effect runs after the commit, so a list that had just\n * changed would carry an `aria-activedescendant` naming a row that has left it,\n * and a key pressed in that window would commit the wrong row or nothing at\n * all.\n *\n * The cursor holds the row's id, not its position. A position alone cannot tell\n * a list that shrank from one that swapped its rows for a different set of the\n * same length, and the second case is the one that silently hands Enter to a\n * row the user never chose. A cursor whose row has left the list returns the\n * highlight to the first row rather than to the nearest surviving one: the row\n * the user aimed at is gone, and the first row is where a new query already\n * puts the highlight.\n *\n * Pass the query the list is filtered by. The cursor is stamped with it and\n * dropped when it changes, so a caller cannot forget to clear it — including a\n * caller whose query arrives as a prop and so never runs its own handler. Rows\n * that come back under a query that has moved on cannot revive it either.\n * `moveTo(null)` is for deliberate resets, such as reopening the list.\n *\n * Both callbacks keep one identity for the life of the component, and read the\n * rows and the query through a ref to do it. A caller will put them in an\n * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`\n * rebuilt on every keystroke would re-run that effect on every keystroke.\n */\nexport function useRowCursor(rows: readonly { id: string }[], query: string) {\n  const [cursor, setCursor] = useState<RowCursor | null>(null);\n  // Written after commit, not during render: a render React discards or has not\n  // finished still runs the component body, and an event handler that read this\n  // in that window would stamp the cursor with a query the committed tree does\n  // not have.\n  const latest = useRef({ rows, query });\n  useLayoutEffect(() => {\n    latest.current = { rows, query };\n  });\n\n  const cursorRow = indexOfCursor(rows, query, cursor);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // already gone, so rows that come back cannot revive a highlight the user has\n  // stopped aiming at.\n  if (cursor !== null && cursorRow < 0) setCursor(null);\n\n  const moveTo = useCallback(\n    (id: string | null) =>\n      setCursor(id === null ? null : { id, query: latest.current.query }),\n    [],\n  );\n\n  const moveActive = useCallback((direction: 1 | -1) => {\n    const { rows: live, query: liveQuery } = latest.current;\n    const last = live.length - 1;\n    if (last < 0) return;\n    // Steps from the row the cursor is really on, inside the update, so that\n    // two keys landing in one batch move two rows rather than one.\n    setCursor((current) => {\n      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);\n      const next = Math.min(Math.max(at + direction, 0), last);\n      return { id: live[next].id, query: liveQuery };\n    });\n  }, []);\n\n  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","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/number-ticker.tsx","type":"registry:component","target":"@components/motion/number-ticker.tsx","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /**\n   * Fraction digits to keep. The value is rounded to this many places before\n   * `format` runs (default 0, whole numbers).\n   */\n  decimals?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  decimals = 0,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const places = Math.max(0, Math.floor(decimals));\n    const factor = 10 ** places;\n    const rounded = Math.round(value * factor) / factor;\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(undefined, {\n            minimumFractionDigits: places,\n            maximumFractionDigits: places,\n          })\n        : rounded.toFixed(places);\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, decimals, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-baseline tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    // The invisible in-flow digit gives the slot a real text baseline so it\n    // lines up with surrounding text; clip-path clips the rolling column\n    // without moving that baseline the way overflow: hidden would.\n    <span\n      className={cn(\n        \"relative inline-block text-center [clip-path:inset(0)]\",\n        className,\n      )}\n      style={{\n        height: `${DIGIT_HEIGHT_EM}em`,\n        lineHeight: `${DIGIT_HEIGHT_EM}em`,\n        width: \"1ch\",\n      }}\n    >\n      <span className=\"invisible\">{digit}</span>\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/tooltip.tsx","type":"registry:component","target":"@components/motion/tooltip.tsx","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport {\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\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\";\nimport { focusFromKeyboard, graceArea, inBox, inPolygon, watchModality } from \"./tooltip/grace\";\nimport { TooltipArrow, TooltipPositioner } from \"./tooltip/positioner\";\nimport {\n  type TooltipAlign,\n  type TooltipAnchor,\n  type TooltipPoint,\n  type TooltipSide,\n  useTooltipPointer,\n} from \"./tooltip/use-position\";\n\nexport type { TooltipAlign, TooltipSide };\n\n/* -------------------------------------------------------------------------- */\n/* Provider                                                                   */\n/* -------------------------------------------------------------------------- */\n\ninterface TooltipTiming {\n  /** Delay before a hover opens a tooltip (ms). */\n  delay: number;\n  /** Delay before a tooltip closes once the pointer has left it (ms). */\n  closeDelay: number;\n  /** After one tooltip closes, others open instantly for this long (ms). */\n  skipDelay: number;\n}\n\nconst TimingContext = createContext<TooltipTiming>({ delay: 120, closeDelay: 0, skipDelay: 300 });\n\nexport interface TooltipProviderProps extends Partial<TooltipTiming> {\n  children: ReactNode;\n}\n\n/** Shared timing for every tooltip inside it. Optional: tooltips work without one. */\nexport function TooltipProvider({ children, ...timing }: TooltipProviderProps) {\n  const parent = useContext(TimingContext);\n  const { delay = parent.delay, closeDelay = parent.closeDelay, skipDelay = parent.skipDelay } =\n    timing;\n  const value = useMemo(() => ({ delay, closeDelay, skipDelay }), [delay, closeDelay, skipDelay]);\n  return <TimingContext.Provider value={value}>{children}</TimingContext.Provider>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                       */\n/* -------------------------------------------------------------------------- */\n\n// Moving along a toolbar should feel instant after the first label, and only\n// one label may show at a time: a keyboard-focused control and a hovered one\n// would otherwise both claim the screen.\nlet lastClosedAt = 0;\nlet current: { close: () => void } | null = null;\n\nfunction isEmpty(content: ReactNode) {\n  return content == null || content === false || content === \"\";\n}\n\ninterface TooltipContextValue {\n  open: boolean;\n  contentId: string;\n  labelId: string;\n  asLabel: boolean;\n  empty: boolean;\n  followCursor: boolean;\n  anchor: TooltipAnchor | null;\n  /** True when a TooltipTrigger owns the anchor; false for an external `anchorRef`. */\n  triggered: boolean;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  pointer: ReturnType<typeof useTooltipPointer>;\n  setTrigger: (element: TooltipAnchor | null) => void;\n  setEmpty: (empty: boolean) => void;\n  close: () => void;\n  onContentEnter: () => void;\n  onContentLeave: (event: ReactPointerEvent) => void;\n}\n\nconst TooltipContext = createContext<TooltipContextValue | null>(null);\n\nfunction useTooltipContext(part: string) {\n  const context = useContext(TooltipContext);\n  if (!context) throw new Error(`${part} must be used inside <Tooltip>`);\n  return context;\n}\n\nexport interface TooltipRootProps {\n  children?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Delay before showing on hover (ms). Keyboard focus shows immediately. */\n  delay?: number;\n  /** Delay before hiding once the pointer has left (ms). */\n  closeDelay?: number;\n  /** Never show, e.g. while the label it carries is already visible. */\n  disabled?: boolean;\n  /**\n   * Use the tooltip as the trigger's accessible name rather than its\n   * description. For icon-only controls whose only label is the tooltip.\n   */\n  asLabel?: boolean;\n  /** Follow real pointer coordinates; keyboard focus still uses the anchor. */\n  followCursor?: boolean;\n  /** Existing anchor for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<TooltipAnchor | null>;\n  /** Id of the tooltip surface, for anchors that reference it themselves. */\n  id?: string;\n}\n\nfunction TooltipRoot({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  delay: delayProp,\n  closeDelay: closeDelayProp,\n  disabled = false,\n  asLabel = false,\n  followCursor = false,\n  anchorRef,\n  id,\n  initialEmpty = false,\n}: TooltipRootProps & { initialEmpty?: boolean }) {\n  const timing = useContext(TimingContext);\n  const delay = delayProp ?? timing.delay;\n  const closeDelay = closeDelayProp ?? timing.closeDelay;\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? uncontrolledOpen;\n  const [empty, setEmpty] = useState(initialEmpty);\n  const [trigger, setTrigger] = useState<TooltipAnchor | null>(null);\n  const [external, setExternal] = useState<TooltipAnchor | null>(null);\n  const anchor = anchorRef ? external : trigger;\n  const generatedId = useId();\n  const contentId = id ?? `${generatedId}-content`;\n  const labelId = `${generatedId}-label`;\n  const floatingRef = useRef<HTMLSpanElement | null>(null);\n  const anchorObject = useRef<TooltipAnchor | null>(null);\n  const pointer = useTooltipPointer(anchor, followCursor);\n\n  // An external ref can attach after this component renders; read it once it has.\n  useLayoutEffect(() => {\n    if (!anchorRef) return;\n    const element = anchorRef.current ?? null;\n    if (element !== external) setExternal(element);\n  });\n\n  // Everything the event listeners read, current without re-binding them.\n  const latest = useRef({ open, delay, closeDelay, disabled, empty, onOpenChange, controlled: false });\n  useLayoutEffect(() => {\n    anchorObject.current = anchor;\n    latest.current = {\n      open,\n      delay,\n      closeDelay,\n      disabled,\n      empty,\n      onOpenChange,\n      controlled: controlledOpen !== undefined,\n    };\n  });\n\n  const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const grace = useRef<(() => void) | null>(null);\n  const self = useRef<{ close: () => void }>({ close: () => {} });\n\n  const clearTimers = useCallback(() => {\n    if (openTimer.current) clearTimeout(openTimer.current);\n    if (closeTimer.current) clearTimeout(closeTimer.current);\n    openTimer.current = null;\n    closeTimer.current = null;\n  }, []);\n\n  const stopGrace = useCallback(() => {\n    grace.current?.();\n    grace.current = null;\n  }, []);\n\n  // Only a real change reaches the consumer: a pointer leaving a closed\n  // tooltip is not news.\n  const commit = useCallback((next: boolean) => {\n    if (latest.current.open === next) return;\n    if (!latest.current.controlled) setUncontrolledOpen(next);\n    latest.current.onOpenChange?.(next);\n  }, []);\n\n  const openNow = useCallback(() => {\n    clearTimers();\n    stopGrace();\n    if (latest.current.disabled || latest.current.empty) return;\n    if (current && current !== self.current) current.close();\n    current = self.current;\n    commit(true);\n  }, [clearTimers, stopGrace, commit]);\n\n  const close = useCallback(() => {\n    clearTimers();\n    stopGrace();\n    if (latest.current.open) lastClosedAt = Date.now();\n    if (current === self.current) current = null;\n    commit(false);\n  }, [clearTimers, stopGrace, commit]);\n  useLayoutEffect(() => {\n    self.current.close = close;\n  }, [close]);\n\n  const requestOpen = useCallback(() => {\n    clearTimers();\n    if (latest.current.open || latest.current.disabled || latest.current.empty) return;\n    const warm = Date.now() - lastClosedAt < timing.skipDelay;\n    if (warm || latest.current.delay <= 0) openNow();\n    else openTimer.current = setTimeout(openNow, latest.current.delay);\n  }, [clearTimers, openNow, timing.skipDelay]);\n\n  const requestClose = useCallback(() => {\n    clearTimers();\n    if (latest.current.closeDelay > 0) {\n      closeTimer.current = setTimeout(close, latest.current.closeDelay);\n    } else close();\n  }, [clearTimers, close]);\n\n  // Hold the label open while the pointer crosses from one box toward the other.\n  const startGrace = useCallback(\n    (exit: TooltipPoint, from: Element, to: Element | null) => {\n      stopGrace();\n      if (!to) return requestClose();\n      const target = to.getBoundingClientRect();\n      const origin = from.getBoundingClientRect();\n      const corridor = graceArea(exit, target);\n      const onMove = (event: PointerEvent) => {\n        const point = { x: event.clientX, y: event.clientY };\n        if (inBox(point, target) || inBox(point, origin)) return;\n        if (!inPolygon(point, corridor)) requestClose();\n      };\n      document.addEventListener(\"pointermove\", onMove);\n      grace.current = () => document.removeEventListener(\"pointermove\", onMove);\n    },\n    [stopGrace, requestClose],\n  );\n\n  const hover = useHoverGesture();\n  const tap = useTapGesture<boolean>();\n\n  // Listeners go on the trigger itself rather than on cloned props, so a\n  // component that owns its own handlers keeps them, and no wrapper element\n  // sits between the control and its layout.\n  useEffect(() => {\n    if (anchorRef || !trigger) return;\n    watchModality();\n    // A mouse press dismisses the label and keeps it away until the pointer\n    // leaves; hovering a control you just clicked should not bring it back.\n    let suppressed = false;\n    // Focus that came from the keyboard holds the label open on its own.\n    let keyboardFocus = false;\n\n    const onEnter = (event: PointerEvent) => {\n      if (!hover.enter(event)) return;\n      stopGrace();\n      if (closeTimer.current) clearTimeout(closeTimer.current);\n      if (!suppressed) requestOpen();\n    };\n    const onLeave = (event: PointerEvent) => {\n      if (!hover.leave(event)) return;\n      suppressed = false;\n      if (keyboardFocus) return;\n      if (openTimer.current) {\n        clearTimers();\n        return;\n      }\n      if (latest.current.open && !followCursor) {\n        startGrace({ x: event.clientX, y: event.clientY }, trigger, floatingRef.current);\n      } else requestClose();\n    };\n    const onDown = (event: PointerEvent) => {\n      tap.start(event, latest.current.open);\n      if (event.pointerType === \"mouse\") {\n        suppressed = true;\n        close();\n      }\n    };\n    // A finger never hovers, and Safari does not focus a button on tap either,\n    // so the label is only reachable if the tap itself toggles it. The click\n    // carries no pointerType; the pointerdown before it says whether it was a\n    // tap, and keyboard activation has none at all.\n    const onClick = () => {\n      const gesture = tap.take();\n      if (!gesture || gesture.pointerType === \"mouse\") return;\n      if (gesture.state) close();\n      else openNow();\n    };\n    const onKey = () => tap.drop();\n    const onFocusIn = () => {\n      if (!focusFromKeyboard()) return;\n      keyboardFocus = true;\n      openNow();\n    };\n    const onFocusOut = (event: FocusEvent) => {\n      if (trigger.contains(event.relatedTarget as Node | null)) return;\n      keyboardFocus = false;\n      close();\n    };\n\n    // Both element kinds dispatch the same events; one type keeps the listeners typed.\n    const el = trigger as HTMLElement;\n    el.addEventListener(\"pointerenter\", onEnter);\n    el.addEventListener(\"pointerleave\", onLeave);\n    el.addEventListener(\"pointerdown\", onDown);\n    el.addEventListener(\"pointercancel\", tap.drop);\n    el.addEventListener(\"click\", onClick);\n    el.addEventListener(\"keydown\", onKey);\n    el.addEventListener(\"focusin\", onFocusIn);\n    el.addEventListener(\"focusout\", onFocusOut);\n    return () => {\n      el.removeEventListener(\"pointerenter\", onEnter);\n      el.removeEventListener(\"pointerleave\", onLeave);\n      el.removeEventListener(\"pointerdown\", onDown);\n      el.removeEventListener(\"pointercancel\", tap.drop);\n      el.removeEventListener(\"click\", onClick);\n      el.removeEventListener(\"keydown\", onKey);\n      el.removeEventListener(\"focusin\", onFocusIn);\n      el.removeEventListener(\"focusout\", onFocusOut);\n    };\n  }, [\n    anchorRef,\n    trigger,\n    followCursor,\n    hover,\n    tap,\n    requestOpen,\n    requestClose,\n    openNow,\n    close,\n    clearTimers,\n    stopGrace,\n    startGrace,\n  ]);\n\n  // While open, a trigger that turns disabled loses focus without a blur event,\n  // and a window that loses focus never reports the pointer leaving.\n  useEffect(() => {\n    if (!open || anchorRef || !trigger) return;\n    const observer = new MutationObserver(() => {\n      if (trigger.matches(\":disabled, [aria-disabled='true']\")) close();\n    });\n    observer.observe(trigger, { attributes: true, attributeFilter: [\"disabled\", \"aria-disabled\"] });\n    window.addEventListener(\"blur\", close);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"blur\", close);\n    };\n  }, [open, anchorRef, trigger, close]);\n\n  // A chart drives its own open state and swaps content as it goes; only a\n  // trigger-owned label closes itself for having nothing to say.\n  useEffect(() => {\n    if (disabled || (empty && !anchorRef)) close();\n  }, [disabled, empty, anchorRef, close]);\n\n  useEffect(\n    () => () => {\n      clearTimers();\n      stopGrace();\n      if (current === self.current) current = null;\n    },\n    [clearTimers, stopGrace],\n  );\n\n  const insideTooltip = useCallback(\n    (target: Element) => Boolean(floatingRef.current?.contains(target)),\n    [],\n  );\n  // ...and closed by the next tap that lands somewhere else, or Escape. The\n  // label covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, close, anchorObject, { ignore: insideTooltip });\n\n  const onContentEnter = useCallback(() => {\n    stopGrace();\n    clearTimers();\n  }, [stopGrace, clearTimers]);\n\n  const onContentLeave = useCallback(\n    (event: ReactPointerEvent) => {\n      const floating = floatingRef.current;\n      if (!floating) return;\n      if (anchorRef) {\n        // Bridge the small gap back onto a chart; it reopens on its own terms.\n        clearTimers();\n        closeTimer.current = setTimeout(close, 100);\n        return;\n      }\n      startGrace({ x: event.clientX, y: event.clientY }, floating, anchorObject.current);\n    },\n    [anchorRef, clearTimers, close, startGrace],\n  );\n\n  const value = useMemo<TooltipContextValue>(\n    () => ({\n      open,\n      contentId,\n      labelId,\n      asLabel,\n      empty,\n      followCursor,\n      anchor,\n      triggered: !anchorRef,\n      floatingRef,\n      pointer,\n      setTrigger,\n      setEmpty,\n      close,\n      onContentEnter,\n      onContentLeave,\n    }),\n    [\n      open,\n      contentId,\n      labelId,\n      asLabel,\n      empty,\n      followCursor,\n      anchor,\n      anchorRef,\n      pointer,\n      close,\n      onContentEnter,\n      onContentLeave,\n    ],\n  );\n\n  return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Trigger                                                                    */\n/* -------------------------------------------------------------------------- */\n\nfunction mergeIds(...ids: unknown[]) {\n  return ids.filter((value) => typeof value === \"string\" && value).join(\" \") || undefined;\n}\n\nfunction assignRef<T>(ref: Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as { current: T | null }).current = value;\n}\n\nexport interface TooltipTriggerProps extends ComponentProps<\"button\"> {\n  /** Use the single child element as the trigger instead of rendering a button. */\n  asChild?: boolean;\n}\n\nexport function TooltipTrigger({ asChild = false, children, ref, ...props }: TooltipTriggerProps) {\n  const { open, contentId, labelId, asLabel, empty, setTrigger } =\n    useTooltipContext(\"TooltipTrigger\");\n  const child = asChild && isValidElement(children) ? children : null;\n  const childProps = (child?.props ?? props) as Record<string, unknown>;\n  const childRef = child ? (childProps.ref as Ref<TooltipAnchor> | undefined) : undefined;\n  const attached = useRef<TooltipAnchor | null>(null);\n\n  const setRef = useCallback(\n    (element: TooltipAnchor | null) => {\n      attached.current = element;\n      setTrigger(element);\n      assignRef(childRef ?? (ref as Ref<TooltipAnchor> | undefined), element);\n    },\n    [setTrigger, childRef, ref],\n  );\n\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"production\" && child && !attached.current) {\n      console.warn(\n        \"TooltipTrigger: the child did not attach a ref, so the tooltip has nothing to listen to. Render a DOM element or a component that passes `ref` through.\",\n      );\n    }\n  }, [child]);\n\n  const aria = asLabel\n    ? {\n        \"aria-labelledby\": empty\n          ? (childProps[\"aria-labelledby\"] as string | undefined)\n          : labelId,\n      }\n    : {\n        \"aria-describedby\": mergeIds(\n          childProps[\"aria-describedby\"],\n          open && !empty ? contentId : undefined,\n        ),\n      };\n\n  if (asChild) {\n    if (!child) return children;\n    return cloneElement(child as ReactElement<Record<string, unknown>>, { ref: setRef, ...aria });\n  }\n\n  return (\n    <button\n      type=\"button\"\n      data-state={open ? \"open\" : \"closed\"}\n      {...props}\n      ref={setRef as Ref<HTMLButtonElement>}\n      {...aria}\n    >\n      {children}\n    </button>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Content                                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport interface TooltipContentProps {\n  children?: ReactNode;\n  side?: TooltipSide;\n  align?: TooltipAlign;\n  /** Gap between anchor and tooltip (px). */\n  sideOffset?: number;\n  /** Draw an arrow pointing at the anchor. */\n  arrow?: boolean;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: TooltipPoint;\n  className?: string;\n}\n\nexport function TooltipContent({\n  children,\n  side = \"top\",\n  align = \"center\",\n  sideOffset,\n  arrow = false,\n  anchorPoint,\n  className,\n}: TooltipContentProps) {\n  const {\n    open,\n    contentId,\n    labelId,\n    asLabel,\n    followCursor,\n    anchor,\n    floatingRef,\n    pointer,\n    setEmpty,\n    close,\n    onContentEnter,\n    onContentLeave,\n  } = useTooltipContext(\"TooltipContent\");\n  const empty = isEmpty(children);\n  const arrowRef = useRef<SVGSVGElement | null>(null);\n\n  useLayoutEffect(() => {\n    setEmpty(empty);\n  }, [empty, setEmpty]);\n\n  if (typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <>\n      {/* The name has to exist while the label is closed, so it lives apart from the surface. */}\n      {asLabel && !empty ? (\n        <span id={labelId} hidden>\n          {children}\n        </span>\n      ) : null}\n      <AnimatePresence>\n        {open && !empty ? (\n          <TooltipPositioner\n            key=\"tooltip\"\n            anchor={anchor}\n            floatingRef={floatingRef}\n            arrowRef={arrow ? arrowRef : undefined}\n            anchorPoint={anchorPoint}\n            followCursor={followCursor}\n            side={side}\n            align={align}\n            sideOffset={sideOffset}\n            onDismiss={close}\n            pointer={pointer}\n          >\n            {(positioned, isPresent, placedSide) => (\n              <TooltipSurface\n                id={asLabel ? undefined : contentId}\n                aria-hidden={asLabel || undefined}\n                ready={positioned}\n                onPointerEnter={onContentEnter}\n                onPointerLeave={onContentLeave}\n                style={{ pointerEvents: isPresent && !followCursor ? \"auto\" : \"none\" }}\n                className={cn(\n                  \"max-w-[min(20rem,calc(100vw-16px))] whitespace-normal break-words\",\n                  arrow && \"relative\",\n                  className,\n                )}\n              >\n                {children}\n                {arrow ? <TooltipArrow ref={arrowRef} side={placedSide} /> : null}\n              </TooltipSurface>\n            )}\n          </TooltipPositioner>\n        ) : null}\n      </AnimatePresence>\n    </>,\n    document.body,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip (root, or one-line shorthand when given `content`)                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface TooltipProps\n  extends TooltipRootProps,\n    Omit<TooltipContentProps, \"children\" | \"className\"> {\n  /**\n   * Shorthand: the tooltip's content. With it, `children` is the trigger\n   * element; without it, compose `TooltipTrigger` and `TooltipContent`.\n   */\n  content?: ReactNode;\n  /** Shorthand only: classes for the tooltip surface. */\n  className?: string;\n}\n\nexport function Tooltip(props: TooltipProps) {\n  if (!(\"content\" in props)) return <TooltipRoot {...props} />;\n  const {\n    content,\n    children,\n    side,\n    align,\n    sideOffset,\n    arrow,\n    anchorPoint,\n    className,\n    ...root\n  } = props;\n  if (!root.anchorRef && !isValidElement(children)) return children ?? null;\n  return (\n    <TooltipRoot {...root} initialEmpty={isEmpty(content)}>\n      {isValidElement(children) ? <TooltipTrigger asChild>{children}</TooltipTrigger> : null}\n      <TooltipContent\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        arrow={arrow}\n        anchorPoint={anchorPoint}\n        className={className}\n      >\n        {content}\n      </TooltipContent>\n    </TooltipRoot>\n  );\n}\n"},{"path":"components/motion/tooltip/grace.ts","type":"registry:component","target":"@components/motion/tooltip/grace.ts","content":"type Point = { x: number; y: number };\ntype Box = { left: number; top: number; right: number; bottom: number };\n\n/**\n * The corridor a pointer may cross between the trigger and the open tooltip\n * without closing it: the convex hull of where it left and the box it is\n * heading for. A fixed timeout either cuts a slow diagonal move short or keeps\n * the label alive after the pointer has plainly gone elsewhere; this follows\n * the path itself.\n */\nexport function graceArea(exit: Point, target: Box): Point[] {\n  // A few pixels either side of the exit point give the corridor some width at\n  // its narrow end, so a hand that drifts sideways while it moves stays inside.\n  const pad = 5;\n  return hull([\n    { x: exit.x - pad, y: exit.y - pad },\n    { x: exit.x + pad, y: exit.y - pad },\n    { x: exit.x - pad, y: exit.y + pad },\n    { x: exit.x + pad, y: exit.y + pad },\n    { x: target.left, y: target.top },\n    { x: target.right, y: target.top },\n    { x: target.right, y: target.bottom },\n    { x: target.left, y: target.bottom },\n  ]);\n}\n\nexport function inBox(point: Point, box: Box) {\n  return (\n    point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom\n  );\n}\n\nexport function inPolygon(point: Point, polygon: Point[]) {\n  let inside = false;\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const a = polygon[i];\n    const b = polygon[j];\n    if (\n      a.y > point.y !== b.y > point.y &&\n      point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x\n    ) {\n      inside = !inside;\n    }\n  }\n  return inside;\n}\n\n// Monotone chain; the corridor is always convex, so a hull is all it needs.\nfunction hull(points: Point[]) {\n  const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);\n  const cross = (o: Point, a: Point, b: Point) =>\n    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);\n  const lower: Point[] = [];\n  for (const p of sorted) {\n    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {\n      lower.pop();\n    }\n    lower.push(p);\n  }\n  const upper: Point[] = [];\n  for (let i = sorted.length - 1; i >= 0; i--) {\n    const p = sorted[i];\n    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {\n      upper.pop();\n    }\n    upper.push(p);\n  }\n  lower.pop();\n  upper.pop();\n  return lower.concat(upper);\n}\n\n/**\n * How the user last reached for the page. A focus that a mouse press caused is\n * not a request to read the label: the pointer is already on the control and\n * hovering showed it. A focus that followed a key — Tab, or a roving arrow key\n * moving focus in script — is the only way a keyboard user sees it at all.\n */\nlet modality: \"keyboard\" | \"pointer\" = \"keyboard\";\nlet listening = false;\n\nexport function watchModality() {\n  if (listening || typeof window === \"undefined\") return;\n  listening = true;\n  window.addEventListener(\"keydown\", () => (modality = \"keyboard\"), true);\n  window.addEventListener(\"pointerdown\", () => (modality = \"pointer\"), true);\n}\n\nexport function focusFromKeyboard() {\n  return modality === \"keyboard\";\n}\n"},{"path":"components/motion/tooltip/positioner.tsx","type":"registry:component","target":"@components/motion/tooltip/positioner.tsx","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport { type CSSProperties, type ReactNode, type Ref, useCallback, useState } from \"react\";\nimport { type TooltipSide, 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, side: TooltipSide) => ReactNode;\n}) {\n  const present = useIsPresent();\n  const [ready, setReady] = useState(false);\n  const [side, setSide] = useState<TooltipSide>(position.side);\n  const onPosition = useCallback((placed: TooltipSide) => {\n    setReady(true);\n    setSide(placed);\n  }, []);\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, side)}\n    </span>\n  );\n}\n\n// Each arrow is a 12px box whose base sits on the surface's inner border edge,\n// so its fill covers the border beneath it and only the two outer edges stroke.\nconst ARROW: Record<TooltipSide, { path: string; style: CSSProperties }> = {\n  top: { path: \"M0 6L6 12L12 6\", style: { top: \"calc(100% - 6px)\" } },\n  bottom: { path: \"M0 6L6 0L12 6\", style: { bottom: \"calc(100% - 6px)\" } },\n  left: { path: \"M6 0L12 6L6 12\", style: { left: \"calc(100% - 6px)\" } },\n  right: { path: \"M6 0L0 6L6 12\", style: { right: \"calc(100% - 6px)\" } },\n};\n\nexport function TooltipArrow({ side, ref }: { side: TooltipSide; ref: Ref<SVGSVGElement> }) {\n  const { path, style } = ARROW[side];\n  return (\n    <svg\n      ref={ref}\n      aria-hidden=\"true\"\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      className=\"pointer-events-none absolute overflow-visible\"\n      style={style}\n    >\n      <path d={`${path}Z`} style={{ fill: \"var(--tooltip-arrow-fill, transparent)\" }} />\n      <path\n        d={path}\n        fill=\"none\"\n        strokeWidth=\"1\"\n        style={{ stroke: \"var(--tooltip-arrow-stroke, transparent)\" }}\n      />\n    </svg>\n  );\n}\n"},{"path":"components/motion/tooltip/use-position.ts","type":"registry:component","target":"@components/motion/tooltip/use-position.ts","content":"\"use client\";\n\nimport {\n  arrow as arrowMiddleware,\n  autoUpdate,\n  computePosition,\n  flip,\n  hide,\n  offset,\n  type Placement,\n  shift,\n  type VirtualElement,\n} from \"@floating-ui/dom\";\nimport { type RefObject, useCallback, useLayoutEffect, useRef } from \"react\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipAlign = \"start\" | \"center\" | \"end\";\nexport type TooltipPoint = { x: number; y: number };\nexport type TooltipAnchor = HTMLElement | SVGElement;\n\nconst ORIGIN: Record<TooltipSide, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */\nexport function useTooltipPosition({\n  open,\n  anchor,\n  floatingRef,\n  arrowRef,\n  anchorPoint,\n  followCursor,\n  side,\n  align,\n  sideOffset,\n  onDismiss,\n  onPosition,\n  pointer,\n}: {\n  open: boolean;\n  anchor: TooltipAnchor | null;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  arrowRef?: RefObject<SVGSVGElement | null>;\n  anchorPoint?: TooltipPoint;\n  followCursor: boolean;\n  side: TooltipSide;\n  align: TooltipAlign;\n  sideOffset?: number;\n  onDismiss: () => void;\n  onPosition: (side: TooltipSide) => void;\n  pointer: ReturnType<typeof useTooltipPointer>;\n}) {\n  const { cursor, onMove } = pointer;\n  const preferred: Placement = align === \"center\" ? side : `${side}-${align}`;\n  const cursorSide = useRef<Placement | null>(null);\n  useLayoutEffect(() => {\n    cursorSide.current = open ? preferred : null;\n  }, [open, preferred]);\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 floating = floatingRef.current;\n      if (!open || !anchor || !floating) return;\n      const revision = ++version.current;\n      const currentCursor = followCursor ? cursor.current : null;\n      const virtual = Boolean(currentCursor) || pointX !== undefined || pointY !== undefined;\n      const reference: Element | VirtualElement = virtual\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      const arrowElement = arrowRef?.current ?? null;\n      void computePosition(reference, floating, {\n        strategy: \"fixed\",\n        placement: currentCursor ? (cursorSide.current ?? preferred) : preferred,\n        middleware: [\n          offset(sideOffset ?? (currentCursor ? 12 : 8)),\n          flip({ padding: 8 }),\n          shift({ padding: 8 }),\n          ...(arrowElement ? [arrowMiddleware({ element: arrowElement, padding: 10 })] : []),\n          // A label left floating over whatever scrolled into its trigger's place\n          // describes nothing there. Points inside a chart move with the chart.\n          ...(virtual ? [] : [hide({ strategy: \"referenceHidden\" })]),\n        ],\n      }).then(({ x, y, placement, middlewareData }) => {\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 placedSide = placement.split(\"-\")[0] as TooltipSide;\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        floating.style.setProperty(\"--tooltip-origin\", ORIGIN[placedSide]);\n        // A box with no size has no clipped state to report, only a missing layout.\n        const box = anchor.getBoundingClientRect();\n        const clipped =\n          Boolean(middlewareData.hide?.referenceHidden) && (box.width > 0 || box.height > 0);\n        floating.style.visibility = clipped ? \"hidden\" : \"visible\";\n        floating.dataset.placement = placement;\n        const surface = arrowElement?.parentElement;\n        if (arrowElement && surface) {\n          // Measured against the positioner, drawn inside the surface's border.\n          const { x: ax, y: ay } = middlewareData.arrow ?? {};\n          if (ax != null) arrowElement.style.left = `${ax - surface.clientLeft}px`;\n          if (ay != null) arrowElement.style.top = `${ay - surface.clientTop}px`;\n          // Paint with whatever the surface resolved to, so a restyled tooltip\n          // keeps a matching arrow without a second set of classes.\n          const style = getComputedStyle(surface);\n          arrowElement.style.setProperty(\"--tooltip-arrow-fill\", style.backgroundColor);\n          arrowElement.style.setProperty(\"--tooltip-arrow-stroke\", style.borderTopColor);\n        }\n        onPosition(placedSide);\n      });\n    };\n    if (open) schedule();\n  }, [\n    open,\n    anchor,\n    floatingRef,\n    arrowRef,\n    followCursor,\n    pointX,\n    pointY,\n    preferred,\n    sideOffset,\n    schedule,\n    onPosition,\n    cursor,\n  ]);\n\n  useLayoutEffect(() => {\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, anchor, 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 anchor, including the opening delay. */\nexport function useTooltipPointer(anchor: TooltipAnchor | null, followCursor: boolean) {\n  const cursor = useRef<TooltipPoint | null>(null);\n  const onMove = useRef<(() => void) | null>(null);\n  const pointerFocus = useRef(false);\n  useLayoutEffect(() => {\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  }, [anchor, followCursor]);\n\n  return { cursor, onMove };\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"registry:component","target":"@components/motion/tooltip-surface.tsx","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":"registry:hook","target":"@lib/hooks/use-dismiss.ts","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":"registry:hook","target":"@lib/hooks/use-hover-gesture.ts","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":"registry:hook","target":"@lib/hooks/use-tap-gesture.ts","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":"registry:lib","target":"@lib/touch.ts","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"}]}