{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bar-chart","type":"registry:component","title":"Bar Chart","description":"Grouped, stacked and horizontal bars that grow in, rescale and reorder with springs.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["@floating-ui/dom","clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/bar-chart.tsx","type":"registry:component","target":"@components/charts/bar-chart.tsx","content":"\"use client\";\n// beui.dev/charts/bar-chart\n\nimport { cn } from \"@/lib/utils\";\nimport { BarChartContext, type BarChartProps, useBarChartModel } from \"./bar-chart/context\";\nimport { BarChartLegend } from \"./bar-chart/legend\";\nimport { BarChartPlot } from \"./bar-chart/plot\";\nimport { BarChartTooltip } from \"./bar-chart/tooltip\";\nimport { XYDataTable } from \"./xy/data-table\";\n\n/** Compose Plot, Tooltip and Legend, or omit children for the complete chart. */\nexport function BarChart({\n  series,\n  categories,\n  layout,\n  orientation,\n  sort,\n  max,\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  hiddenSeries,\n  defaultHiddenSeries,\n  onHiddenSeriesChange,\n  formatValue,\n  formatTick,\n  label,\n  children,\n  className,\n  ...props\n}: BarChartProps) {\n  const model = useBarChartModel({\n    series,\n    categories,\n    layout,\n    orientation,\n    sort,\n    max,\n    activeId,\n    defaultActiveId,\n    onActiveChange,\n    hiddenSeries,\n    defaultHiddenSeries,\n    onHiddenSeriesChange,\n    formatValue,\n    formatTick,\n    label,\n  });\n  return (\n    <BarChartContext.Provider value={model}>\n      <section\n        aria-label={model.label}\n        {...props}\n        data-slot=\"bar-chart\"\n        className={cn(\"w-full min-w-0 space-y-3\", className)}\n      >\n        {children === undefined ? (\n          <>\n            <BarChartPlot />\n            <BarChartTooltip />\n            {model.rows.length > 1 ? <BarChartLegend /> : null}\n          </>\n        ) : (\n          children\n        )}\n        <XYDataTable\n          label={model.label}\n          rows={model.rows}\n          categories={model.categories}\n          formatValue={model.formatValue}\n        />\n      </section>\n    </BarChartContext.Provider>\n  );\n}\n\nexport type { BarChartProps } from \"./bar-chart/context\";\nexport { useBarChart } from \"./bar-chart/context\";\nexport type { BarChartLegendProps } from \"./bar-chart/legend\";\nexport { BarChartLegend } from \"./bar-chart/legend\";\nexport type { BarChartPlotProps } from \"./bar-chart/plot\";\nexport { BarChartPlot } from \"./bar-chart/plot\";\nexport type { BarChartTooltipData, BarChartTooltipProps } from \"./bar-chart/tooltip\";\nexport { BarChartTooltip } from \"./bar-chart/tooltip\";\nexport type { XYSeries as BarChartSeries } from \"./xy/model\";\n"},{"path":"components/charts/bar-chart/context.tsx","type":"registry:component","target":"@components/charts/bar-chart/context.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport { type ComponentProps, createContext, type ReactNode, useContext, useMemo, useState } from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { normalizeXY, type XYSeries } from \"../xy/model\";\nimport { niceDomain } from \"../xy/scale\";\nimport { useControllable, useInspection } from \"../xy/use-inspection\";\n\nexport interface BarChartProps extends Omit<ComponentProps<\"section\">, \"children\"> {\n  /** Nonnegative values aligned with categories. Missing or negative values leave a gap. */\n  series: readonly XYSeries[];\n  /** Unique category labels in display order. */\n  categories: readonly string[];\n  layout?: \"grouped\" | \"stacked\";\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Order categories by their visible total. Bars glide to their new place when it changes. */\n  sort?: \"none\" | \"ascending\" | \"descending\";\n  /** Fixed top of the value axis. Defaults to a rounded value above the tallest bar. */\n  max?: number;\n  /** Inspected category, by label. */\n  activeId?: string | null;\n  defaultActiveId?: string | null;\n  onActiveChange?: (id: string | null) => void;\n  /** Series ids hidden through the legend. */\n  hiddenSeries?: readonly string[];\n  defaultHiddenSeries?: readonly string[];\n  onHiddenSeriesChange?: (ids: readonly string[]) => void;\n  formatValue?: (value: number) => string;\n  /** Axis tick labels. Defaults to compact notation (1.2K). */\n  formatTick?: (value: number) => string;\n  label?: string;\n  children?: ReactNode;\n}\n\nconst number = new Intl.NumberFormat(\"en\", { maximumFractionDigits: 2 });\nconst compact = new Intl.NumberFormat(\"en\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst NONE: readonly string[] = [];\n\nexport function useBarChartModel({\n  series,\n  categories,\n  layout = \"grouped\",\n  orientation = \"vertical\",\n  sort = \"none\",\n  max,\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  hiddenSeries,\n  defaultHiddenSeries = NONE,\n  onHiddenSeriesChange,\n  formatValue = (value) => number.format(value),\n  formatTick = (value) => compact.format(value),\n  label = \"Bar chart\",\n}: Omit<BarChartProps, \"children\">) {\n  const data = useMemo(() => normalizeXY(series, categories, { nonnegative: true }), [series, categories]);\n  const [hidden, setHidden] = useControllable(hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange);\n  const visibleRows = data.rows.filter((row) => !hidden.includes(row.id));\n  const [hovered, setHovered] = useState<string | null>(null);\n  const highlight = visibleRows.some((row) => row.id === hovered) ? hovered : null;\n  const totals = data.categories.map((_, i) =>\n    visibleRows.reduce((sum, row) => sum + (row.values[i] ?? 0), 0),\n  );\n  // Visual order. Ties keep their given order so equal bars never swap places.\n  const order = data.categories.map((_, i) => i);\n  if (sort !== \"none\")\n    order.sort((a, b) => (sort === \"ascending\" ? totals[a] - totals[b] : totals[b] - totals[a]) || a - b);\n  const domain = niceDomain(\n    layout === \"stacked\"\n      ? totals\n      : visibleRows.flatMap((row) => row.values.filter((value): value is number => value !== null)),\n    { includeZero: true, fixed: max !== undefined && max > 0 ? [0, max] : undefined },\n  );\n  const inspection = useInspection({\n    ids: data.categories,\n    activeId,\n    defaultActiveId,\n    onActiveChange,\n  });\n  return {\n    ...data,\n    ...inspection,\n    visibleRows,\n    totals,\n    order,\n    hidden,\n    toggle: (id: string) =>\n      setHidden(hidden.includes(id) ? hidden.filter((h) => h !== id) : [...hidden, id]),\n    highlight,\n    setHovered,\n    domain,\n    layout,\n    orientation,\n    formatValue,\n    formatTick,\n    label,\n    reduce: useReducedMotion() ?? false,\n    canHover: useHoverCapable(),\n  };\n}\n\nexport type BarChartModel = ReturnType<typeof useBarChartModel>;\n\nexport const BarChartContext = createContext<BarChartModel | null>(null);\n\nexport function useBarChart() {\n  const context = useContext(BarChartContext);\n  if (!context) throw new Error(\"Bar chart parts must be rendered inside BarChart.\");\n  return context;\n}\n"},{"path":"components/charts/bar-chart/legend.tsx","type":"registry:component","target":"@components/charts/bar-chart/legend.tsx","content":"\"use client\";\n\nimport { XYLegend } from \"../xy/legend\";\nimport { useBarChart } from \"./context\";\n\nexport interface BarChartLegendProps {\n  className?: string;\n}\n\n/** Toggle a series to hide it; its bars collapse and the axis rescales. */\nexport function BarChartLegend({ className }: BarChartLegendProps) {\n  const { rows, hidden, toggle, setHovered, canHover } = useBarChart();\n  return (\n    <XYLegend\n      rows={rows}\n      hidden={hidden}\n      onToggle={toggle}\n      onHover={setHovered}\n      canHover={canHover}\n      label=\"Series\"\n      className={className}\n    />\n  );\n}\n"},{"path":"components/charts/bar-chart/plot.tsx","type":"registry:component","target":"@components/charts/bar-chart/plot.tsx","content":"\"use client\";\n\n/* Bars grow out of the baseline with a short stagger the first time the chart\n * scrolls into view. Each bar is a unit rect placed with translate and scale,\n * plus a rounded cap that only translates, so value changes animate\n * transforms alone and the corners never stretch. Categories glide to a new\n * rank when sorting reorders them; a hidden series collapses into the\n * baseline while the axis rescales. Reduced motion: no growth, glide or\n * stagger, values update in place. */\n\nimport { motion, type Transition, useInView } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { XYGridLines, XYTickLabels } from \"../xy/axis\";\nimport { XYInspector } from \"../xy/inspector\";\nimport { labelStride } from \"../xy/scale\";\nimport { useElementWidth } from \"../xy/use-width\";\nimport { useBarChart } from \"./context\";\n\n/** Gutter for value labels (vertical bars), in px. */\nconst VALUE_AXIS = 44;\nconst PAD = { top: 12, right: 12, bottom: 0 } as const;\n/** Fraction of each category slot the bars occupy. */\nconst FILL = 0.7;\nconst GAP = 3;\nconst MAX_BAR = 32;\nconst MAX_STACK = 56;\nconst RADIUS = 4;\n// Collapsing to an exact zero scale can leave a hairline in some browsers.\nconst NONE = 0.0001;\n\nexport interface BarChartPlotProps {\n  /** Plot height in px. Horizontal charts default to 36px per category. */\n  height?: number;\n  /** Width of the category label gutter for horizontal bars, in px. */\n  labelWidth?: number;\n  showGrid?: boolean;\n  className?: string;\n}\n\nexport function BarChartPlot({\n  height: heightProp,\n  labelWidth = 96,\n  showGrid = true,\n  className,\n}: BarChartPlotProps) {\n  const {\n    rows,\n    categories,\n    visibleRows,\n    hidden,\n    highlight,\n    order,\n    totals,\n    domain,\n    layout,\n    orientation,\n    formatValue,\n    formatTick,\n    label,\n    activeIndex,\n    setActive,\n    open,\n    setOpen,\n    touching,\n    setTouching,\n    plotRef,\n    tooltipId,\n    reduce,\n    canHover,\n  } = useBarChart();\n  const { ref, width } = useElementWidth<HTMLDivElement>();\n  const inView = useInView(ref, { once: true, amount: 0.3 });\n  const [intro, setIntro] = useState(true);\n  useEffect(() => {\n    if (!inView) return;\n    const timer = setTimeout(() => setIntro(false), 900);\n    return () => clearTimeout(timer);\n  }, [inView]);\n  const clipId = useId();\n  const n = categories.length;\n  const vertical = orientation === \"vertical\";\n  const height = heightProp ?? (vertical ? 288 : Math.max(160, n * 36));\n\n  if (!n || !rows.length)\n    return (\n      <p\n        className={cn(\"flex items-center justify-center text-sm text-muted-foreground\", className)}\n        style={{ height }}\n      >\n        No data\n      </p>\n    );\n\n  // Band axis runs along categories; value axis along magnitudes.\n  const bandStart = vertical ? VALUE_AXIS : 0;\n  const bandLength = vertical ? Math.max(0, width - VALUE_AXIS - PAD.right) : height;\n  const valueStart = vertical ? height - PAD.bottom : labelWidth;\n  const valueLength = vertical\n    ? height - PAD.top - PAD.bottom\n    : Math.max(0, width - labelWidth - PAD.right * 2);\n  const slot = bandLength / n;\n  const scale = (value: number) => (value / (domain.max || 1)) * valueLength;\n  /** Value to px along the value axis, for gridlines and tick labels. */\n  const valueAt = (value: number) => (vertical ? valueStart - scale(value) : valueStart + scale(value));\n\n  const k = Math.max(1, visibleRows.length);\n  const grouped = layout === \"grouped\";\n  const thickness = grouped\n    ? Math.max(1, Math.min(MAX_BAR, (slot * FILL - (k - 1) * GAP) / k))\n    : Math.max(1, Math.min(MAX_STACK, slot * FILL));\n  const groupThickness = grouped ? k * thickness + (k - 1) * GAP : thickness;\n  const radius = Math.min(RADIUS, thickness / 2);\n  const revealed = inView || reduce;\n  const spring: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n  const activeRank = activeIndex >= 0 ? order.indexOf(activeIndex) : -1;\n  const stride = labelStride(n, bandLength, vertical ? 56 : 18);\n\n  return (\n    <div className={cn(\"min-w-0 select-none\", className)}>\n      <div ref={ref} className=\"relative w-full\" style={{ height }}>\n        {width > 0 ? (\n          <>\n            <svg\n              aria-hidden=\"true\"\n              width={width}\n              height={height}\n              className=\"absolute inset-0 overflow-visible\"\n            >\n              <defs>\n                <clipPath id={clipId}>\n                  {/* Caps of short bars would poke through the baseline. */}\n                  {vertical ? (\n                    <rect x={0} y={-16} width={width} height={valueStart + 16} />\n                  ) : (\n                    <rect x={valueStart} y={0} width={width} height={height} />\n                  )}\n                </clipPath>\n              </defs>\n              {showGrid ? (\n                <XYGridLines\n                  ticks={domain.ticks}\n                  position={valueAt}\n                  axis={vertical ? \"y\" : \"x\"}\n                  from={vertical ? bandStart : 0}\n                  to={vertical ? bandStart + bandLength : height}\n                  reduce={reduce}\n                />\n              ) : null}\n              {/* A soft band behind the inspected category, the full depth of\n                  the plot, gliding between slots. Bars stay at full strength. */}\n              <motion.rect\n                x={0}\n                y={0}\n                width={vertical ? slot : valueLength + PAD.right}\n                height={vertical ? valueLength : slot}\n                rx={4}\n                fill=\"currentColor\"\n                className=\"text-foreground\"\n                initial={false}\n                animate={{\n                  x: vertical ? bandStart + Math.max(activeRank, 0) * slot : valueStart,\n                  y: vertical ? PAD.top : Math.max(activeRank, 0) * slot,\n                  opacity: open && activeRank >= 0 ? 0.05 : 0,\n                }}\n                // The band tracks a finger directly; any glide under touch reads as lag.\n                transition={{\n                  default: touching ? { duration: 0 } : spring,\n                  opacity: { duration: 0.1, ease: EASE_OUT },\n                }}\n              />\n              <g clipPath={`url(#${clipId})`}>\n                {order.map((categoryIndex, rank) => {\n                  const category = categories[categoryIndex];\n                  const delay = intro && !reduce ? Math.min(rank * 0.035, 0.4) : 0;\n                  const position = bandStart + rank * slot;\n                  let stack = 0;\n                  let visibleSlot = 0;\n                  const topRow = [...visibleRows].reverse().find((row) => (row.values[categoryIndex] ?? 0) > 0);\n                  return (\n                    <motion.g\n                      key={category}\n                      initial={false}\n                      animate={{ [vertical ? \"x\" : \"y\"]: position }}\n                      transition={spring}\n                    >\n                      {rows.map((row) => {\n                        const isHidden = hidden.includes(row.id);\n                        const value = isHidden ? 0 : (row.values[categoryIndex] ?? 0);\n                        const length = revealed ? scale(value) : 0;\n                        const start = grouped || !revealed ? 0 : scale(stack);\n                        const offset =\n                          (slot - groupThickness) / 2 +\n                          (grouped\n                            ? (isHidden ? (k - 1) / 2 : visibleSlot) * (thickness + GAP)\n                            : 0);\n                        if (!isHidden) {\n                          stack += value;\n                          visibleSlot++;\n                        }\n                        const capped = grouped || row.id === topRow?.id;\n                        const inset = capped ? radius : 0;\n                        const bodyLength = Math.max(NONE, length - inset);\n                        const end = start + length;\n                        const transition: Transition = {\n                          default: { ...spring, delay },\n                          opacity: { duration: 0.15, ease: EASE_OUT },\n                        };\n                        const shown = value > 0 && !isHidden;\n                        const opacity = highlight !== null && highlight !== row.id ? 0.25 : 1;\n                        return (\n                          <g key={row.id} fill={row.color}>\n                            <motion.rect\n                              x={0}\n                              y={0}\n                              width={vertical ? thickness : 1}\n                              height={vertical ? 1 : thickness}\n                              style={{ originX: 0, originY: 0 }}\n                              initial={false}\n                              animate={\n                                vertical\n                                  ? {\n                                      x: offset,\n                                      y: valueStart - end + inset,\n                                      scaleY: bodyLength,\n                                      opacity: shown ? opacity : 0,\n                                    }\n                                  : {\n                                      y: offset,\n                                      x: valueStart + start,\n                                      scaleX: bodyLength,\n                                      opacity: shown ? opacity : 0,\n                                    }\n                              }\n                              transition={transition}\n                            />\n                            {capped ? (\n                              <motion.rect\n                                x={0}\n                                y={0}\n                                width={vertical ? thickness : radius * 2}\n                                height={vertical ? radius * 2 : thickness}\n                                rx={radius}\n                                initial={false}\n                                animate={\n                                  vertical\n                                    ? { x: offset, y: valueStart - end, opacity: shown ? opacity : 0 }\n                                    : {\n                                        y: offset,\n                                        x: valueStart + end - radius * 2,\n                                        opacity: shown ? opacity : 0,\n                                      }\n                                }\n                                transition={transition}\n                              />\n                            ) : null}\n                          </g>\n                        );\n                      })}\n                    </motion.g>\n                  );\n                })}\n              </g>\n            </svg>\n            {vertical ? (\n              <XYTickLabels\n                ticks={domain.ticks}\n                position={valueAt}\n                axis=\"y\"\n                format={formatTick}\n                reduce={reduce}\n                offset={VALUE_AXIS}\n              />\n            ) : (\n              order.map((categoryIndex, rank) => (\n                <motion.span\n                  key={categories[categoryIndex]}\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"pointer-events-none absolute top-0 left-0 truncate pr-3 text-right text-xs text-muted-foreground\",\n                    rank % stride !== 0 && \"invisible\",\n                  )}\n                  style={{ width: labelWidth, lineHeight: `${slot}px` }}\n                  initial={false}\n                  animate={{ y: rank * slot }}\n                  transition={spring}\n                >\n                  {categories[categoryIndex]}\n                </motion.span>\n              ))\n            )}\n          </>\n        ) : null}\n        <div\n          ref={plotRef}\n          className=\"absolute rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-ring\"\n          style={\n            vertical\n              ? { left: bandStart, width: bandLength, top: PAD.top, bottom: PAD.bottom }\n              : { left: valueStart, width: valueLength, top: 0, height: bandLength }\n          }\n        >\n          <XYInspector\n            count={n}\n            activeIndex={activeRank}\n            onActive={(rank) => setActive(categories[order[rank]])}\n            label={`${label}: inspect categories`}\n            valueText={(rank) => {\n              const i = order[rank];\n              return [\n                categories[i],\n                ...visibleRows.map((row) => {\n                  const value = row.values[i];\n                  return `${row.name} ${value === null ? \"no data\" : formatValue(value)}`;\n                }),\n                ...(layout === \"stacked\" && visibleRows.length > 1\n                  ? [`Total ${formatValue(totals[i])}`]\n                  : []),\n              ].join(\". \");\n            }}\n            mode=\"band\"\n            orientation={vertical ? \"horizontal\" : \"vertical\"}\n            open={open}\n            setOpen={setOpen}\n            onTouchChange={setTouching}\n            tooltipId={tooltipId}\n            canHover={canHover}\n            className=\"inset-0 size-full\"\n          />\n        </div>\n      </div>\n      <div aria-hidden=\"true\" className=\"relative mt-2 h-4\">\n        {width > 0\n          ? vertical\n            ? order.map((categoryIndex, rank) => (\n                <motion.span\n                  key={categories[categoryIndex]}\n                  className={cn(\n                    \"absolute top-0 left-0 truncate px-0.5 text-center text-[11px] text-muted-foreground\",\n                    rank % stride !== 0 && \"invisible\",\n                  )}\n                  // Shown labels borrow the slots of the hidden ones around them.\n                  style={{ width: slot * stride }}\n                  initial={false}\n                  animate={{ x: bandStart + (rank + 0.5) * slot - (slot * stride) / 2 }}\n                  transition={spring}\n                >\n                  {categories[categoryIndex]}\n                </motion.span>\n              ))\n            : (\n              <XYTickLabels\n                ticks={domain.ticks}\n                position={valueAt}\n                axis=\"x\"\n                format={formatTick}\n                reduce={reduce}\n                offset={0}\n              />\n            )\n          : null}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/charts/bar-chart/tooltip.tsx","type":"registry:component","target":"@components/charts/bar-chart/tooltip.tsx","content":"\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { XYTooltipContent, type XYTooltipRow } from \"../xy/tooltip-content\";\nimport { useBarChart } from \"./context\";\n\nexport interface BarChartTooltipData {\n  category: string;\n  index: number;\n  rows: XYTooltipRow[];\n  total: number;\n}\n\nexport interface BarChartTooltipProps {\n  children?: ReactNode | ((data: BarChartTooltipData) => ReactNode);\n  className?: string;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n}\n\n/**\n * Follows the pointer like the other beUI charts' readouts. Keyboard\n * inspection, which has no pointer, anchors at the end of the category's\n * longest bar instead.\n */\nexport function BarChartTooltip({ children, className, side = \"top\" }: BarChartTooltipProps) {\n  const {\n    categories,\n    visibleRows,\n    order,\n    totals,\n    domain,\n    layout,\n    orientation,\n    activeIndex,\n    open,\n    setOpen,\n    plotRef,\n    tooltipId,\n    formatValue,\n  } = useBarChart();\n  const category = categories[activeIndex];\n  const rows: XYTooltipRow[] = visibleRows.map((row) => ({\n    id: row.id,\n    name: row.name,\n    color: row.color,\n    value: row.values[activeIndex] ?? null,\n  }));\n  const total = totals[activeIndex] ?? 0;\n  const reach = layout === \"stacked\" ? total : Math.max(0, ...rows.map((row) => row.value ?? 0));\n  const along = Math.min(1, reach / (domain.max || 1));\n  const across = (Math.max(order.indexOf(activeIndex), 0) + 0.5) / Math.max(categories.length, 1);\n  return (\n    <Tooltip\n      id={tooltipId}\n      anchorRef={plotRef}\n      followCursor\n      anchorPoint={\n        orientation === \"vertical\" ? { x: across, y: 1 - along } : { x: along, y: across }\n      }\n      open={open && category !== undefined}\n      onOpenChange={setOpen}\n      side={side}\n      className={cn(\"max-w-64\", className)}\n      content={\n        category === undefined ? null : typeof children === \"function\" ? (\n          children({ category, index: activeIndex, rows, total })\n        ) : (\n          (children ?? (\n            <XYTooltipContent\n              title={category}\n              rows={rows}\n              formatValue={formatValue}\n              total={layout === \"stacked\" && rows.length > 1 ? total : undefined}\n              compact\n            />\n          ))\n        )\n      }\n    />\n  );\n}\n"},{"path":"components/charts/xy/data-table.tsx","type":"registry:component","target":"@components/charts/xy/data-table.tsx","content":"import type { XYRow } from \"./model\";\n\n/** Every value stays readable by screen readers without a tab stop per mark. */\nexport function XYDataTable({\n  label,\n  rows,\n  categories,\n  formatValue,\n}: {\n  label: string;\n  rows: readonly XYRow[];\n  categories: readonly string[];\n  formatValue: (value: number) => string;\n}) {\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{label} data</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Category</th>\n            {rows.map((row) => (\n              <th key={row.id} scope=\"col\">\n                {row.name}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {categories.map((category, i) => (\n            <tr key={category}>\n              <th scope=\"row\">{category}</th>\n              {rows.map((row) => {\n                const value = row.values[i];\n                return <td key={row.id}>{value === null ? \"No data\" : formatValue(value)}</td>;\n              })}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n"},{"path":"components/charts/xy/model.ts","type":"registry:component","target":"@components/charts/xy/model.ts","content":"export interface XYSeries {\n  /** Stable, unique identity. Keep it when updating values so marks morph in place. */\n  id: string;\n  name: string;\n  /** Any CSS color, including theme variables. */\n  color: string;\n  /** Values aligned with categories. Missing or non-finite values leave a gap. */\n  values: readonly (number | null)[];\n}\n\nexport type XYRow = Omit<XYSeries, \"values\"> & { values: (number | null)[] };\n\n/** First of repeated series and category ids wins. Invalid values become gaps, never zeros. */\nexport function normalizeXY(\n  series: readonly XYSeries[],\n  categories: readonly string[],\n  { nonnegative = false }: { nonnegative?: boolean } = {},\n) {\n  const columns: { id: string; source: number }[] = [];\n  const seenColumns = new Set<string>();\n  categories.forEach((id, source) => {\n    if (seenColumns.has(id)) return;\n    seenColumns.add(id);\n    columns.push({ id, source });\n  });\n  const seenRows = new Set<string>();\n  const rows: XYRow[] = [];\n  for (const { values, ...row } of series) {\n    if (seenRows.has(row.id)) continue;\n    seenRows.add(row.id);\n    rows.push({\n      ...row,\n      values: columns.map(({ source }) => {\n        const value = values[source];\n        return typeof value === \"number\" && Number.isFinite(value) && (!nonnegative || value >= 0)\n          ? value\n          : null;\n      }),\n    });\n  }\n  return { rows, categories: columns.map((column) => column.id) };\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":"components/charts/xy/scale.ts","type":"registry:component","target":"@components/charts/xy/scale.ts","content":"export interface XYDomain {\n  min: number;\n  max: number;\n  ticks: number[];\n}\n\nconst tidy = (value: number) => Number(value.toPrecision(12));\n\nfunction niceStep(raw: number) {\n  const power = 10 ** Math.floor(Math.log10(raw));\n  const fraction = raw / power;\n  return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power;\n}\n\n/** Round a value range out to human tick steps (1, 2, 5 × 10ⁿ). */\nexport function niceDomain(\n  values: readonly number[],\n  { includeZero = false, count = 4, fixed }: {\n    includeZero?: boolean;\n    count?: number;\n    fixed?: readonly [number, number];\n  } = {},\n): XYDomain {\n  let min = fixed?.[0] ?? (values.length ? Math.min(...values) : 0);\n  let max = fixed?.[1] ?? (values.length ? Math.max(...values) : 1);\n  if (includeZero && !fixed) {\n    min = Math.min(0, min);\n    max = Math.max(0, max);\n  }\n  if (min === max) {\n    if (min === 0) max = 1;\n    else if (includeZero && min > 0) min = 0;\n    else {\n      const pad = Math.abs(min) * 0.1;\n      min -= pad;\n      max += pad;\n    }\n  }\n  const step = niceStep((max - min) / count);\n  const lo = fixed ? min : tidy(Math.floor(min / step) * step);\n  const hi = fixed ? max : tidy(Math.ceil(max / step) * step);\n  const ticks: number[] = [];\n  for (let tick = Math.ceil(lo / step) * step; tick <= hi + step / 1e6; tick += step)\n    ticks.push(tidy(tick));\n  return { min: lo, max: hi, ticks };\n}\n\n/** Show every nth label so neighbours never overlap. */\nexport function labelStride(count: number, length: number, minGap = 64) {\n  if (count <= 1 || length <= 0) return 1;\n  return Math.max(1, Math.ceil((count * minGap) / length));\n}\n"},{"path":"components/charts/xy/use-inspection.ts","type":"registry:component","target":"@components/charts/xy/use-inspection.ts","content":"\"use client\";\n\nimport { useCallback, useId, useLayoutEffect, useRef, useState } from \"react\";\n\n/** Controlled/uncontrolled value with a stable setter. */\nexport function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (next: T) => void) {\n  const [internal, setInternal] = useState(defaultValue);\n  const latest = useRef({ value, internal, onChange });\n  useLayoutEffect(() => {\n    latest.current = { value, internal, onChange };\n  });\n  const set = useCallback((next: T) => {\n    const current = latest.current;\n    if (current.value === undefined) setInternal(next);\n    if ((current.value === undefined ? current.internal : current.value) !== next)\n      current.onChange?.(next);\n  }, []);\n  return [value === undefined ? internal : value, set, setInternal] as const;\n}\n\n/**\n * The inspected category is held by id and resolved during render, so a\n * category that leaves the data clears the cursor instead of landing on a\n * neighbour, and one that comes back cannot revive it.\n */\nexport function useInspection({\n  ids,\n  activeId,\n  defaultActiveId = null,\n  onActiveChange,\n}: {\n  ids: readonly string[];\n  activeId?: string | null;\n  defaultActiveId?: string | null;\n  onActiveChange?: (id: string | null) => void;\n}) {\n  const [requested, setActive, setInternal] = useControllable(\n    activeId,\n    defaultActiveId,\n    onActiveChange,\n  );\n  if (activeId === undefined && requested !== null && !ids.includes(requested)) setInternal(null);\n  const activeIndex = requested === null ? -1 : ids.indexOf(requested);\n  const [open, setOpen] = useState(false);\n  // A finger on the plot: inspection marks follow it directly instead of gliding.\n  const [touching, setTouching] = useState(false);\n  const plotRef = useRef<HTMLDivElement>(null);\n  const tooltipId = useId();\n  return {\n    activeId: activeIndex >= 0 ? requested : null,\n    activeIndex,\n    setActive,\n    open,\n    setOpen,\n    touching,\n    setTouching,\n    plotRef,\n    tooltipId,\n  };\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":"components/charts/xy/legend.tsx","type":"registry:component","target":"@components/charts/xy/legend.tsx","content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { XYRow } from \"./model\";\n\nexport interface XYLegendProps {\n  rows: readonly XYRow[];\n  hidden: readonly string[];\n  onToggle: (id: string) => void;\n  onHover: (id: string | null) => void;\n  canHover: boolean;\n  label: string;\n  className?: string;\n}\n\n/** Each entry is a toggle: pressed means the series is drawn. */\nexport function XYLegend({\n  rows,\n  hidden,\n  onToggle,\n  onHover,\n  canHover,\n  label,\n  className,\n}: XYLegendProps) {\n  return (\n    <ul\n      aria-label={label}\n      className={cn(\"flex flex-wrap items-center gap-x-1 gap-y-1 text-xs\", className)}\n    >\n      {rows.map((row) => {\n        const shown = !hidden.includes(row.id);\n        return (\n          <li key={row.id}>\n            <button\n              type=\"button\"\n              aria-pressed={shown}\n              onClick={() => onToggle(row.id)}\n              onPointerEnter={() => {\n                if (canHover && shown) onHover(row.id);\n              }}\n              onPointerLeave={() => onHover(null)}\n              onFocus={() => {\n                if (shown) onHover(row.id);\n              }}\n              onBlur={() => onHover(null)}\n              className={cn(\n                \"flex min-h-9 items-center gap-2 rounded-md px-2 text-muted-foreground transition-[color,opacity] duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n                !shown && \"opacity-50\",\n              )}\n            >\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"size-2.5 shrink-0 rounded-full border-2 transition-colors duration-150\",\n                )}\n                style={{ borderColor: row.color, backgroundColor: shown ? row.color : \"transparent\" }}\n              />\n              <span className={cn(!shown && \"line-through decoration-muted-foreground/60\")}>\n                {row.name}\n              </span>\n            </button>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n"},{"path":"components/charts/xy/axis.tsx","type":"registry:component","target":"@components/charts/xy/axis.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\n\nconst FADE = { duration: 0.16, ease: EASE_OUT } as const;\nconst EXIT = { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } } as const;\n\n/**\n * Value gridlines keyed by tick value: a tick that survives a rescale glides to\n * its new position, new ticks fade in where they belong, old ones fade out.\n */\nexport function XYGridLines({\n  ticks,\n  position,\n  axis,\n  from,\n  to,\n  reduce,\n}: {\n  ticks: readonly number[];\n  position: (value: number) => number;\n  /** `y` draws horizontal lines at y positions; `x` vertical lines at x positions. */\n  axis: \"x\" | \"y\";\n  from: number;\n  to: number;\n  reduce: boolean;\n}) {\n  return (\n    <AnimatePresence initial={false}>\n      {ticks.map((tick) => {\n        const at = { [axis]: position(tick) };\n        return (\n          <motion.line\n            key={tick}\n            {...(axis === \"y\" ? { x1: from, x2: to, y1: 0, y2: 0 } : { y1: from, y2: to, x1: 0, x2: 0 })}\n            // Border tokens are tuned for card edges and vanish as thin rules on a plot.\n            stroke=\"currentColor\"\n            strokeOpacity={tick === 0 ? 0.3 : 0.12}\n            className=\"text-muted-foreground\"\n            initial={{ opacity: 0, ...at }}\n            animate={{ opacity: 1, ...at }}\n            exit={EXIT}\n            transition={{ opacity: FADE, default: reduce ? { duration: 0 } : SPRING_LAYOUT }}\n          />\n        );\n      })}\n    </AnimatePresence>\n  );\n}\n\nexport function XYTickLabels({\n  ticks,\n  position,\n  axis,\n  format,\n  reduce,\n  offset,\n}: {\n  ticks: readonly number[];\n  position: (value: number) => number;\n  /** `y` stacks labels in a left gutter; `x` lines them up under the plot. */\n  axis: \"x\" | \"y\";\n  format: (value: number) => string;\n  reduce: boolean;\n  /** Gutter width for `y`, or the top edge of the label row for `x`, in px. */\n  offset: number;\n}) {\n  return (\n    <AnimatePresence initial={false}>\n      {ticks.map((tick) => {\n        const at = { [axis]: position(tick) };\n        return (\n          <motion.span\n            key={tick}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-0 left-0 font-mono text-[11px] whitespace-nowrap text-muted-foreground tabular-nums\"\n            style={axis === \"y\" ? { width: offset - 8 } : { top: offset }}\n            initial={{ opacity: 0, ...at }}\n            animate={{ opacity: 1, ...at }}\n            exit={EXIT}\n            transition={{ opacity: FADE, default: reduce ? { duration: 0 } : SPRING_LAYOUT }}\n          >\n            <span\n              className={\n                axis === \"y\" ? \"block -translate-y-1/2 text-right\" : \"block -translate-x-1/2\"\n              }\n            >\n              {format(tick)}\n            </span>\n          </motion.span>\n        );\n      })}\n    </AnimatePresence>\n  );\n}\n"},{"path":"components/charts/xy/inspector.tsx","type":"registry:component","target":"@components/charts/xy/inspector.tsx","content":"\"use client\";\n\nimport { type CSSProperties, type PointerEvent, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface XYInspectorProps {\n  count: number;\n  activeIndex: number;\n  onActive: (index: number) => void;\n  /** Accessible name of the slider. */\n  label: string;\n  valueText: (index: number) => string;\n  /** `point` snaps to the nearest sample (lines); `band` to the slot under the pointer (bars). */\n  mode: \"point\" | \"band\";\n  /** `vertical` reads categories top to bottom, for horizontal bars. */\n  orientation?: \"horizontal\" | \"vertical\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  tooltipId?: string;\n  canHover: boolean;\n  /** Reports a touch drag starting and ending, so marks can track the finger 1:1. */\n  onTouchChange?: (touching: boolean) => void;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/**\n * One invisible native range over the plot: a single tab stop for keyboard and\n * assistive technology, with pointer hit testing that matches the drawn marks.\n */\nexport function XYInspector({\n  count,\n  activeIndex,\n  onActive,\n  label,\n  valueText,\n  mode,\n  orientation = \"horizontal\",\n  open,\n  setOpen,\n  tooltipId,\n  canHover,\n  onTouchChange,\n  className,\n  style,\n}: XYInspectorProps) {\n  const pointerDriven = useRef(false);\n  const focused = useRef(false);\n  // Focus from a mouse click is incidental: it must not pin the tooltip open\n  // once the pointer leaves. Focus from the keyboard (or keys pressed after a\n  // click) is deliberate and keeps it open until blur or Escape.\n  const mouseFocus = useRef(false);\n  const last = useRef<number | null>(null);\n  const index = activeIndex >= 0 ? activeIndex : count - 1;\n  const vertical = orientation === \"vertical\";\n  const inspectPointer = (event: PointerEvent<HTMLInputElement>) => {\n    const bounds = event.currentTarget.getBoundingClientRect();\n    const length = vertical ? bounds.height : bounds.width;\n    if (!length || !count) return;\n    const offset = vertical ? event.clientY - bounds.top : event.clientX - bounds.left;\n    const fraction = Math.min(1, Math.max(0, offset / length));\n    const next =\n      mode === \"point\"\n        ? Math.round(fraction * (count - 1))\n        : Math.min(count - 1, Math.floor(fraction * count));\n    onActive(next);\n    if (last.current !== next) setOpen(true);\n    last.current = next;\n  };\n  const step = (next: number) => {\n    onActive(Math.min(count - 1, Math.max(0, next)));\n    setOpen(true);\n  };\n  if (!count) return null;\n  return (\n    <input\n      type=\"range\"\n      aria-label={label}\n      aria-orientation={orientation}\n      min={0}\n      max={count - 1}\n      step={1}\n      value={index}\n      aria-valuetext={valueText(index)}\n      aria-describedby={open && activeIndex >= 0 ? tooltipId : undefined}\n      className={cn(\"absolute m-0 cursor-crosshair touch-pan-y opacity-0\", className)}\n      style={style}\n      onFocus={() => {\n        focused.current = true;\n        if (!pointerDriven.current) step(index);\n      }}\n      onBlur={() => {\n        focused.current = false;\n        mouseFocus.current = false;\n        pointerDriven.current = false;\n        setOpen(false);\n      }}\n      onPointerEnter={(event) => {\n        if (canHover && event.pointerType !== \"touch\") inspectPointer(event);\n      }}\n      onPointerMove={(event) => {\n        if (event.pointerType === \"touch\" ? event.buttons === 1 : canHover || event.buttons === 1)\n          inspectPointer(event);\n      }}\n      onPointerLeave={(event) => {\n        last.current = null;\n        // Touch fires a leave after every tap; tap-to-inspect stays open.\n        const mouse = event.pointerType !== \"touch\" && mouseFocus.current;\n        if (!focused.current || pointerDriven.current || mouse) setOpen(false);\n      }}\n      onPointerDown={(event) => {\n        pointerDriven.current = true;\n        if (event.pointerType === \"touch\") onTouchChange?.(true);\n        else mouseFocus.current = true;\n        last.current = null;\n        inspectPointer(event);\n      }}\n      onPointerUp={(event) => {\n        if (event.pointerType === \"touch\") onTouchChange?.(false);\n      }}\n      onPointerCancel={(event) => {\n        pointerDriven.current = false;\n        if (event.pointerType === \"touch\") onTouchChange?.(false);\n        setOpen(false);\n      }}\n      onClick={() => {\n        // Native range changes finish before click. Release the pointer guard\n        // so later assistive-technology changes are accepted.\n        pointerDriven.current = false;\n      }}\n      onKeyDown={(event) => {\n        pointerDriven.current = false;\n        mouseFocus.current = false;\n        if (event.key === \"Escape\") setOpen(false);\n        else if (vertical && (event.key === \"ArrowUp\" || event.key === \"ArrowDown\")) {\n          // Categories read top to bottom, so Down moves down the list.\n          event.preventDefault();\n          step(index + (event.key === \"ArrowDown\" ? 1 : -1));\n        } else if (\n          [\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\", \"Home\", \"End\", \"PageUp\", \"PageDown\"].includes(\n            event.key,\n          )\n        )\n          setOpen(true);\n      }}\n      onChange={(event) => {\n        // Native thumb travel differs from the drawn marks. Pointer hit testing\n        // owns pointer gestures; the range owns keyboard and AT input.\n        if (pointerDriven.current) return;\n        step(Number(event.target.value));\n      }}\n    />\n  );\n}\n"},{"path":"components/charts/xy/use-width.ts","type":"registry:component","target":"@components/charts/xy/use-width.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\n/** Pixel geometry keeps dots round and strokes even; nothing renders before the first measure. */\nexport function useElementWidth<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node) return;\n    const update = () => setWidth(node.clientWidth);\n    update();\n    const observer = new ResizeObserver(update);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return { ref, width };\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/charts/xy/tooltip-content.tsx","type":"registry:component","target":"@components/charts/xy/tooltip-content.tsx","content":"\"use client\";\n\nimport { MotionConfig } from \"motion/react\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface XYTooltipRow {\n  id: string;\n  name: string;\n  color: string;\n  value: number | null;\n}\n\nfunction Value({ value, format }: { value: number; format: (value: number) => string }) {\n  return (\n    // Keep the short digit tween when composed inside spring-based Tabs.\n    <MotionConfig transition={{ type: \"tween\" }}>\n      <NumberTicker\n        value={value}\n        // Keep the consumer's exact formatting, including fractions and units.\n        format={() => format(value)}\n        startOnView={false}\n        duration={0.2}\n        stagger={0}\n        className=\"whitespace-pre\"\n      />\n    </MotionConfig>\n  );\n}\n\nexport function XYTooltipContent({\n  title,\n  rows,\n  formatValue,\n  total,\n  compact = false,\n  className,\n}: {\n  title: string;\n  rows: readonly XYTooltipRow[];\n  formatValue: (value: number) => string;\n  total?: number | null;\n  /** Size to the content instead of a fixed panel width, for pointer-following readouts. */\n  compact?: boolean;\n  className?: string;\n}) {\n  return (\n    <span className={cn(\"block max-w-full min-w-0\", compact ? \"min-w-36\" : \"w-56\", className)}>\n      <span className={cn(\"block text-xs font-medium\", compact ? \"mb-1.5\" : \"mb-2\")}>{title}</span>\n      <span className={cn(\"grid\", compact ? \"gap-1\" : \"gap-1.5\")}>\n        {rows.map((row) => (\n          <span key={row.id} className=\"flex items-center gap-2 text-[11px]\">\n            <span\n              aria-hidden=\"true\"\n              className=\"h-2.5 w-1 shrink-0 rounded-full\"\n              style={{ backgroundColor: row.color }}\n            />\n            <span className=\"min-w-0 flex-1 truncate pr-3 text-muted-foreground\">{row.name}</span>\n            <span className=\"shrink-0 font-mono tabular-nums\">\n              {row.value === null ? \"No data\" : <Value value={row.value} format={formatValue} />}\n            </span>\n          </span>\n        ))}\n        {total !== undefined && total !== null ? (\n          <span className=\"mt-0.5 flex items-center gap-2 border-t border-border pt-1.5 text-[11px]\">\n            <span className=\"flex-1 text-muted-foreground\">Total</span>\n            <span className=\"font-mono font-medium tabular-nums\">\n              <Value value={total} format={formatValue} />\n            </span>\n          </span>\n        ) : null}\n      </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/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/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"}]}