{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chart-bar","type":"registry:component","title":"Chart Bar Chart","description":"Grouped columns that grow from the baseline; hovering a category dims the rest and reads every series.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/chart/bar-chart.tsx","type":"registry:component","target":"@components/motion/chart/bar-chart.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/chart\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useRef, useState } from \"react\";\nimport {\n  type ChartDatum,\n  ChartLegend,\n  type ChartSeries,\n  ChartTable,\n  ChartTooltip,\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 BarChartProps {\n  data: ChartDatum[];\n  /** Key of the category label in each datum. */\n  index: string;\n  series: ChartSeries[];\n  /** \"vertical\" draws columns; \"horizontal\" draws rows of bars. */\n  layout?: \"vertical\" | \"horizontal\";\n  /** Stack series into one bar per category. */\n  stacked?: boolean;\n  /** Print each bar's value at its end. */\n  showValues?: boolean;\n  /** Corner radius of the bar's data end, in px. Default 6. */\n  radius?: number;\n  /** Plot height in px (vertical layout). Default 250. */\n  height?: number;\n  /** Show the value axis labels. Default false: gridlines carry the scale. */\n  valueAxis?: boolean;\n  /** Show the legend. Default: when there are two or more series. */\n  legend?: boolean;\n  indicator?: TooltipIndicator;\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 GAP = 4; // between bars of one group\nconst ROW = 36; // horizontal layout: px per category\nconst AXIS = 28;\n\ntype Rect = { x: number; y: number; w: number; h: number };\n\n/** Bar path rounded only at the data end, square against the baseline. */\nfunction barPath({ x, y, w, h }: Rect, r: number, end: \"top\" | \"bottom\" | \"right\" | \"left\") {\n  const rr = Math.max(0, Math.min(r, w / 2, h / 2));\n  if (end === \"top\")\n    return `M${x},${y + h}V${y + rr}Q${x},${y} ${x + rr},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + h}Z`;\n  if (end === \"bottom\")\n    return `M${x},${y}V${y + h - rr}Q${x},${y + h} ${x + rr},${y + h}H${x + w - rr}Q${x + w},${y + h} ${x + w},${y + h - rr}V${y}Z`;\n  if (end === \"right\")\n    return `M${x},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + h - rr}Q${x + w},${y + h} ${x + w - rr},${y + h}H${x}Z`;\n  return `M${x + w},${y}H${x + rr}Q${x},${y} ${x},${y + rr}V${y + h - rr}Q${x},${y + h} ${x + rr},${y + h}H${x + w}Z`;\n}\n\n/**\n * Compares magnitudes across categories as columns or horizontal bars,\n * grouped or stacked. Bars grow from the baseline when scrolled into view;\n * hovering or arrowing to a category dims the others and shows one tooltip\n * for every series.\n */\nexport function BarChart({\n  data,\n  index,\n  series,\n  layout = \"vertical\",\n  stacked = false,\n  showValues = false,\n  radius = 6,\n  height: heightProp = 250,\n  valueAxis = false,\n  legend,\n  indicator = \"dot\",\n  formatIndex = (v) => v,\n  formatValue = formatFull,\n  formatAxis = formatCompact,\n  title,\n  className,\n}: BarChartProps) {\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  const horizontal = layout === \"horizontal\";\n  const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;\n\n  // Value extent: stacked bars sum positives and negatives separately.\n  const extents = data.flatMap((d) => {\n    if (!stacked) return series.map((s) => value(d, s.key));\n    let pos = 0;\n    let neg = 0;\n    for (const s of series) {\n      const v = value(d, s.key);\n      if (v >= 0) pos += v;\n      else neg += v;\n    }\n    return [pos, neg];\n  });\n  const ticks = niceTicks(Math.min(0, ...extents), Math.max(0, ...extents));\n  const lo = ticks[0];\n  const hi = ticks[ticks.length - 1];\n\n  const labelGutter = horizontal\n    ? gutterFor(data.map((d) => formatIndex(String(d[index]))))\n    : 0;\n  const valueGutter = !horizontal && valueAxis ? gutterFor(ticks.map(formatAxis)) : 0;\n  // Room at the data end for printed values.\n  const endRoom = showValues ? (horizontal ? 44 : 20) : 0;\n  const height = horizontal ? data.length * ROW : heightProp;\n\n  // Category axis runs along x (vertical) or y (horizontal).\n  const catStart = horizontal ? 0 : valueGutter;\n  const catLength = horizontal ? height : Math.max(0, width - valueGutter);\n  const band = data.length ? catLength / data.length : 0;\n  const valStart = horizontal ? labelGutter : endRoom;\n  const valLength = horizontal\n    ? Math.max(0, width - labelGutter - endRoom)\n    : height - endRoom;\n  // Value → position along the value axis, measured from its origin.\n  const scale = (v: number) => ((v - lo) / (hi - lo)) * valLength;\n  const zero = scale(0);\n\n  const groups = stacked ? 1 : series.length;\n  const thickness = Math.max(\n    3,\n    Math.min(horizontal ? 24 : 56, (band * (horizontal ? 0.72 : 0.62) - GAP * (groups - 1)) / groups),\n  );\n  const groupLen = thickness * groups + GAP * (groups - 1);\n\n  const bars = data.map((d, i) => {\n    const offset = catStart + band * i + (band - groupLen) / 2;\n    let pos = 0;\n    let neg = 0;\n    return series.map((s, si) => {\n      const v = value(d, s.key);\n      const from = stacked ? (v >= 0 ? pos : neg) : 0;\n      const to = from + v;\n      if (stacked) {\n        if (v >= 0) pos = to;\n        else neg = to;\n      }\n      const a = scale(Math.min(from, to));\n      const b = scale(Math.max(from, to));\n      const lane = offset + (stacked ? 0 : si * (thickness + GAP));\n      const rect: Rect = horizontal\n        ? { x: valStart + a, y: lane, w: b - a, h: thickness }\n        : { x: lane, y: valStart + (valLength - b), w: thickness, h: b - a };\n      // Only the outermost segment of a stack gets the rounded end.\n      const outer =\n        !stacked ||\n        series.slice(si + 1).every((t) => Math.sign(value(d, t.key)) !== Math.sign(v) || value(d, t.key) === 0);\n      const end = horizontal ? (v >= 0 ? \"right\" : \"left\") : v >= 0 ? \"top\" : \"bottom\";\n      return { s, si, v, rect, outer, end } as const;\n    });\n  });\n\n  const totalH = horizontal ? height : height + AXIS;\n  const stride = horizontal ? 1 : labelStride(data.length, catLength, 48);\n\n  // Tooltip anchors at the category's data end.\n  const anchor = (i: number) => {\n    const rs = bars[i].map((b) => b.rect);\n    if (horizontal) {\n      return {\n        x: Math.max(...rs.map((r) => r.x + r.w)),\n        y: catStart + band * i + band / 2,\n      };\n    }\n    return {\n      x: catStart + band * i + band / 2,\n      y: Math.min(...rs.map((r) => r.y)) + 12,\n    };\n  };\n  const tip = active === null ? { x: 0, y: 0 } : anchor(active);\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={totalH}\n            role=\"group\"\n            aria-label={`${title}. Use arrow keys to read each category.`}\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            onPointerLeave={() => setActive(null)}\n            onBlur={() => setActive(null)}\n            className=\"block overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {/* Gridlines run across the value axis only. */}\n            {ticks.map((t) => {\n              const p = valStart + (horizontal ? scale(t) : valLength - scale(t));\n              return (\n                <g key={t}>\n                  {horizontal ? (\n                    <line x1={p} x2={p} y1={0} y2={height} stroke=\"var(--border-strong)\" shapeRendering=\"crispEdges\" />\n                  ) : (\n                    <line x1={valueGutter} x2={width} y1={p} y2={p} stroke=\"var(--border-strong)\" shapeRendering=\"crispEdges\" />\n                  )}\n                  {valueAxis && !horizontal && (\n                    <text x={valueGutter - 8} y={p} dy=\"0.32em\" textAnchor=\"end\" className=\"fill-muted-foreground text-xs tabular-nums\">\n                      {formatAxis(t)}\n                    </text>\n                  )}\n                </g>\n              );\n            })}\n\n            {bars.map((group, i) => {\n              const dim = active !== null && active !== i;\n              const label = formatIndex(String(data[i][index]));\n              return (\n                // biome-ignore lint/suspicious/noArrayIndexKey: categories keep their order.\n                <g key={i}>\n                  {group.map(({ s, si, rect, outer, end }) => (\n                    <motion.path\n                      key={s.key}\n                      d={barPath(rect, outer ? radius : 0, end)}\n                      fill={seriesColor(s, si)}\n                      initial={false}\n                      animate={{\n                        [horizontal ? \"scaleX\" : \"scaleY\"]: shown ? 1 : 0,\n                        opacity: dim ? 0.3 : 1,\n                      }}\n                      transition={{\n                        scaleX: reduce ? { duration: 0 } : { ...GROW, delay: i * 0.04 },\n                        scaleY: reduce ? { duration: 0 } : { ...GROW, delay: i * 0.04 },\n                        opacity: { duration: 0.15 },\n                      }}\n                      style={{\n                        // Grow from the zero line, whichever side the bar sits on.\n                        transformBox: \"view-box\",\n                        transformOrigin: horizontal\n                          ? `${valStart + zero}px 0px`\n                          : `0px ${valStart + valLength - zero}px`,\n                      }}\n                    />\n                  ))}\n\n                  {showValues &&\n                    (stacked ? [group[group.length - 1]] : group).map(({ s, rect, v }) => {\n                      const shownValue = stacked\n                        ? group.reduce((sum, g) => sum + g.v, 0)\n                        : v;\n                      const end = stacked\n                        ? horizontal\n                          ? Math.max(...group.map((g) => g.rect.x + g.rect.w))\n                          : Math.min(...group.map((g) => g.rect.y))\n                        : horizontal\n                          ? rect.x + rect.w\n                          : rect.y;\n                      return (\n                        <motion.text\n                          key={`${s.key}-v`}\n                          x={horizontal ? end + 8 : rect.x + (stacked ? groupLen : rect.w) / 2}\n                          y={horizontal ? rect.y + (stacked ? groupLen : rect.h) / 2 : end - 8}\n                          dy={horizontal ? \"0.32em\" : undefined}\n                          textAnchor={horizontal ? \"start\" : \"middle\"}\n                          className=\"fill-foreground text-xs font-medium tabular-nums\"\n                          initial={false}\n                          animate={{ opacity: shown ? (dim ? 0.3 : 1) : 0 }}\n                          transition={reduce ? { duration: 0 } : { duration: 0.3, delay: shown && active === null ? 0.5 + i * 0.04 : 0 }}\n                        >\n                          {formatValue(shownValue)}\n                        </motion.text>\n                      );\n                    })}\n\n                  {horizontal ? (\n                    <text\n                      x={labelGutter - 10}\n                      y={catStart + band * i + band / 2}\n                      dy=\"0.32em\"\n                      textAnchor=\"end\"\n                      className=\"fill-muted-foreground text-xs\"\n                    >\n                      {label}\n                    </text>\n                  ) : (\n                    i % stride === 0 && (\n                      <text\n                        x={catStart + band * i + band / 2}\n                        y={height + 20}\n                        textAnchor=\"middle\"\n                        className=\"fill-muted-foreground text-xs\"\n                      >\n                        {label}\n                      </text>\n                    )\n                  )}\n\n                  {/* The whole category band is the hit target, not the bar. */}\n                  <rect\n                    x={horizontal ? 0 : catStart + band * i}\n                    y={horizontal ? catStart + band * i : 0}\n                    width={horizontal ? width : band}\n                    height={horizontal ? band : height}\n                    fill=\"transparent\"\n                    onPointerEnter={() => setActive(i)}\n                  />\n                </g>\n              );\n            })}\n          </svg>\n        )}\n        <ChartTooltip\n          open={active !== null}\n          x={tip.x}\n          y={tip.y}\n          width={width}\n          indicator={indicator}\n          label={active === null ? \"\" : formatIndex(String(data[active][index]))}\n          rows={\n            active === null\n              ? []\n              : series.map((s, si) => ({\n                  label: s.label,\n                  value: formatValue(value(data[active], s.key)),\n                  color: seriesColor(s, si),\n                }))\n          }\n        />\n      </div>\n      {(legend ?? series.length > 1) && (\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"}]}