{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sparkline","type":"registry:component","title":"Sparkline","description":"A compact inline trend for stat cards and tables, with a change badge and optional readout.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/sparkline.tsx","type":"registry:component","target":"@components/charts/sparkline.tsx","content":"\"use client\";\n// beui.dev/charts/sparkline\n\n/* A compact trend for tables, stat cards and inline text. New values morph the\n * line on the same springs as LineChart, and the end dot follows. With\n * `interactive`, hovering, dragging or arrow keys move a dot along the line and\n * the readout shows that sample. Reduced motion: values update in place. */\n\nimport { motion, useReducedMotion, useTransform } from \"motion/react\";\nimport { type ComponentProps, useId, useState } from \"react\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\nimport { XYInspector } from \"./xy/inspector\";\nimport { areaPath, linePath, type XYCurve } from \"./xy/path\";\nimport { useAnimatedPoints, type XYPointTarget } from \"./xy/use-points\";\nimport { useElementWidth } from \"./xy/use-width\";\n\nexport interface SparklineProps extends Omit<ComponentProps<\"div\">, \"children\"> {\n  /** Samples in display order. Missing or non-finite values leave a gap. */\n  data: readonly (number | null)[];\n  /** Optional sample labels, such as dates, used by the interactive readout. */\n  labels?: readonly string[];\n  /** Accessible name, such as \"Revenue, last 30 days\". */\n  label: string;\n  variant?: \"line\" | \"area\";\n  curve?: XYCurve;\n  /** Any CSS color. Defaults to the current text color. */\n  color?: string;\n  /** Height in px. Width follows the container. */\n  height?: number;\n  strokeWidth?: number;\n  /** Show the change from the first to the last sample as a badge. */\n  showDelta?: boolean;\n  /** Color a fall green and a rise red, for metrics where lower is better, such as error rate. */\n  invertDelta?: boolean;\n  /** Hover, drag or arrow keys to read individual samples. Adds one tab stop. */\n  interactive?: boolean;\n  formatValue?: (value: number) => string;\n}\n\nconst number = new Intl.NumberFormat(\"en\", { maximumFractionDigits: 2 });\nconst PAD = 3;\n\nexport function Sparkline({\n  data,\n  labels,\n  label,\n  variant = \"line\",\n  curve = \"monotone\",\n  color = \"currentColor\",\n  height = 32,\n  strokeWidth = 1.5,\n  showDelta = false,\n  invertDelta = false,\n  interactive = false,\n  formatValue = (value) => number.format(value),\n  className,\n  ...props\n}: SparklineProps) {\n  const reduce = useReducedMotion() ?? false;\n  const canHover = useHoverCapable();\n  const gradientId = useId();\n  const tooltipId = useId();\n  const { ref, width } = useElementWidth<HTMLDivElement>();\n  const [active, setActive] = useState<number | null>(null);\n  const [open, setOpen] = useState(false);\n  // Under a finger the readout dot tracks the touch directly instead of gliding.\n  const [touching, setTouching] = useState(false);\n  const values = data.map((value) =>\n    typeof value === \"number\" && Number.isFinite(value) ? value : null,\n  );\n  const present = values.filter((value): value is number => value !== null);\n  // Fit the data tightly: at this size a zero baseline or rounded bounds would\n  // flatten a metric that sits far from zero. The area fills to the bottom edge.\n  const lo = present.length ? Math.min(...present) : 0;\n  const hi = present.length ? Math.max(...present) : 1;\n  const range = hi - lo || 1;\n  const n = values.length;\n  const x = (i: number) => PAD + (n === 1 ? (width - PAD * 2) / 2 : (i / (n - 1)) * (width - PAD * 2));\n  const y = (value: number) =>\n    hi === lo ? height / 2 : PAD + ((hi - value) / range) * (height - PAD * 2);\n\n  const targets: XYPointTarget[] = [];\n  if (width > 0) {\n    targets.push({ key: \"baseline\", x: 0, y: height });\n    values.forEach((value, i) => {\n      if (value !== null) targets.push({ key: String(i), x: x(i), y: y(value) });\n    });\n  }\n  const points = useAnimatedPoints(targets, reduce, width);\n  // Indexed by position: a live sparkline shifts every sample left as it ticks.\n  const ordered = values.map((value, i) => (value === null ? null : (points.get(String(i)) ?? null)));\n  const baseline = points.get(\"baseline\");\n  const motionValues = [\n    ...(baseline ? [baseline.y] : []),\n    ...ordered.flatMap((point) => (point ? [point.x, point.y] : [])),\n  ];\n  const read = (coordinates: number[]) => {\n    let cursor = 1;\n    return ordered.map((point) =>\n      point ? { x: coordinates[cursor++], y: coordinates[cursor++] } : null,\n    );\n  };\n  const line = useTransform(motionValues, (c: number[]) => (baseline ? linePath(read(c), curve) : \"\"));\n  const fill = useTransform(motionValues, (c: number[]) =>\n    baseline ? areaPath(read(c), c[0], curve) : \"\",\n  );\n\n  const lastIndex = values.findLastIndex((value) => value !== null);\n  const firstIndex = values.findIndex((value) => value !== null);\n  const last = lastIndex >= 0 ? (values[lastIndex] as number) : null;\n  const first = firstIndex >= 0 ? (values[firstIndex] as number) : null;\n  const delta = first !== null && last !== null && first !== 0 ? ((last - first) / Math.abs(first)) * 100 : null;\n  const trend = delta === null || Math.abs(delta) < 0.05 ? \"flat\" : delta > 0 ? \"up\" : \"down\";\n  const tone = trend === \"flat\" ? \"flat\" : (trend === \"up\") !== invertDelta ? \"good\" : \"bad\";\n  const inspecting = interactive && open && active !== null && values[active] !== null;\n  const dotIndex = inspecting ? (active as number) : lastIndex;\n  const dotValue = dotIndex >= 0 ? values[dotIndex] : null;\n  const name = (i: number) => labels?.[i] ?? `Sample ${i + 1}`;\n  const summary = [\n    label,\n    first !== null && last !== null ? `from ${formatValue(first)} to ${formatValue(last)}` : \"no data\",\n    delta !== null && trend !== \"flat\" ? `${trend} ${Math.abs(delta).toFixed(1)}%` : null,\n  ]\n    .filter(Boolean)\n    .join(\", \");\n\n  return (\n    <div\n      {...props}\n      data-slot=\"sparkline\"\n      className={cn(\"inline-flex w-full min-w-0 items-center gap-2\", className)}\n    >\n      <div ref={ref} className=\"relative min-w-0 flex-1\" style={{ height, color }}>\n        <svg\n          role=\"img\"\n          aria-label={summary}\n          width={width || undefined}\n          height={height}\n          className=\"absolute inset-0 overflow-visible\"\n        >\n          {variant === \"area\" ? (\n            <>\n              <defs>\n                <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                  <stop offset=\"0%\" stopColor=\"currentColor\" stopOpacity={0.3} />\n                  <stop offset=\"100%\" stopColor=\"currentColor\" 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=\"currentColor\"\n            strokeWidth={strokeWidth}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n          {width > 0 && dotValue !== null && dotValue !== undefined ? (\n            <motion.circle\n              r={inspecting ? 3.5 : 2.5}\n              cx={0}\n              cy={0}\n              fill=\"currentColor\"\n              stroke=\"var(--background)\"\n              strokeWidth={inspecting ? 2 : 0}\n              initial={false}\n              animate={{ x: x(dotIndex), y: y(dotValue) }}\n              transition={reduce || touching ? { duration: 0 } : SPRING_LAYOUT}\n            />\n          ) : null}\n        </svg>\n        {interactive && present.length ? (\n          <div className=\"absolute inset-0 rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring\">\n            <XYInspector\n              count={n}\n              activeIndex={active ?? -1}\n              onActive={setActive}\n              label={`${label}: inspect samples`}\n              valueText={(i) => {\n                const value = values[i];\n                return `${name(i)}: ${value === null ? \"no data\" : formatValue(value)}`;\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          </div>\n        ) : null}\n      </div>\n      {interactive && present.length ? (\n        <span\n          id={tooltipId}\n          aria-hidden={!inspecting}\n          className=\"shrink-0 text-right text-xs leading-tight tabular-nums\"\n        >\n          <span className=\"block font-mono\">\n            {dotValue !== null && dotValue !== undefined ? (\n              <NumberTicker\n                value={dotValue}\n                format={() => formatValue(dotValue)}\n                startOnView={false}\n                duration={0.2}\n                stagger={0}\n                className=\"whitespace-pre\"\n              />\n            ) : (\n              \"No data\"\n            )}\n          </span>\n          <motion.span\n            className=\"block text-[10px] text-muted-foreground\"\n            initial={false}\n            animate={{ opacity: inspecting ? 1 : 0 }}\n            transition={{ duration: 0.12, ease: EASE_OUT }}\n          >\n            {inspecting ? name(active as number) : \" \"}\n          </motion.span>\n        </span>\n      ) : null}\n      {showDelta && delta !== null ? (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"shrink-0 rounded-md px-1.5 py-0.5 font-mono text-[11px] tabular-nums\",\n            tone === \"good\" && \"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\",\n            tone === \"bad\" && \"bg-rose-500/10 text-rose-600 dark:text-rose-400\",\n            tone === \"flat\" && \"bg-muted text-muted-foreground\",\n          )}\n        >\n          {trend === \"up\" ? \"+\" : trend === \"down\" ? \"−\" : \"\"}\n          {Math.abs(delta).toFixed(1)}%\n        </span>\n      ) : null}\n    </div>\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/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":"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/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":"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":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/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"}]}