{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chart-line","type":"registry:component","title":"Chart Line Chart","description":"Smooth monotone lines with dots, a snapping cursor and one tooltip for every series.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/chart/line-chart.tsx","type":"registry:component","target":"@components/motion/chart/line-chart.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/chart\n\nimport {\n  CartesianChart,\n  type CartesianChartProps,\n} from \"@/components/motion/chart/cartesian\";\n\nexport type LineChartProps = CartesianChartProps;\n\n/**\n * Trend over time: 2px lines (smooth, straight or stepped) revealed left to\n * right when scrolled into view, a cursor that snaps to the nearest point and\n * one tooltip listing every series. Arrow keys step through points.\n */\nexport function LineChart(props: LineChartProps) {\n  return <CartesianChart type=\"line\" {...props} />;\n}\n"},{"path":"components/motion/chart/cartesian.tsx","type":"registry:component","target":"@components/motion/chart/cartesian.tsx","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { type PointerEvent, useId, useRef, useState } from \"react\";\nimport {\n  type ChartCurve,\n  type ChartDatum,\n  ChartLegend,\n  type ChartSeries,\n  ChartTable,\n  ChartTooltip,\n  curvePath,\n  formatCompact,\n  formatFull,\n  GROW,\n  gutterFor,\n  labelStride,\n  niceTicks,\n  seriesColor,\n  stepIndex,\n  type TooltipIndicator,\n  useChartWidth,\n} from \"@/components/motion/chart/shared\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CartesianChartProps {\n  data: ChartDatum[];\n  /** Key of the x value in each datum (dates, months…). */\n  index: string;\n  series: ChartSeries[];\n  /** Plot height in px. Default 250. */\n  height?: number;\n  /** Default \"natural\" (smooth, never overshoots a point). */\n  curve?: ChartCurve;\n  /** Stack series on top of each other instead of overlapping them. */\n  stacked?: boolean;\n  /** Draw a dot on every point. */\n  dots?: boolean;\n  /** Print each point's value above it. Best for short series. */\n  showValues?: boolean;\n  /** Show the y axis labels. Default false: gridlines carry the scale. */\n  yAxis?: boolean;\n  /** Show the legend. Default: when there are two or more series. */\n  legend?: boolean;\n  indicator?: TooltipIndicator;\n  /** Formats x labels and the tooltip title. */\n  formatIndex?: (value: string) => string;\n  formatValue?: (value: number) => string;\n  formatAxis?: (value: number) => string;\n  /** Names the chart for assistive tech and the table view. */\n  title: string;\n  className?: string;\n}\n\nconst AXIS = 28; // x labels\nconst PAD_X = 12;\nconst PAD_TOP = 12;\n\n/**\n * Shared engine for line and area charts: monotone, linear or stepped curves,\n * optional stacking and gradient fills; lines draw in left to right and areas\n * rise from the baseline when scrolled into view (replayed when the range changes), a hairline cursor that\n * snaps to the nearest x, and one tooltip listing every series there.\n */\nexport function CartesianChart({\n  type,\n  fill = \"gradient\",\n  data,\n  index,\n  series,\n  height = 250,\n  curve = \"natural\",\n  stacked = false,\n  dots = false,\n  showValues = false,\n  yAxis = false,\n  legend,\n  indicator = \"dot\",\n  formatIndex = (v) => v,\n  formatValue = formatFull,\n  formatAxis = formatCompact,\n  title,\n  className,\n}: CartesianChartProps & {\n  type: \"line\" | \"area\";\n  fill?: \"gradient\" | \"solid\";\n}) {\n  const uid = useId().replace(/:/g, \"\");\n  const reduce = useReducedMotion();\n  const [measureRef, width] = useChartWidth();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.3 });\n  const [active, setActive] = useState<number | null>(null);\n  const shown = reduce || inView;\n\n  const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;\n\n  // Per-series [lower, upper] for every point; lower is 0 unless stacked.\n  const bands = series.map((_, si) =>\n    data.map((d) => {\n      const lower = stacked\n        ? series.slice(0, si).reduce((sum, s) => sum + value(d, s.key), 0)\n        : 0;\n      return [lower, lower + value(d, series[si].key)] as const;\n    }),\n  );\n  const tops = bands.flatMap((b) => b.map(([, up]) => up));\n  const ticks = niceTicks(Math.min(0, ...tops), Math.max(...tops, 0));\n  const lo = ticks[0];\n  const hi = ticks[ticks.length - 1];\n\n  const left = yAxis ? gutterFor(ticks.map(formatAxis)) : PAD_X;\n  const plotW = Math.max(0, width - left - PAD_X);\n  const step = data.length > 1 ? plotW / (data.length - 1) : 0;\n  const x = (i: number) => left + i * step;\n  const y = (v: number) => PAD_TOP + (1 - (v - lo) / (hi - lo)) * (height - PAD_TOP);\n  const stride = labelStride(data.length, plotW, 64);\n  const last = data.length - 1;\n  const showLegend = legend ?? series.length > 1;\n\n  const linePts = (si: number) =>\n    bands[si].map(([, up], i) => [x(i), y(up)] as [number, number]);\n  const areaPath = (si: number) => {\n    const top = curvePath(linePts(si), curve);\n    const bottom = curvePath(\n      bands[si].map(([low], i) => [x(i), y(low)] as [number, number]).reverse(),\n      curve,\n    ).replace(/^M/, \"L\");\n    return `${top}${bottom}Z`;\n  };\n\n  // Replays the reveal when the visible range changes (e.g. 7d → 90d).\n  const revealKey = `${data.length}-${String(data[0]?.[index] ?? \"\")}`;\n\n  const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const i = Math.round((e.clientX - box.left - left) / (step || 1));\n    setActive(Math.min(last, Math.max(0, i)));\n  };\n\n  const activeTop =\n    active === null\n      ? 0\n      : Math.min(...series.map((_, si) => y(bands[si][active][1])));\n\n  return (\n    <div ref={viewRef} className={cn(\"w-full\", className)}>\n      <div ref={measureRef} className=\"relative w-full\">\n        {width > 0 && data.length > 0 && (\n          // biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot\n          <svg\n            width={width}\n            height={height + AXIS}\n            role=\"group\"\n            aria-label={`${title}. Use arrow keys to read each point.`}\n            // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values\n            tabIndex={0}\n            onKeyDown={(e) => {\n              const next = stepIndex(e.key, active, data.length);\n              if (next === undefined) return;\n              e.preventDefault();\n              setActive(next);\n            }}\n            onPointerMove={onPointerMove}\n            onPointerLeave={() => setActive(null)}\n            onBlur={() => setActive(null)}\n            className=\"block touch-pan-y overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <defs>\n              {type === \"area\" &&\n                series.map((s, si) => (\n                  <linearGradient\n                    key={s.key}\n                    id={`${uid}-fill-${si}`}\n                    x1=\"0\"\n                    x2=\"0\"\n                    y1=\"0\"\n                    y2=\"1\"\n                  >\n                    <stop\n                      offset=\"5%\"\n                      stopColor={seriesColor(s, si)}\n                      stopOpacity={fill === \"gradient\" ? 0.6 : 0.4}\n                    />\n                    <stop\n                      offset=\"95%\"\n                      stopColor={seriesColor(s, si)}\n                      stopOpacity={fill === \"gradient\" ? 0.05 : 0.4}\n                    />\n                  </linearGradient>\n                ))}\n            </defs>\n\n            {ticks.map((t) => (\n              <g key={t}>\n                <line\n                  x1={yAxis ? left : 0}\n                  x2={width}\n                  y1={y(t)}\n                  y2={y(t)}\n                  stroke=\"var(--border-strong)\"\n                  shapeRendering=\"crispEdges\"\n                />\n                {yAxis && (\n                  <text\n                    x={left - 8}\n                    y={y(t)}\n                    dy=\"0.32em\"\n                    textAnchor=\"end\"\n                    className=\"fill-muted-foreground text-xs tabular-nums\"\n                  >\n                    {formatAxis(t)}\n                  </text>\n                )}\n              </g>\n            ))}\n\n            {data.map((d, i) =>\n              // Every stride-th label plus the last, minus a strided one that\n              // would crowd the last.\n              i === last || (i % stride === 0 && (last - i) * step >= 64) ? (\n                <text\n                  // biome-ignore lint/suspicious/noArrayIndexKey: x positions keep their order.\n                  key={i}\n                  x={x(i)}\n                  y={height + 20}\n                  textAnchor={i === 0 ? \"start\" : i === last ? \"end\" : \"middle\"}\n                  className=\"fill-muted-foreground text-xs\"\n                >\n                  {formatIndex(String(d[index]))}\n                </text>\n              ) : null,\n            )}\n\n            {active !== null && (\n              <line\n                x1={x(active)}\n                x2={x(active)}\n                y1={PAD_TOP}\n                y2={height}\n                stroke=\"var(--muted-foreground)\"\n                strokeOpacity={0.4}\n                shapeRendering=\"crispEdges\"\n              />\n            )}\n\n            {/* Remounted per range so the entrance replays when the data\n                window changes (e.g. 7 days → 3 months). */}\n            <g key={revealKey}>\n              {type === \"area\" &&\n                series.map((s, si) => (\n                  <motion.path\n                    key={s.key}\n                    d={areaPath(si)}\n                    fill={`url(#${uid}-fill-${si})`}\n                    initial={reduce ? false : { opacity: 0, scaleY: 0.4 }}\n                    animate={shown ? { opacity: 1, scaleY: 1 } : { opacity: 0, scaleY: 0.4 }}\n                    transition={reduce ? { duration: 0 } : { ...GROW, duration: 0.9, delay: 0.15 }}\n                    style={{ transformBox: \"view-box\", transformOrigin: `0px ${y(lo)}px` }}\n                  />\n                ))}\n              {series.map((s, si) => (\n                <motion.path\n                  key={s.key}\n                  d={curvePath(linePts(si), curve)}\n                  fill=\"none\"\n                  stroke={seriesColor(s, si)}\n                  strokeWidth={2}\n                  strokeLinejoin=\"round\"\n                  strokeLinecap=\"round\"\n                  initial={reduce ? false : { pathLength: 0 }}\n                  animate={{ pathLength: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { duration: 1.1, ease: GROW.ease, delay: si * 0.08 }}\n                />\n              ))}\n              {(dots || showValues) && (\n                <motion.g\n                  initial={reduce ? false : { opacity: 0 }}\n                  animate={{ opacity: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { duration: 0.3, delay: 0.9 }}\n                >\n                  {dots &&\n                    series.map((s, si) =>\n                      linePts(si).map(([cx, cy], i) => (\n                        <circle\n                          // biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.\n                          key={`${s.key}-${i}`}\n                          cx={cx}\n                          cy={cy}\n                          r={3.5}\n                          fill={seriesColor(s, si)}\n                          stroke=\"var(--card)\"\n                          strokeWidth={2}\n                        />\n                      )),\n                    )}\n                  {showValues &&\n                    series.map((s, si) =>\n                      linePts(si).map(([cx, cy], i) => (\n                        <text\n                          // biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.\n                          key={`${s.key}-v${i}`}\n                          x={cx}\n                          y={cy - 12}\n                          textAnchor=\"middle\"\n                          className=\"fill-foreground text-xs font-medium tabular-nums\"\n                        >\n                          {formatValue(value(data[i], s.key))}\n                        </text>\n                      )),\n                    )}\n                </motion.g>\n              )}\n            </g>\n\n            {active !== null &&\n              series.map((s, si) => (\n                <motion.circle\n                  key={s.key}\n                  initial={reduce ? false : { scale: 0.4 }}\n                  animate={{ scale: 1, cx: x(active), cy: y(bands[si][active][1]) }}\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : { type: \"spring\", stiffness: 520, damping: 40 }\n                  }\n                  r={5}\n                  fill={seriesColor(s, si)}\n                  stroke=\"var(--card)\"\n                  strokeWidth={2}\n                  style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                />\n              ))}\n          </svg>\n        )}\n        <ChartTooltip\n          open={active !== null}\n          x={active === null ? 0 : x(active)}\n          y={activeTop}\n          width={width}\n          indicator={indicator}\n          label={active === null ? \"\" : formatIndex(String(data[active][index]))}\n          rows={\n            active === null\n              ? []\n              : (() => {\n                  const rows = series.map((s, si) => ({\n                    label: s.label,\n                    value: formatValue(value(data[active], s.key)),\n                    color: seriesColor(s, si),\n                  }));\n                  // Stacked: read top-down, the way the bands sit.\n                  return stacked ? rows.reverse() : rows;\n                })()\n          }\n        />\n      </div>\n      {showLegend && (\n        <ChartLegend\n          items={series.map((s, si) => ({ label: s.label, color: seriesColor(s, si) }))}\n        />\n      )}\n      <ChartTable\n        caption={title}\n        head={[index, ...series.map((s) => s.label)]}\n        rows={data.map((d) => [\n          formatIndex(String(d[index])),\n          ...series.map((s) => formatValue(value(d, s.key))),\n        ])}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/chart/shared.tsx","type":"registry:component","target":"@components/motion/chart/shared.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChartSeries = {\n  /** Key of the value in each datum. */\n  key: string;\n  label: string;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport type ChartDatum = Record<string, string | number>;\n\n/** \"natural\" is a monotone curve: smooth, but never overshoots a data point. */\nexport type ChartCurve = \"natural\" | \"linear\" | \"step\";\n\nexport type TooltipIndicator = \"dot\" | \"line\" | \"dashed\";\n\n// Fixed categorical order, validated for colorblind and normal-vision\n// separation (light and dark steps live in the theme's --chart-* tokens).\n// Fallbacks are the light steps, for projects without the theme layer.\nconst SLOT_FALLBACKS = [\"#2a78d6\", \"#eb6834\", \"#1baf7a\", \"#eda100\", \"#e87ba4\"];\n\n/** Color for slot `index`; color follows the series, never its rank. */\nexport function seriesColor(series: { color?: string }, index: number) {\n  if (series.color) return series.color;\n  const slot = index % SLOT_FALLBACKS.length;\n  return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;\n}\n\n/**\n * Layout width of the element given the returned ref. ResizeObserver reports\n * the untransformed box, unlike getBoundingClientRect (react-use-measure), so a\n * chart inside a scaled container (thumbnails, zoomed stages) still fills it.\n */\nexport function useChartWidth<T extends Element = HTMLDivElement>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setWidth(entry.contentRect.width),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return [setEl, width] as const;\n}\n\n// A fixed locale keeps server and client output identical for hydration.\nconst FULL = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst COMPACT = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nexport const formatFull = (n: number) => FULL.format(n);\nexport const formatCompact = (n: number) => COMPACT.format(n);\n\n/** Round tick values covering [min, max], about `count` of them. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1;\n  const raw = (max - min) / count;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step =\n    [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  const start = Math.floor(min / step) * step;\n  const end = Math.ceil(max / step) * step;\n  const ticks: number[] = [];\n  for (let v = start; v <= end + step / 2; v += step) {\n    ticks.push(Number(v.toFixed(10)));\n  }\n  return ticks;\n}\n\n/** Keep every k-th label so neighbours stay at least `minGap` px apart. */\nexport function labelStride(count: number, width: number, minGap = 56) {\n  return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));\n}\n\n/** Approximate gutter for axis labels at 12px. */\nexport function gutterFor(labels: string[]) {\n  return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;\n}\n\ntype Point = [number, number];\n\n/** SVG path through `points` (sorted by x) with the given curve. */\nexport function curvePath(points: Point[], curve: ChartCurve) {\n  if (points.length === 0) return \"\";\n  const [x0, y0] = points[0];\n  if (points.length === 1) return `M${x0},${y0}`;\n  if (curve === \"step\") {\n    // Changes value halfway between points, like a stepped series.\n    let d = `M${x0},${y0}`;\n    for (let i = 1; i < points.length; i++) {\n      const [px] = points[i - 1];\n      const [x, y] = points[i];\n      const mid = (px + x) / 2;\n      d += `H${mid}V${y}H${x}`;\n    }\n    return d;\n  }\n  if (curve === \"linear\" || points.length < 3) {\n    return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x},${y}`).join(\"\");\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays\n  // between its neighbours and never invents a peak the data doesn't have.\n  const n = points.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);\n  }\n  const tangents = points.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    const a = slopes[i - 1];\n    const b = slopes[i];\n    return a * b <= 0 ? 0 : (a + b) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    const m = slopes[i];\n    if (m === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / m;\n    const b = tangents[i + 1] / m;\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * m;\n      tangents[i + 1] = t * b * m;\n    }\n  }\n  let d = `M${x0},${y0}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [xa, ya] = points[i];\n    const [xb, yb] = points[i + 1];\n    const h = (xb - xa) / 3;\n    d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;\n  }\n  return d;\n}\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport type TooltipRow = { label: string; value: string; color: string };\n\nfunction Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 rounded-[2px]\",\n        kind === \"dot\" && \"size-2.5\",\n        kind === \"line\" && \"w-1 self-stretch\",\n        kind === \"dashed\" && \"w-0 self-stretch border-[1.5px] border-dashed\",\n      )}\n      style={\n        kind === \"dashed\" ? { borderColor: color } : { background: color }\n      }\n    />\n  );\n}\n\n/**\n * Compact readout that springs between data positions: an optional label,\n * then one row per series with a colored indicator, the name and the value.\n */\nexport function ChartTooltip({\n  open,\n  x,\n  y,\n  width,\n  label,\n  rows,\n  indicator = \"dot\",\n}: {\n  open: boolean;\n  /** Anchor in px, relative to the chart's positioned wrapper. */\n  x: number;\n  y: number;\n  /** Wrapper width, to flip the tooltip before it overflows the edge. */\n  width: number;\n  label?: string;\n  rows: TooltipRow[];\n  indicator?: TooltipIndicator;\n}) {\n  const reduce = useReducedMotion();\n  const flip = x > width / 2;\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          aria-hidden\n          initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, x, y, scale: 1 }}\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12 },\n                  scale: { duration: 0.15, ease: EASE_OUT },\n                  // Tracks the pointer between data positions; firm enough to\n                  // never trail behind a quick sweep.\n                  default: { type: \"spring\", stiffness: 520, damping: 42 },\n                }\n          }\n          className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        >\n          <div\n            className={cn(\n              \"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n              flip ? \"-translate-x-[calc(100%+12px)]\" : \"translate-x-3\",\n            )}\n          >\n            {label ? (\n              <p className=\"font-medium text-foreground\">{label}</p>\n            ) : null}\n            {rows.map((row) => (\n              <div key={row.label} className=\"flex items-stretch gap-2\">\n                <Indicator kind={indicator} color={row.color} />\n                <div className=\"flex flex-1 items-center justify-between gap-4 leading-none\">\n                  <span className=\"text-muted-foreground\">{row.label}</span>\n                  <span className=\"font-mono font-medium tabular-nums text-foreground\">\n                    {row.value}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// ── legend & table ─────────────────────────────────────────────────────────\n\nexport function ChartLegend({\n  items,\n  className,\n}: {\n  items: { label: string; color: string }[];\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"size-2 shrink-0 rounded-[2px]\"\n            style={{ background: item.color }}\n          />\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** The table view: every value reachable without hovering or seeing color. */\nexport function ChartTable({\n  caption,\n  head,\n  rows,\n}: {\n  caption: string;\n  head: string[];\n  rows: ReactNode[][];\n}) {\n  // The wrapper is what hides it: a <caption> escapes a visually hidden\n  // table's clip and would otherwise paint over the chart.\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{caption}</caption>\n        <thead>\n          <tr>\n            {head.map((h) => (\n              <th key={h} scope=\"col\">\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row, r) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.\n            <tr key={r}>\n              {row.map((cell, c) =>\n                c === 0 ? (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <th key={c} scope=\"row\">\n                    {cell}\n                  </th>\n                ) : (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <td key={c}>{cell}</td>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/** Shared entrance timing: marks grow once the chart scrolls into view. */\nexport const GROW = { duration: 0.7, ease: EASE_OUT } as const;\n\n/** Arrow-key stepping shared by every chart that reads positions. */\nexport function stepIndex(\n  key: string,\n  current: number | null,\n  count: number,\n  wrap = false,\n) {\n  const forward = key === \"ArrowRight\" || key === \"ArrowDown\";\n  const back = key === \"ArrowLeft\" || key === \"ArrowUp\";\n  if (!forward && !back) return undefined;\n  if (current === null) return forward ? 0 : count - 1;\n  const next = current + (forward ? 1 : -1);\n  if (wrap) return (next + count) % count;\n  return Math.min(count - 1, Math.max(0, next));\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/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"}]}