{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chart-heatmap","type":"registry:component","title":"Chart Heatmap Chart","description":"GitHub-style contribution calendar; cells fade in week by week and arrow keys read each day.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/chart/heatmap-chart.tsx","type":"registry:component","target":"@components/motion/chart/heatmap-chart.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/chart\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  ChartTable,\n  ChartTooltip,\n  formatFull,\n  GROW,\n  seriesColor,\n  useChartWidth,\n} from \"@/components/motion/chart/shared\";\nimport { cn } from \"@/lib/utils\";\n\nexport type HeatmapDatum = {\n  /** Calendar day, `YYYY-MM-DD` (read as UTC). */\n  date: string;\n  value: number;\n};\n\nexport interface HeatmapChartProps {\n  /** One entry per day; missing days count as zero, repeated days are summed. */\n  data: HeatmapDatum[];\n  /** Columns to show, ending on the latest date in `data`. Default 53 (a year). */\n  weeks?: number;\n  /** Intensity steps including the empty step. Default 5. */\n  levels?: number;\n  /** Any CSS color for the strongest step. Default `--chart-1`. */\n  color?: string;\n  /** Tooltip value text. Default \"N contributions\". */\n  formatValue?: (value: number) => string;\n  /** Tooltip date text from a `YYYY-MM-DD` string. Default \"Oct 3\". */\n  formatDate?: (date: string) => string;\n  /** Names the chart for assistive tech and the table view. */\n  title: string;\n  className?: string;\n}\n\nconst DAY_MS = 86_400_000;\nconst LEFT = 30; // weekday labels\nconst TOP = 20; // month labels\n// Cell pitch bounds: below MIN the grid scrolls; above MAX it stops growing.\nconst MIN_PITCH = 8;\nconst MAX_PITCH = 18;\nconst WEEKDAYS = [\n  [1, \"Mon\"],\n  [3, \"Wed\"],\n  [5, \"Fri\"],\n] as const;\n\n// Fixed locale + UTC keep server and client output identical for hydration.\nconst MONTH = new Intl.DateTimeFormat(\"en-US\", { month: \"short\", timeZone: \"UTC\" });\nconst SHORT = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"short\",\n  day: \"numeric\",\n  timeZone: \"UTC\",\n});\nconst LONG = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"long\",\n  day: \"numeric\",\n  year: \"numeric\",\n  timeZone: \"UTC\",\n});\n\n/** Days since the epoch, from `YYYY-MM-DD`, in UTC. */\nfunction toDay(iso: string) {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  return Math.floor(Date.UTC(y, (m || 1) - 1, d || 1) / DAY_MS);\n}\nconst toIso = (day: number) => new Date(day * DAY_MS).toISOString().slice(0, 10);\n/** 0 = Sunday. 1970-01-01 was a Thursday. */\nconst weekday = (day: number) => (((day + 4) % 7) + 7) % 7;\n\nconst defaultFormatValue = (v: number) =>\n  `${formatFull(v)} ${v === 1 ? \"contribution\" : \"contributions\"}`;\nconst defaultFormatDate = (iso: string) => SHORT.format(new Date(`${iso}T00:00:00Z`));\n\n/**\n * Contribution calendar: one rounded cell per day, a column per week, shaded\n * in `levels` steps of one color. Cells fade and scale in column by column when\n * scrolled into view; hovering or arrowing (up/down by day, left/right by\n * week) reads a day. Sizes itself to the available width and scrolls\n * horizontally, newest week in view, when it can't fit.\n */\nexport function HeatmapChart({\n  data,\n  weeks = 53,\n  levels = 5,\n  color = seriesColor({}, 0),\n  formatValue = defaultFormatValue,\n  formatDate = defaultFormatDate,\n  title,\n  className,\n}: HeatmapChartProps) {\n  const reduce = useReducedMotion();\n  const [measureRef, width] = useChartWidth();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.3 });\n  const [active, setActive] = useState<number | null>(null);\n  const [scrollLeft, setScrollLeft] = useState(0);\n  const shown = reduce || inView;\n\n  const steps = Math.max(2, Math.round(levels));\n  const cols = Math.max(1, Math.round(weeks));\n\n  const values = new Map<number, number>();\n  let end = Number.NEGATIVE_INFINITY;\n  for (const d of data) {\n    const day = toDay(d.date);\n    if (Number.isNaN(day)) continue;\n    values.set(day, (values.get(day) ?? 0) + (Number(d.value) || 0));\n    end = Math.max(end, day);\n  }\n  const hasData = Number.isFinite(end);\n  const first = hasData ? end - weekday(end) - (cols - 1) * 7 : 0;\n  const count = hasData ? end - first + 1 : 0;\n  const cells = Array.from({ length: count }, (_, i) => {\n    const day = first + i;\n    return { day, iso: toIso(day), value: values.get(day) ?? 0 };\n  });\n\n  const max = Math.max(0, ...cells.map((c) => c.value));\n  const level = (v: number) =>\n    v <= 0 || max <= 0\n      ? 0\n      : Math.min(steps - 1, Math.max(1, Math.ceil((v / max) * (steps - 1))));\n  const fill = (l: number) => {\n    if (l === 0) return \"var(--border-strong)\";\n    const pct = steps === 2 ? 100 : Math.round(30 + (70 * (l - 1)) / (steps - 2));\n    return `color-mix(in oklab, ${color} ${pct}%, transparent)`;\n  };\n\n  const pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, (width - LEFT) / cols));\n  const gap = Math.max(2, pitch * 0.18);\n  const cell = pitch - gap;\n  const svgW = LEFT + cols * pitch - gap;\n  const svgH = TOP + 7 * pitch - gap;\n  const cellX = (i: number) => LEFT + Math.floor(i / 7) * pitch;\n  const cellY = (i: number) => TOP + (i % 7) * pitch;\n\n  // Month label over the first column of each month; a first label crowded by\n  // the next one is dropped.\n  const months: { col: number; text: string }[] = [];\n  for (let c = 0; c < cols; c++) {\n    const m = new Date((first + c * 7) * DAY_MS).getUTCMonth();\n    const prev = c === 0 ? -1 : new Date((first + (c - 1) * 7) * DAY_MS).getUTCMonth();\n    if (m !== prev) months.push({ col: c, text: MONTH.format(new Date((first + c * 7) * DAY_MS)) });\n  }\n  if (months.length > 1 && months[1].col - months[0].col < 3) months.shift();\n\n  // Narrow screens: start on the newest weeks, the end people read first.\n  const overflows = svgW > width;\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el || !overflows) return;\n    el.scrollLeft = el.scrollWidth;\n    setScrollLeft(el.scrollLeft);\n  }, [overflows]);\n\n  const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const col = Math.floor((e.clientX - box.left - LEFT) / pitch);\n    const row = Math.floor((e.clientY - box.top - TOP) / pitch);\n    const i = col * 7 + row;\n    if (col < 0 || row < 0 || row > 6 || i >= count) setActive(null);\n    else setActive(i);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<SVGSVGElement>) => {\n    const deltas: Record<string, number> = {\n      ArrowUp: -1,\n      ArrowDown: 1,\n      ArrowLeft: -7,\n      ArrowRight: 7,\n    };\n    let next: number;\n    if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = count - 1;\n    else if (e.key in deltas) {\n      if (active === null) next = count - 1;\n      else {\n        const moved = active + deltas[e.key];\n        next = moved < 0 || moved >= count ? active : moved;\n      }\n    } else return;\n    e.preventDefault();\n    setActive(next);\n    // Keep the read cell inside the scroll window.\n    const el = scrollRef.current;\n    if (el && overflows) {\n      const x = cellX(next);\n      if (x < el.scrollLeft + LEFT) el.scrollLeft = x - LEFT;\n      else if (x + pitch > el.scrollLeft + el.clientWidth) {\n        el.scrollLeft = x + pitch - el.clientWidth;\n      }\n    }\n  };\n\n  const activeCell = active === null ? null : cells[active];\n\n  return (\n    <div ref={viewRef} className={cn(\"w-full\", className)}>\n      <div ref={measureRef} className=\"relative w-full\">\n        <div\n          ref={scrollRef}\n          onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}\n          className={cn(\"w-full\", overflows && \"overflow-x-auto pb-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\")}\n        >\n          {width > 0 && count > 0 && (\n            // biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot\n            <svg\n              width={svgW}\n              height={svgH}\n              role=\"group\"\n              aria-label={`${title}. Use arrow keys to read each day: up and down by day, left and right by week.`}\n              // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values\n              tabIndex={0}\n              onKeyDown={onKeyDown}\n              onPointerMove={onPointerMove}\n              onPointerLeave={() => setActive(null)}\n              onBlur={() => setActive(null)}\n              className=\"block touch-pan-x touch-pan-y rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              {months.map((m) => (\n                <text\n                  key={m.col}\n                  x={LEFT + m.col * pitch}\n                  y={TOP - 7}\n                  className=\"fill-muted-foreground text-xs\"\n                >\n                  {m.text}\n                </text>\n              ))}\n              {WEEKDAYS.map(([row, text]) => (\n                <text\n                  key={text}\n                  x={0}\n                  y={TOP + row * pitch + cell / 2}\n                  dy=\"0.32em\"\n                  className=\"fill-muted-foreground text-[10px]\"\n                >\n                  {text}\n                </text>\n              ))}\n\n              <motion.g\n                initial={reduce ? false : \"hidden\"}\n                animate={shown ? \"shown\" : \"hidden\"}\n              >\n                {cells.map((c, i) => (\n                  <motion.rect\n                    key={c.day}\n                    x={cellX(i)}\n                    y={cellY(i)}\n                    width={cell}\n                    height={cell}\n                    rx={Math.min(3, cell * 0.22)}\n                    fill={fill(level(c.value))}\n                    custom={Math.floor(i / 7)}\n                    variants={{\n                      hidden: { opacity: 0, scale: reduce ? 1 : 0.4 },\n                      shown: (col: number) => ({\n                        opacity: 1,\n                        scale: 1,\n                        transition: reduce\n                          ? { duration: 0 }\n                          : { ...GROW, duration: 0.45, delay: col * 0.012 },\n                      }),\n                    }}\n                    style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                  />\n                ))}\n              </motion.g>\n\n              {active !== null && (\n                <motion.rect\n                  initial={false}\n                  animate={{ x: cellX(active) - 1, y: cellY(active) - 1 }}\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : { type: \"spring\", stiffness: 520, damping: 40 }\n                  }\n                  width={cell + 2}\n                  height={cell + 2}\n                  rx={Math.min(4, cell * 0.25)}\n                  fill=\"none\"\n                  stroke=\"var(--foreground)\"\n                  strokeWidth={1.5}\n                  pointerEvents=\"none\"\n                />\n              )}\n            </svg>\n          )}\n        </div>\n        <ChartTooltip\n          open={activeCell !== null}\n          x={active === null ? 0 : cellX(active) + cell / 2 - scrollLeft}\n          y={active === null ? 0 : cellY(active) + cell / 2}\n          width={width}\n          label={\n            activeCell\n              ? `${formatValue(activeCell.value)} · ${formatDate(activeCell.iso)}`\n              : \"\"\n          }\n          rows={[]}\n        />\n      </div>\n\n      <div className=\"flex items-center justify-end gap-1 pt-3 text-xs text-muted-foreground\">\n        <span className=\"mr-1\">Less</span>\n        {Array.from({ length: steps }, (_, l) => (\n          <span\n            // biome-ignore lint/suspicious/noArrayIndexKey: steps are a fixed ordered scale.\n            key={l}\n            aria-hidden\n            className=\"size-2.5 shrink-0 rounded-[2px]\"\n            style={{ background: fill(l) }}\n          />\n        ))}\n        <span className=\"ml-1\">More</span>\n      </div>\n\n      <ChartTable\n        caption={title}\n        head={[\"Date\", \"Value\"]}\n        rows={cells.map((c) => [\n          LONG.format(new Date(c.day * DAY_MS)),\n          formatValue(c.value),\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"}]}