{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"line-chart","type":"registry:component","title":"Line Chart","description":"Line and area charts that morph with your data, with a snapping crosshair and value tooltips.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/line-chart.tsx","type":"registry:component","target":"@components/charts/line-chart.tsx","content":"\"use client\";\n// beui.dev/charts/line-chart\n\nimport { cn } from \"@/lib/utils\";\nimport { LineChartContext, type LineChartProps, useLineChartModel } from \"./line-chart/context\";\nimport { LineChartLegend } from \"./line-chart/legend\";\nimport { LineChartPlot } from \"./line-chart/plot\";\nimport { LineChartTooltip } from \"./line-chart/tooltip\";\nimport { XYDataTable } from \"./xy/data-table\";\n\n/** Compose Plot, Tooltip and Legend, or omit children for the complete chart. */\nexport function LineChart({\n  series,\n  categories,\n  variant,\n  curve,\n  domain,\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  hiddenSeries,\n  defaultHiddenSeries,\n  onHiddenSeriesChange,\n  formatValue,\n  formatTick,\n  label,\n  status,\n  loadingLabel,\n  children,\n  className,\n  ...props\n}: LineChartProps) {\n  const model = useLineChartModel({\n    series,\n    categories,\n    variant,\n    curve,\n    domain,\n    activeId,\n    defaultActiveId,\n    onActiveChange,\n    hiddenSeries,\n    defaultHiddenSeries,\n    onHiddenSeriesChange,\n    formatValue,\n    formatTick,\n    label,\n    status,\n    loadingLabel,\n  });\n  return (\n    <LineChartContext.Provider value={model}>\n      <section\n        aria-label={model.label}\n        aria-busy={model.loading || undefined}\n        {...props}\n        data-slot=\"line-chart\"\n        className={cn(\"w-full min-w-0 space-y-3\", className)}\n      >\n        {children === undefined ? (\n          <>\n            <LineChartPlot />\n            <LineChartTooltip />\n            {model.rows.length > 1 ? <LineChartLegend /> : null}\n          </>\n        ) : (\n          children\n        )}\n        {/* Placeholder or stale values are not data yet. */}\n        {model.loading ? null : (\n          <XYDataTable\n            label={model.label}\n            rows={model.rows}\n            categories={model.categories}\n            formatValue={model.formatValue}\n          />\n        )}\n      </section>\n    </LineChartContext.Provider>\n  );\n}\n\nexport type { LineChartProps } from \"./line-chart/context\";\nexport { useLineChart } from \"./line-chart/context\";\nexport type { LineChartLegendProps } from \"./line-chart/legend\";\nexport { LineChartLegend } from \"./line-chart/legend\";\nexport type { LineChartPlotProps } from \"./line-chart/plot\";\nexport { LineChartPlot } from \"./line-chart/plot\";\nexport type { LineChartTooltipData, LineChartTooltipProps } from \"./line-chart/tooltip\";\nexport { LineChartTooltip } from \"./line-chart/tooltip\";\nexport type { XYSeries as LineChartSeries } from \"./xy/model\";\nexport type { XYCurve as LineChartCurve } from \"./xy/path\";\n"},{"path":"components/charts/line-chart/context.tsx","type":"registry:component","target":"@components/charts/line-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 type { XYCurve } from \"../xy/path\";\nimport { niceDomain } from \"../xy/scale\";\nimport { useControllable, useInspection } from \"../xy/use-inspection\";\n\nexport interface LineChartProps extends Omit<ComponentProps<\"section\">, \"children\"> {\n  series: readonly XYSeries[];\n  /** Unique x-axis labels in display order. Values align with them by index. */\n  categories: readonly string[];\n  /** `area` fills each line down to zero with a fading gradient. */\n  variant?: \"line\" | \"area\";\n  curve?: XYCurve;\n  /** Fixed value-axis bounds. Defaults to rounded bounds around the visible series. */\n  domain?: readonly [number, 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  /**\n   * `loading` hides the lines behind a pulsing placeholder until your data\n   * arrives; switching to `ready` hands off to the draw-in. Keep one chart\n   * mounted across both states.\n   */\n  status?: \"ready\" | \"loading\";\n  /** Shown over the placeholder while loading. An empty string hides it. */\n  loadingLabel?: 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 useLineChartModel({\n  series,\n  categories,\n  variant = \"line\",\n  curve = \"monotone\",\n  domain: fixedDomain,\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 = \"Line chart\",\n  status = \"ready\",\n  loadingLabel = \"Loading data\",\n}: Omit<LineChartProps, \"children\">) {\n  // The plot area hosts the tooltip panel, and its measured size places it.\n  const [overlay, setOverlay] = useState<HTMLDivElement | null>(null);\n  const [geometry, setGeometry] = useState({ width: 0, height: 0 });\n  const data = useMemo(() => normalizeXY(series, categories), [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  // Hiding a series rescales the axis to what is left, and every mark springs there.\n  const domain = niceDomain(\n    visibleRows.flatMap((row) => row.values.filter((value): value is number => value !== null)),\n    { includeZero: variant === \"area\", fixed: fixedDomain },\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    hidden,\n    toggle: (id: string) =>\n      setHidden(hidden.includes(id) ? hidden.filter((h) => h !== id) : [...hidden, id]),\n    highlight,\n    setHovered,\n    domain,\n    variant,\n    curve,\n    formatValue,\n    formatTick,\n    label,\n    status,\n    loading: status === \"loading\",\n    loadingLabel,\n    overlay,\n    setOverlay,\n    geometry,\n    setGeometry,\n    reduce: useReducedMotion() ?? false,\n    canHover: useHoverCapable(),\n  };\n}\n\nexport type LineChartModel = ReturnType<typeof useLineChartModel>;\n\nexport const LineChartContext = createContext<LineChartModel | null>(null);\n\nexport function useLineChart() {\n  const context = useContext(LineChartContext);\n  if (!context) throw new Error(\"Line chart parts must be rendered inside LineChart.\");\n  return context;\n}\n"},{"path":"components/charts/line-chart/legend.tsx","type":"registry:component","target":"@components/charts/line-chart/legend.tsx","content":"\"use client\";\n\nimport { XYLegend } from \"../xy/legend\";\nimport { useLineChart } from \"./context\";\n\nexport interface LineChartLegendProps {\n  className?: string;\n}\n\n/** Toggle a series to hide it; the axis rescales to what is left. */\nexport function LineChartLegend({ className }: LineChartLegendProps) {\n  const { rows, hidden, toggle, setHovered, canHover } = useLineChart();\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/line-chart/plot.tsx","type":"registry:component","target":"@components/charts/line-chart/plot.tsx","content":"\"use client\";\n\n/* Lines draw in left to right through a clip the first time the chart scrolls\n * into view, and fade out at both ends of the plot. Every sample is a pair of\n * springs keyed by series and category, so new data, a hidden series or a\n * rescaled axis morph the lines, fills and dots together; a resize snaps.\n *\n * Inspecting glides a crosshair that fades toward its ends, haloed dots, and a\n * pill on the category axis that rolls to the new label while the labels it\n * would cover step aside. Everything right of the crosshair recedes, so the\n * line reads \"up to here\".\n *\n * While loading, a placeholder line breathes under a travelling highlight.\n * When data arrives it fades away and the real lines draw in.\n *\n * Reduced motion: no draw-in, springs, roll or travelling highlight; values\n * update in place and the placeholder only pulses its opacity. */\n\nimport {\n  AnimatePresence,\n  type MotionValue,\n  motion,\n  type Transition,\n  useInView,\n  useTransform,\n} from \"motion/react\";\nimport { useId, useLayoutEffect, useState } from \"react\";\nimport { TextShimmer } from \"@/components/motion/text-shimmer\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { XYGridLines, XYTickLabels } from \"../xy/axis\";\nimport { XYInspector } from \"../xy/inspector\";\nimport type { XYRow } from \"../xy/model\";\nimport { areaPath, linePath, type XYCurve } from \"../xy/path\";\nimport { labelStride } from \"../xy/scale\";\nimport { useAnimatedPoints, type XYAnimatedPoint, type XYPointTarget } from \"../xy/use-points\";\nimport { useElementWidth } from \"../xy/use-width\";\nimport { useLineChart } from \"./context\";\n\n/** Left gutter for value labels, in px. */\nconst AXIS = 44;\nconst PAD = { top: 12, right: 16, bottom: 8 } as const;\nconst BASELINE = \"baseline\";\n/** Category labels closer than this to the inspected one hide behind its pill, in px. */\nconst PILL_CLEAR = 40;\n/** How much the line right of the crosshair recedes (mask darkness, 0 to 1). */\nconst RECEDE = 0.6;\nconst PLACEHOLDER_SAMPLES = 16;\nconst pointKey = (series: string, category: string) => JSON.stringify([series, category]);\n\nexport interface LineChartPlotProps {\n  /** Plot height in px, excluding the category labels. */\n  height?: number;\n  /** Stroke width of each line, in px. */\n  strokeWidth?: number;\n  showGrid?: boolean;\n  /** Fade lines and fills out toward the left and right edges of the plot. */\n  fadeEdges?: boolean;\n  className?: string;\n}\n\nexport function LineChartPlot({\n  height = 288,\n  strokeWidth = 2,\n  showGrid = true,\n  fadeEdges = true,\n  className,\n}: LineChartPlotProps) {\n  const {\n    rows,\n    categories,\n    visibleRows,\n    hidden,\n    highlight,\n    domain,\n    variant,\n    curve,\n    formatValue,\n    formatTick,\n    label,\n    loading,\n    loadingLabel,\n    activeIndex,\n    setActive,\n    open,\n    setOpen,\n    touching,\n    setTouching,\n    setOverlay,\n    setGeometry,\n    tooltipId,\n    reduce,\n    canHover,\n  } = useLineChart();\n  const { ref, width } = useElementWidth<HTMLDivElement>();\n  const inView = useInView(ref, { once: true, amount: 0.3 });\n  const id = useId();\n  const ids = {\n    clip: `${id}-clip`,\n    mask: `${id}-mask`,\n    edges: `${id}-edges`,\n    crosshair: `${id}-crosshair`,\n    pulse: `${id}-pulse`,\n    band: `${id}-band`,\n  };\n  const n = categories.length;\n  const plotWidth = Math.max(0, width - AXIS - PAD.right);\n  const innerHeight = height - PAD.top - PAD.bottom;\n  const x = (i: number) => AXIS + (n === 1 ? plotWidth / 2 : (i / (n - 1)) * plotWidth);\n  const y = (value: number) =>\n    PAD.top + ((domain.max - value) / (domain.max - domain.min || 1)) * innerHeight;\n  const baseline = y(Math.min(domain.max, Math.max(domain.min, 0)));\n\n  useLayoutEffect(() => {\n    setGeometry((previous) =>\n      previous.width === plotWidth && previous.height === innerHeight\n        ? previous\n        : { width: plotWidth, height: innerHeight },\n    );\n  }, [plotWidth, innerHeight, setGeometry]);\n\n  const targets: XYPointTarget[] = [];\n  if (width > 0) {\n    targets.push({ key: BASELINE, x: 0, y: baseline });\n    for (const row of rows)\n      row.values.forEach((value, i) => {\n        if (value !== null)\n          targets.push({ key: pointKey(row.id, categories[i]), x: x(i), y: y(value) });\n      });\n  }\n  const points = useAnimatedPoints(targets, reduce, width);\n\n  const active = activeIndex >= 0 && !loading ? activeIndex : null;\n  const inspecting = open && active !== null;\n  // Roll the pill's label up when moving right and down when moving left.\n  const [lastActive, setLastActive] = useState(active);\n  const [direction, setDirection] = useState(1);\n  if (active !== lastActive) {\n    if (active !== null && lastActive !== null) setDirection(active < lastActive ? -1 : 1);\n    setLastActive(active);\n  }\n\n  if ((!n || !rows.length) && !loading)\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  const activeValues =\n    active === null\n      ? []\n      : visibleRows.flatMap((row) => {\n          const value = row.values[active];\n          return value === null ? [] : [{ row, value }];\n        });\n  const stride = labelStride(n, plotWidth, 64);\n  const baselinePoint = points.get(BASELINE);\n  // Inspection marks glide between samples for a mouse or keys, but under a\n  // finger any glide reads as lag, so they track the touch directly.\n  const spring: Transition = reduce || touching ? { duration: 0 } : SPRING_LAYOUT;\n  const revealed = (inView || reduce) && !loading;\n  const fade = plotWidth > 0 ? Math.min(32, plotWidth * 0.08) / plotWidth : 0;\n  const crossX = active === null ? AXIS : x(active);\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={ids.clip}>\n                  <motion.rect\n                    x={0}\n                    y={-16}\n                    width={width}\n                    height={height + 32}\n                    style={{ originX: 0 }}\n                    initial={reduce ? false : { scaleX: 0 }}\n                    animate={{ scaleX: revealed || reduce ? 1 : 0 }}\n                    // Wait for the placeholder to leave before drawing in, and\n                    // collapse only after the lines have faded on the way back.\n                    transition={\n                      revealed\n                        ? { duration: 0.7, ease: EASE_OUT, delay: 0.12 }\n                        : { duration: 0, delay: 0.16 }\n                    }\n                  />\n                </clipPath>\n                <linearGradient\n                  id={ids.edges}\n                  gradientUnits=\"userSpaceOnUse\"\n                  x1={AXIS}\n                  x2={AXIS + plotWidth}\n                  y1={0}\n                  y2={0}\n                >\n                  <stop offset={0} stopColor=\"white\" stopOpacity={fadeEdges ? 0 : 1} />\n                  <stop offset={fade} stopColor=\"white\" />\n                  <stop offset={1 - fade} stopColor=\"white\" />\n                  <stop offset={1} stopColor=\"white\" stopOpacity={fadeEdges ? 0 : 1} />\n                </linearGradient>\n                <mask\n                  id={ids.mask}\n                  maskUnits=\"userSpaceOnUse\"\n                  x={0}\n                  y={-16}\n                  width={width}\n                  height={height + 32}\n                >\n                  <rect x={0} y={-16} width={width} height={height + 32} fill={`url(#${ids.edges})`} />\n                  <motion.rect\n                    x={0}\n                    y={-16}\n                    width={width}\n                    height={height + 32}\n                    fill=\"black\"\n                    initial={{ x: crossX, opacity: 0 }}\n                    animate={{ x: crossX, opacity: inspecting ? RECEDE : 0 }}\n                    transition={{ x: spring, opacity: { duration: 0.18, ease: EASE_OUT } }}\n                  />\n                </mask>\n                <linearGradient\n                  id={ids.crosshair}\n                  gradientUnits=\"userSpaceOnUse\"\n                  x1={0}\n                  x2={0}\n                  y1={PAD.top}\n                  y2={height - PAD.bottom}\n                >\n                  <stop offset={0} stopColor=\"var(--foreground)\" stopOpacity={0} />\n                  <stop offset={0.25} stopColor=\"var(--foreground)\" stopOpacity={0.35} />\n                  <stop offset={0.75} stopColor=\"var(--foreground)\" stopOpacity={0.35} />\n                  <stop offset={1} stopColor=\"var(--foreground)\" stopOpacity={0} />\n                </linearGradient>\n              </defs>\n              {showGrid ? (\n                <XYGridLines\n                  ticks={domain.ticks}\n                  position={y}\n                  axis=\"y\"\n                  from={AXIS}\n                  to={AXIS + plotWidth}\n                  reduce={reduce}\n                />\n              ) : null}\n              {baselinePoint ? (\n                <g mask={`url(#${ids.mask})`}>\n                  <motion.g\n                    clipPath={`url(#${ids.clip})`}\n                    initial={false}\n                    animate={{ opacity: loading ? 0 : 1 }}\n                    transition={{ duration: 0.15, ease: EASE_OUT }}\n                  >\n                    {rows.map((row) => (\n                      <Series\n                        key={row.id}\n                        row={row}\n                        points={row.values.map((value, i) =>\n                          value === null\n                            ? null\n                            : (points.get(pointKey(row.id, categories[i])) ?? null),\n                        )}\n                        baseline={baselinePoint.y}\n                        curve={curve}\n                        area={variant === \"area\"}\n                        // Overlapping fills compound; keep several series from muddying into one slab.\n                        fillOpacity={rows.length > 1 ? 0.14 : 0.28}\n                        strokeWidth={strokeWidth}\n                        hidden={hidden.includes(row.id)}\n                        dimmed={highlight !== null && highlight !== row.id}\n                      />\n                    ))}\n                  </motion.g>\n                </g>\n              ) : null}\n              <AnimatePresence>\n                {loading ? (\n                  <Placeholder\n                    key=\"placeholder\"\n                    ids={ids}\n                    left={AXIS}\n                    width={plotWidth}\n                    top={PAD.top}\n                    height={innerHeight}\n                    strokeWidth={strokeWidth}\n                    reduce={reduce}\n                  />\n                ) : null}\n              </AnimatePresence>\n              {inspecting && active !== null ? (\n                <>\n                  <motion.line\n                    y1={PAD.top}\n                    y2={height - PAD.bottom}\n                    x1={0}\n                    x2={0}\n                    stroke={`url(#${ids.crosshair})`}\n                    initial={{ x: x(active), opacity: 0 }}\n                    animate={{ x: x(active), opacity: 1 }}\n                    transition={{ x: spring, opacity: { duration: 0.12 } }}\n                  />\n                  {activeValues.map(({ row, value }) => (\n                    <motion.g\n                      key={row.id}\n                      initial={{ x: x(active), y: y(value), opacity: 0, scale: 0.5 }}\n                      animate={{ x: x(active), y: y(value), opacity: 1, scale: 1 }}\n                      transition={{ default: spring, opacity: { duration: 0.12 } }}\n                    >\n                      <circle r={10} fill={row.color} opacity={0.18} />\n                      <circle r={4} fill={row.color} stroke=\"var(--background)\" strokeWidth={2} />\n                    </motion.g>\n                  ))}\n                </>\n              ) : null}\n            </svg>\n            <motion.div\n              className=\"pointer-events-none absolute inset-0\"\n              initial={false}\n              animate={{ opacity: loading ? 0 : 1 }}\n              transition={{ duration: 0.15, ease: EASE_OUT }}\n            >\n              <XYTickLabels\n                ticks={domain.ticks}\n                position={y}\n                axis=\"y\"\n                format={formatTick}\n                reduce={reduce}\n                offset={AXIS}\n              />\n            </motion.div>\n          </>\n        ) : null}\n        <AnimatePresence>\n          {loading ? (\n            <motion.output\n              key=\"loading\"\n              className=\"pointer-events-none absolute flex items-center justify-center text-xs text-muted-foreground\"\n              style={{ left: AXIS, right: PAD.right, top: PAD.top, bottom: PAD.bottom }}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{\n                opacity: 0,\n                y: reduce ? 0 : 8,\n                transition: { duration: 0.18, ease: EASE_OUT },\n              }}\n              transition={{ duration: 0.2, ease: EASE_OUT }}\n            >\n              {loadingLabel ? (\n                <TextShimmer>{loadingLabel}</TextShimmer>\n              ) : (\n                <span className=\"sr-only\">Loading</span>\n              )}\n            </motion.output>\n          ) : null}\n        </AnimatePresence>\n        <div\n          ref={setOverlay}\n          className=\"absolute rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-ring\"\n          style={{ left: AXIS, right: PAD.right, top: PAD.top, bottom: PAD.bottom }}\n        >\n          {loading ? null : (\n            <XYInspector\n              count={n}\n              activeIndex={activeIndex}\n              onActive={(i) => setActive(categories[i])}\n              label={`${label}: inspect values`}\n              valueText={(i) =>\n                [\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                ].join(\". \")\n              }\n              mode=\"point\"\n              open={open}\n              setOpen={setOpen}\n              onTouchChange={setTouching}\n              tooltipId={tooltipId}\n              canHover={canHover}\n              className=\"inset-0 size-full\"\n            />\n          )}\n        </div>\n      </div>\n      <motion.div\n        aria-hidden=\"true\"\n        className=\"relative mt-2 h-5\"\n        initial={false}\n        animate={{ opacity: loading ? 0 : 1 }}\n        transition={{ duration: 0.15, ease: EASE_OUT }}\n      >\n        {width > 0\n          ? categories.map((category, i) =>\n              (n - 1 - i) % stride === 0 ? (\n                <span\n                  key={category}\n                  className=\"absolute top-0.5 -translate-x-1/2 text-[11px] whitespace-nowrap text-muted-foreground transition-opacity duration-150\"\n                  style={{\n                    left: x(i),\n                    opacity: inspecting && Math.abs(x(i) - crossX) < PILL_CLEAR ? 0 : 1,\n                  }}\n                >\n                  {category}\n                </span>\n              ) : null,\n            )\n          : null}\n        <AnimatePresence>\n          {inspecting && active !== null ? (\n            <motion.div\n              key=\"pill\"\n              className=\"pointer-events-none absolute top-0 left-0\"\n              initial={{ x: x(active), opacity: 0 }}\n              animate={{ x: x(active), opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }}\n              transition={{ x: spring, opacity: { duration: 0.12 } }}\n            >\n              <span className=\"block -translate-x-1/2 overflow-hidden rounded-full bg-foreground px-2 py-0.5 text-[11px] leading-4 font-medium whitespace-nowrap text-background\">\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.span\n                    key={categories[active]}\n                    className=\"block\"\n                    initial={{ y: reduce ? 0 : direction * 10, opacity: 0 }}\n                    animate={{ y: 0, opacity: 1 }}\n                    exit={{ y: reduce ? 0 : direction * -10, opacity: 0 }}\n                    transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n                  >\n                    {categories[active]}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Placeholder({\n  ids,\n  left,\n  width,\n  top,\n  height,\n  strokeWidth,\n  reduce,\n}: {\n  ids: { pulse: string; band: string; mask: string };\n  left: number;\n  width: number;\n  top: number;\n  height: number;\n  strokeWidth: number;\n  reduce: boolean;\n}) {\n  // A calm, slightly rising wave: reads as \"a line goes here\", not as data.\n  const d = linePath(\n    Array.from({ length: PLACEHOLDER_SAMPLES }, (_, i) => {\n      const t = i / (PLACEHOLDER_SAMPLES - 1);\n      return {\n        x: left + t * width,\n        y: top + height * (0.62 - t * 0.18 + Math.sin(t * Math.PI * 3) * 0.07),\n      };\n    }),\n    \"monotone\",\n  );\n  const band = Math.max(80, width * 0.28);\n  return (\n    <motion.g\n      className=\"text-muted-foreground\"\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}\n      transition={{ duration: 0.2, ease: EASE_OUT }}\n    >\n      <defs>\n        <linearGradient id={ids.band} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">\n          <stop offset={0} stopColor=\"white\" stopOpacity={0} />\n          <stop offset={0.5} stopColor=\"white\" />\n          <stop offset={1} stopColor=\"white\" stopOpacity={0} />\n        </linearGradient>\n        <mask id={ids.pulse} maskUnits=\"userSpaceOnUse\" x={left - band} y={0} width={width + band * 2} height={top * 2 + height}>\n          <motion.rect\n            x={0}\n            y={0}\n            width={band}\n            height={top * 2 + height}\n            fill={`url(#${ids.band})`}\n            initial={{ x: left - band }}\n            animate={{ x: left + width }}\n            transition={{ duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity, repeatDelay: 0.35 }}\n          />\n        </mask>\n      </defs>\n      {/* Shares the plot's edge fade, so the handoff to real lines keeps its silhouette. */}\n      <g mask={`url(#${ids.mask})`}>\n        <motion.path\n          d={d}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={strokeWidth}\n          strokeLinecap=\"round\"\n          initial={{ strokeOpacity: 0.22 }}\n          animate={reduce ? { strokeOpacity: [0.22, 0.45, 0.22] } : { strokeOpacity: 0.22 }}\n          transition={reduce ? { duration: 1.8, ease: EASE_IN_OUT, repeat: Infinity } : undefined}\n        />\n        {reduce ? null : (\n          <path\n            d={d}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeOpacity={0.75}\n            strokeWidth={strokeWidth}\n            strokeLinecap=\"round\"\n            mask={`url(#${ids.pulse})`}\n          />\n        )}\n      </g>\n    </motion.g>\n  );\n}\n\nfunction Series({\n  row,\n  points,\n  baseline,\n  curve,\n  area,\n  fillOpacity,\n  strokeWidth,\n  hidden,\n  dimmed,\n}: {\n  row: XYRow;\n  points: (XYAnimatedPoint | null)[];\n  baseline: MotionValue<number>;\n  curve: XYCurve;\n  area: boolean;\n  fillOpacity: number;\n  strokeWidth: number;\n  hidden: boolean;\n  dimmed: boolean;\n}) {\n  const gradientId = useId();\n  const values = [baseline, ...points.flatMap((point) => (point ? [point.x, point.y] : []))];\n  const read = (coordinates: number[]) => {\n    let cursor = 1;\n    return points.map((point) =>\n      point ? { x: coordinates[cursor++], y: coordinates[cursor++] } : null,\n    );\n  };\n  // SVG geometry has to reshape with the data; no transform of the whole path can express it.\n  const line = useTransform(values, (coordinates: number[]) => linePath(read(coordinates), curve));\n  const fill = useTransform(values, (coordinates: number[]) =>\n    areaPath(read(coordinates), coordinates[0], curve),\n  );\n  return (\n    <motion.g\n      initial={false}\n      animate={{ opacity: hidden ? 0 : dimmed ? 0.2 : 1 }}\n      transition={{ duration: 0.18, ease: EASE_OUT }}\n    >\n      {area ? (\n        <>\n          <defs>\n            <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n              <stop offset=\"0%\" stopColor={row.color} stopOpacity={fillOpacity} />\n              <stop offset=\"100%\" stopColor={row.color} stopOpacity={0} />\n            </linearGradient>\n          </defs>\n          <motion.path d={fill} fill={`url(#${gradientId})`} />\n        </>\n      ) : null}\n      <motion.path\n        d={line}\n        fill=\"none\"\n        stroke={row.color}\n        strokeWidth={strokeWidth}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </motion.g>\n  );\n}\n"},{"path":"components/charts/line-chart/tooltip.tsx","type":"registry:component","target":"@components/charts/line-chart/tooltip.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { type CSSProperties, type ReactNode, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { cn } from \"@/lib/utils\";\nimport { XYTooltipContent, type XYTooltipRow } from \"../xy/tooltip-content\";\nimport { useLineChart } from \"./context\";\n\nexport interface LineChartTooltipData {\n  category: string;\n  index: number;\n  rows: XYTooltipRow[];\n}\n\nexport interface LineChartTooltipProps {\n  children?: ReactNode | ((data: LineChartTooltipData) => ReactNode);\n  className?: string;\n  /**\n   * `auto` sits right of the point, flips left near the right edge, and on\n   * plots too narrow for either moves above or below the point instead.\n   */\n  side?: \"auto\" | \"left\" | \"right\";\n}\n\nconst GAP = 16;\n/** The panel may extend this far into the axis gutters beside the plot, in px. */\nconst BLEED = { left: 40, right: 12 } as const;\n// Softer than the crosshair's layout spring on purpose: the panel trails a\n// beat behind the point it describes instead of moving rigidly with it.\nconst FOLLOW = { type: \"spring\", stiffness: 260, damping: 28, mass: 0.7 } as const;\n\n/**\n * A panel inside the plot that springs after the inspected point, beside its\n * highest visible value, so it never covers the dots it describes.\n */\nexport function LineChartTooltip({ children, className, side = \"auto\" }: LineChartTooltipProps) {\n  const {\n    categories,\n    visibleRows,\n    domain,\n    activeIndex,\n    open,\n    loading,\n    overlay,\n    geometry,\n    tooltipId,\n    formatValue,\n    reduce,\n    touching,\n  } = useLineChart();\n  const panelRef = useRef<HTMLDivElement>(null);\n  // Kept between openings: the panel's size barely changes from one point to the next.\n  const [size, setSize] = useState({ width: 224, height: 96 });\n  const category = loading ? undefined : categories[activeIndex];\n  const visible = open && category !== undefined;\n  useLayoutEffect(() => {\n    const node = panelRef.current;\n    if (!visible || !node) return;\n    const update = () =>\n      setSize((previous) =>\n        previous.width === node.offsetWidth && previous.height === node.offsetHeight\n          ? previous\n          : { width: node.offsetWidth, height: node.offsetHeight },\n      );\n    update();\n    const observer = new ResizeObserver(update);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [visible]);\n  if (!overlay) return null;\n\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 values = rows.flatMap((row) => (row.value === null ? [] : [row.value]));\n  const top = values.length ? Math.max(...values) : domain.max;\n  const n = categories.length;\n  const { width: W, height: H } = geometry;\n  const px = (n === 1 ? 0.5 : Math.max(activeIndex, 0) / (n - 1)) * W;\n  const py = ((domain.max - top) / (domain.max - domain.min || 1)) * H;\n  const minX = -BLEED.left;\n  const maxX = W + BLEED.right;\n  const fitsRight = px + GAP + size.width <= maxX;\n  const fitsLeft = px - GAP - size.width >= minX;\n  const placement =\n    side === \"right\" || (side === \"auto\" && fitsRight)\n      ? \"right\"\n      : side === \"left\" || (side === \"auto\" && fitsLeft)\n        ? \"left\"\n        : py - GAP - size.height >= 0\n          ? \"above\"\n          : \"below\";\n  const clampX = (value: number) => Math.min(Math.max(value, minX), maxX - size.width);\n  const clampY = (value: number) => Math.min(Math.max(value, -8), Math.max(-8, H + 8 - size.height));\n  const position =\n    placement === \"right\"\n      ? { x: px + GAP, y: clampY(py - size.height / 2) }\n      : placement === \"left\"\n        ? { x: px - GAP - size.width, y: clampY(py - size.height / 2) }\n        : {\n            x: clampX(px - size.width / 2),\n            y: placement === \"above\" ? py - GAP - size.height : py + GAP,\n          };\n  const origin = {\n    right: \"left center\",\n    left: \"right center\",\n    above: \"bottom center\",\n    below: \"top center\",\n  }[placement];\n\n  return createPortal(\n    <AnimatePresence>\n      {visible ? (\n        <motion.div\n          key=\"tooltip\"\n          ref={panelRef}\n          className=\"pointer-events-none absolute top-0 left-0 z-10 w-max\"\n          initial={position}\n          animate={position}\n          // The trailing follow suits a mouse; under a finger it reads as lag.\n          transition={reduce || touching ? { duration: 0 } : FOLLOW}\n        >\n          <TooltipSurface\n            id={tooltipId}\n            style={{ \"--tooltip-origin\": origin } as CSSProperties}\n            className={cn(\"max-w-[min(20rem,calc(100vw-2rem))] py-2\", className)}\n          >\n            {typeof children === \"function\"\n              ? children({ category, index: activeIndex, rows })\n              : (children ?? (\n                  <XYTooltipContent title={category} rows={rows} formatValue={formatValue} />\n                ))}\n          </TooltipSurface>\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    overlay,\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":"components/charts/xy/path.ts","type":"registry:component","target":"@components/charts/xy/path.ts","content":"export type XYCurve = \"monotone\" | \"linear\" | \"step\";\nexport type XYPathPoint = { x: number; y: number } | null;\n\nconst f = (value: number) => Math.round(value * 100) / 100;\n\n/** Contiguous runs. A gap in the data is a gap in the line, never an interpolation. */\nfunction runs(points: readonly XYPathPoint[]) {\n  const out: { x: number; y: number }[][] = [];\n  let run: { x: number; y: number }[] = [];\n  for (const point of points) {\n    if (point) run.push(point);\n    else if (run.length) {\n      out.push(run);\n      run = [];\n    }\n  }\n  if (run.length) out.push(run);\n  return out;\n}\n\n// Monotone cubic interpolation (Fritsch–Carlson): smooth, but it never\n// overshoots a local peak or dips below a local trough the data doesn't have.\nfunction tangents(run: { x: number; y: number }[]) {\n  const n = run.length;\n  const t = new Array<number>(n).fill(0);\n  for (let i = 1; i < n - 1; i++) {\n    const h0 = run[i].x - run[i - 1].x;\n    const h1 = run[i + 1].x - run[i].x;\n    const s0 = h0 ? (run[i].y - run[i - 1].y) / h0 : 0;\n    const s1 = h1 ? (run[i + 1].y - run[i].y) / h1 : 0;\n    const p = h0 + h1 ? (s0 * h1 + s1 * h0) / (h0 + h1) : 0;\n    t[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p));\n  }\n  const end = (a: number, b: number, neighbour: number) => {\n    const h = run[b].x - run[a].x;\n    return h ? (3 * (run[b].y - run[a].y)) / h / 2 - neighbour / 2 : neighbour;\n  };\n  t[0] = end(0, 1, t[1]);\n  t[n - 1] = end(n - 2, n - 1, t[n - 2]);\n  return t;\n}\n\nfunction segment(run: { x: number; y: number }[], curve: XYCurve) {\n  const [first, ...rest] = run;\n  let d = `M${f(first.x)},${f(first.y)}`;\n  // A lone sample draws as a dot with a round line cap.\n  if (!rest.length) return `${d}h0`;\n  if (curve === \"linear\" || run.length === 2) {\n    for (const p of rest) d += `L${f(p.x)},${f(p.y)}`;\n    return d;\n  }\n  if (curve === \"step\") {\n    for (let i = 1; i < run.length; i++) {\n      const mid = (run[i - 1].x + run[i].x) / 2;\n      d += `H${f(mid)}V${f(run[i].y)}`;\n    }\n    return `${d}H${f(run[run.length - 1].x)}`;\n  }\n  const t = tangents(run);\n  for (let i = 1; i < run.length; i++) {\n    const a = run[i - 1];\n    const b = run[i];\n    const dx = (b.x - a.x) / 3;\n    d += `C${f(a.x + dx)},${f(a.y + dx * t[i - 1])},${f(b.x - dx)},${f(b.y - dx * t[i])},${f(b.x)},${f(b.y)}`;\n  }\n  return d;\n}\n\nexport function linePath(points: readonly XYPathPoint[], curve: XYCurve = \"monotone\") {\n  return runs(points)\n    .map((run) => segment(run, curve))\n    .join(\"\");\n}\n\n/** Close each run down to the baseline. Lone samples have no area to fill. */\nexport function areaPath(\n  points: readonly XYPathPoint[],\n  baseline: number,\n  curve: XYCurve = \"monotone\",\n) {\n  return runs(points)\n    .filter((run) => run.length > 1)\n    .map(\n      (run) =>\n        `${segment(run, curve)}L${f(run[run.length - 1].x)},${f(baseline)}L${f(run[0].x)},${f(baseline)}Z`,\n    )\n    .join(\"\");\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-points.ts","type":"registry:component","target":"@components/charts/xy/use-points.ts","content":"\"use client\";\n\nimport { animate, type MotionValue, motionValue } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\n\nexport type XYAnimatedPoint = { x: MotionValue<number>; y: MotionValue<number> };\nexport type XYPointTarget = { key: string; x: number; y: number };\n\n/**\n * One spring per coordinate, keyed by identity. Paths, dots and fills read the\n * same values, so a data change morphs every mark together. A resize jumps:\n * the data didn't change, so nothing should appear to move.\n */\nexport function useAnimatedPoints(targets: XYPointTarget[], reduce: boolean, width: number) {\n  const shape = targets.map((target) => target.key).join(\"\\n\");\n  const create = (previous?: Map<string, XYAnimatedPoint>) =>\n    new Map(\n      targets.map((target) => [\n        target.key,\n        previous?.get(target.key) ?? { x: motionValue(target.x), y: motionValue(target.y) },\n      ]),\n    );\n  const [stored, setStored] = useState(() => ({ shape, points: create() }));\n  let points = stored.points;\n  if (stored.shape !== shape) {\n    points = create(stored.points);\n    setStored({ shape, points });\n  }\n  const lastWidth = useRef(width);\n  // Hover and focus renders produce the same snapshot, so they never restart a spring.\n  const snapshot = JSON.stringify(targets);\n  useLayoutEffect(() => {\n    const resized = lastWidth.current !== width;\n    lastWidth.current = width;\n    const next: XYPointTarget[] = JSON.parse(snapshot);\n    const controls: ReturnType<typeof animate>[] = [];\n    for (const target of next) {\n      const point = points.get(target.key);\n      if (!point) continue;\n      for (const axis of [\"x\", \"y\"] as const) {\n        if (reduce || resized) point[axis].jump(target[axis]);\n        else controls.push(animate(point[axis], target[axis], SPRING_LAYOUT));\n      }\n    }\n    return () => {\n      for (const control of controls) control.stop();\n    };\n  }, [snapshot, reduce, points, width]);\n  return points;\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":"components/motion/text-shimmer.tsx","type":"registry:component","target":"@components/motion/text-shimmer.tsx","content":"import { cn } from \"@/lib/utils\";\nimport type { ElementType, ReactNode } from \"react\";\nimport {\n  TEXT_SHIMMER_CLASS_NAME,\n  TEXT_SHIMMER_KEYFRAMES,\n  textShimmerStyle,\n} from \"@/lib/text-shimmer\";\n\nexport interface TextShimmerProps {\n  children: ReactNode;\n  as?: ElementType;\n  duration?: number;\n  className?: string;\n}\n\nexport function TextShimmer({ children, as: Comp = \"span\", duration = 2.5, className }: TextShimmerProps) {\n  return (\n    <>\n      <style>\n        {TEXT_SHIMMER_KEYFRAMES}\n      </style>\n      <Comp\n        style={textShimmerStyle(duration)}\n        className={cn(\n          \"inline-block\",\n          TEXT_SHIMMER_CLASS_NAME,\n          className,\n        )}\n      >\n        {children}\n      </Comp>\n    </>\n  );\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-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/text-shimmer.ts","type":"registry:lib","target":"@lib/text-shimmer.ts","content":"import type { CSSProperties } from \"react\";\n\n// The reduced-motion rule travels with the component. app/globals.css calms CSS\n// animation globally, but the registry bundles imports only, so an installed\n// copy never receives that reset.\n//\n// `!important` because the sweep is an inline style, which outranks a plain rule\n// in a media query. It selects a marker class carried by TEXT_SHIMMER_CLASS_NAME\n// so it also reaches consumers that build their own span out of these exports.\nexport const TEXT_SHIMMER_KEYFRAMES =\n  \"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}\" +\n  \"@media (prefers-reduced-motion: reduce){.beui-text-shimmer{animation:none !important}}\";\n\nexport const TEXT_SHIMMER_CLASS_NAME =\n  \"beui-text-shimmer bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]\";\n\nexport function textShimmerStyle(duration: number): CSSProperties {\n  return {\n    animation: `beui-text-shimmer ${duration}s linear infinite`,\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"}]}