{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-states",
  "title": "Chart States",
  "description": "Loading skeletons, empty states and error states for every chart — one wrapper, six skeleton shapes, and a frame height that never shifts between them.",
  "files": [
    {
      "path": "app/registry/charts/chart-engine.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\n\nexport const marketVarsClassName =\n  '[--spectrum-chart-up:#059669] [--spectrum-chart-down:#e11d48] [--spectrum-chart-surface:#fff] dark:[--spectrum-chart-up:#34d399] dark:[--spectrum-chart-down:#fb7185] dark:[--spectrum-chart-surface:#0a0a0a]';\n\nexport const UP = 'var(--spectrum-chart-up)';\nexport const DOWN = 'var(--spectrum-chart-down)';\nexport const SURFACE = 'var(--spectrum-chart-surface)';\nexport const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';\n\nexport type Candle = {\n  t: number;\n  open: number;\n  high: number;\n  low: number;\n  close: number;\n  volume: number;\n};\n\nexport type MarketRange = {\n  label: string;\n  bars: number | null;\n};\n\nexport const MARKET_RANGES: MarketRange[] = [\n  { label: '1W', bars: 7 },\n  { label: '1M', bars: 30 },\n  { label: '3M', bars: 90 },\n  { label: '6M', bars: 180 },\n  { label: '1Y', bars: 365 },\n  { label: 'ALL', bars: null },\n];\n\nexport function mulberry32(seed: number) {\n  let a = seed;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nexport const DAY_MS = 86_400_000;\n\nexport function generateCandles({\n  seed,\n  count,\n  start,\n  drift,\n  vol,\n  startedAt,\n}: {\n  seed: number;\n  count: number;\n  start: number;\n  drift: number;\n  vol: number;\n  startedAt: number;\n}): Candle[] {\n  const rand = mulberry32(seed);\n  const out: Candle[] = [];\n  let price = start;\n  let volatility = vol;\n\n  for (let i = 0; i < count; i += 1) {\n    volatility += (vol - volatility) * 0.05 + (rand() - 0.5) * vol * 0.35;\n    volatility = Math.max(vol * 0.35, Math.min(vol * 2.6, volatility));\n\n    const open = price;\n    const shock = (rand() - 0.5) * 2 * volatility + drift;\n    const close = Math.max(0.01, open * (1 + shock));\n    const wick = Math.abs(shock) * 0.9 + volatility * 0.55;\n    const high = Math.max(open, close) * (1 + rand() * wick);\n    const low = Math.min(open, close) * (1 - rand() * wick);\n    const range = Math.abs(close - open) / Math.max(open, 1e-6);\n    const volume = Math.round((0.55 + rand() * 0.7 + range * 26) * 1_000_000);\n\n    out.push({\n      t: startedAt + i * DAY_MS,\n      open: round2(open),\n      high: round2(high),\n      low: round2(low),\n      close: round2(close),\n      volume,\n    });\n    price = close;\n  }\n  return out;\n}\n\nexport function round2(v: number) {\n  return Math.round(v * 100) / 100;\n}\n\nconst SERIES_END = Date.UTC(2026, 7, 21);\nconst SERIES_LEN = 420;\nconst SERIES_START = SERIES_END - (SERIES_LEN - 1) * DAY_MS;\n\nexport const SOL_MARKET = generateCandles({\n  seed: 20_260_823,\n  count: SERIES_LEN,\n  start: 96.4,\n  drift: 0.0021,\n  vol: 0.031,\n  startedAt: SERIES_START,\n});\n\nexport const AAPL_MARKET = generateCandles({\n  seed: 7_431_902,\n  count: SERIES_LEN,\n  start: 189.2,\n  drift: 0.0009,\n  vol: 0.013,\n  startedAt: SERIES_START,\n});\n\nexport const BTC_MARKET = generateCandles({\n  seed: 41_556_073,\n  count: SERIES_LEN,\n  start: 61_400,\n  drift: 0.0016,\n  vol: 0.022,\n  startedAt: SERIES_START,\n});\n\nexport function formatMoney(value: number, compact = false) {\n  const digits = compact ? 2 : value >= 1000 ? 2 : value >= 1 ? 2 : 4;\n  return new Intl.NumberFormat('en-US', {\n    style: 'currency',\n    currency: 'USD',\n    notation: compact ? 'compact' : 'standard',\n    minimumFractionDigits: compact ? 0 : digits,\n    maximumFractionDigits: digits,\n  }).format(value);\n}\n\nexport function formatSignedPct(value: number) {\n  return `${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(2)}%`;\n}\n\nexport const DATE_SHORT = new Intl.DateTimeFormat('en-US', {\n  month: 'short',\n  day: 'numeric',\n  timeZone: 'UTC',\n});\nexport const DATE_FULL = new Intl.DateTimeFormat('en-US', {\n  month: 'short',\n  day: 'numeric',\n  year: 'numeric',\n  timeZone: 'UTC',\n});\n\nexport function formatAxisPrice(value: number) {\n  if (Math.abs(value) >= 10_000) return formatMoney(value, true);\n  return formatMoney(value, false);\n}\n\nexport function niceTicks(lo: number, hi: number, target = 5): number[] {\n  if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return [lo];\n  const raw = (hi - lo) / target;\n  const mag = Math.pow(10, Math.floor(Math.log10(raw)));\n  const norm = raw / mag;\n  const step = (norm >= 7.5 ? 10 : norm >= 3.5 ? 5 : norm >= 2.25 ? 2.5 : norm >= 1.5 ? 2 : 1) * mag;\n  const first = Math.ceil(lo / step) * step;\n  const out: number[] = [];\n  for (let v = first; v <= hi + step * 0.001; v += step) {\n    out.push(Math.round(v / step) * step);\n  }\n  return out;\n}\n\nexport function monotonePath(points: { x: number; y: number }[]): string {\n  const n = points.length;\n  if (n === 0) return '';\n  if (n === 1) return `M${points[0].x},${points[0].y}`;\n\n  const dx: number[] = [];\n  const slope: number[] = [];\n  for (let i = 0; i < n - 1; i += 1) {\n    dx[i] = points[i + 1].x - points[i].x;\n    slope[i] = dx[i] === 0 ? 0 : (points[i + 1].y - points[i].y) / dx[i];\n  }\n\n  const tangent = new Array<number>(n);\n  tangent[0] = slope[0];\n  tangent[n - 1] = slope[n - 2];\n  for (let i = 1; i < n - 1; i += 1) {\n    if (slope[i - 1] * slope[i] <= 0) {\n      tangent[i] = 0;\n    } else {\n      const w1 = 2 * dx[i] + dx[i - 1];\n      const w2 = dx[i] + 2 * dx[i - 1];\n      tangent[i] = (w1 + w2) / (w1 / slope[i - 1] + w2 / slope[i]);\n    }\n  }\n\n  let d = `M${points[0].x},${points[0].y}`;\n  for (let i = 0; i < n - 1; i += 1) {\n    const c1x = points[i].x + dx[i] / 3;\n    const c1y = points[i].y + (tangent[i] * dx[i]) / 3;\n    const c2x = points[i + 1].x - dx[i] / 3;\n    const c2y = points[i + 1].y - (tangent[i + 1] * dx[i]) / 3;\n    d += `C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${points[i + 1].x.toFixed(2)},${points[i + 1].y.toFixed(2)}`;\n  }\n  return d;\n}\n\nexport const MORPH_SAMPLES = 132;\n\nexport function resample(values: number[], n = MORPH_SAMPLES): number[] {\n  if (values.length === 0) return new Array(n).fill(0);\n  if (values.length === 1) return new Array(n).fill(values[0]);\n  const out = new Array<number>(n);\n  for (let i = 0; i < n; i += 1) {\n    const p = (i / (n - 1)) * (values.length - 1);\n    const lo = Math.floor(p);\n    const hi = Math.min(values.length - 1, lo + 1);\n    out[i] = values[lo] + (values[hi] - values[lo]) * (p - lo);\n  }\n  return out;\n}\n\nexport function usePrefersReducedMotion() {\n  const [reduce, setReduce] = React.useState(false);\n  React.useEffect(() => {\n    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const sync = () => setReduce(mq.matches);\n    sync();\n    mq.addEventListener('change', sync);\n    return () => mq.removeEventListener('change', sync);\n  }, []);\n  return reduce;\n}\n\nexport function useElementWidth<T extends HTMLElement>() {\n  const ref = React.useRef<T | null>(null);\n  const [width, setWidth] = React.useState(0);\n  React.useEffect(() => {\n    const node = ref.current;\n    if (!node) return;\n    const ro = new ResizeObserver(([entry]) => {\n      setWidth(entry.contentRect.width);\n    });\n    ro.observe(node);\n    setWidth(node.getBoundingClientRect().width);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\nexport const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);\n\nexport function useTween(target: number[], { duration = 520, enabled = true } = {}) {\n  const [value, setValue] = React.useState(target);\n  const currentRef = React.useRef(target);\n  const fromRef = React.useRef(target);\n  const toRef = React.useRef(target);\n  const startRef = React.useRef(0);\n  const rafRef = React.useRef(0);\n\n  React.useEffect(() => {\n    if (!enabled) return;\n    const to = toRef.current;\n    const changed = to.length !== target.length || target.some((v, i) => v !== to[i]);\n    if (!changed) return;\n\n    toRef.current = target;\n\n    if (currentRef.current.length !== target.length) {\n      currentRef.current = target;\n      fromRef.current = target;\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = requestAnimationFrame(() => setValue(target));\n      return;\n    }\n\n    fromRef.current = currentRef.current;\n    startRef.current = performance.now();\n    cancelAnimationFrame(rafRef.current);\n\n    const tick = (now: number) => {\n      const p = Math.min(1, (now - startRef.current) / duration);\n      const e = easeOutCubic(p);\n      const from = fromRef.current;\n      const dest = toRef.current;\n      const next = dest.map((v, i) => from[i] + (v - from[i]) * e);\n      currentRef.current = next;\n      setValue(next);\n      if (p < 1) rafRef.current = requestAnimationFrame(tick);\n    };\n    rafRef.current = requestAnimationFrame(tick);\n  });\n\n  React.useEffect(() => () => cancelAnimationFrame(rafRef.current), []);\n\n  if (!enabled || value.length !== target.length) return target;\n  return value;\n}\n\nexport function useTweenNumber(target: number, options?: { duration?: number; enabled?: boolean }) {\n  const vec = React.useMemo(() => [target], [target]);\n  return useTween(vec, options)[0];\n}\n\nexport const KEYFRAMES = `\n@keyframes spectrum-mc-rise {\n  from { transform: scaleY(0); opacity: 0; }\n  to   { transform: scaleY(1); opacity: 1; }\n}\n@keyframes spectrum-mc-draw {\n  from { stroke-dashoffset: 1; }\n  to   { stroke-dashoffset: 0; }\n}\n@keyframes spectrum-mc-fade {\n  from { opacity: 0; }\n  to   { opacity: 1; }\n}\n@keyframes spectrum-sk-pulse {\n  from { opacity: 1; }\n  to   { opacity: 0.4; }\n}\n@keyframes spectrum-mc-enter {\n  from { opacity: 0; transform: translateY(6px); }\n  to   { opacity: 1; transform: translateY(0); }\n}\n@keyframes spectrum-mc-grow {\n  from { transform: scaleX(0); }\n  to   { transform: scaleX(1); }\n}\n@keyframes spectrum-mc-flash {\n  from { opacity: 0.2; }\n  to   { opacity: 0; }\n}\n@keyframes spectrum-mc-ping {\n  0%   { r: 4; opacity: 0.55; }\n  70%  { r: 13; opacity: 0; }\n  100% { r: 13; opacity: 0; }\n}\n`;\n\nexport function Keyframes() {\n  return <style>{KEYFRAMES}</style>;\n}\n\nconst DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];\n\nfunction Digit({ char, animate }: { char: string; animate: boolean }) {\n  const digit = char >= '0' && char <= '9' ? Number(char) : null;\n  if (digit == null) {\n    return (\n      <span aria-hidden className=\"inline-block h-[1em] align-bottom leading-none\">\n        {char}\n      </span>\n    );\n  }\n  return (\n    <span\n      aria-hidden\n      className=\"relative inline-block h-[1em] w-[1ch] overflow-hidden align-bottom leading-none\"\n    >\n      <span\n        className=\"absolute inset-x-0 top-0 block\"\n        style={{\n          transform: `translateY(-${digit}em)`,\n          transition: animate ? 'transform 620ms cubic-bezier(0.22, 1, 0.36, 1)' : undefined,\n        }}\n      >\n        {DIGITS.map((d) => (\n          <span key={d} className=\"block h-[1em] text-center leading-none\">\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\nexport function RollingNumber({\n  value,\n  format,\n  className,\n  animate = true,\n}: {\n  value: number;\n  format: (value: number) => string;\n  className?: string;\n  animate?: boolean;\n}) {\n  const text = format(value);\n  return (\n    <span className={cn('inline-flex items-end leading-none tabular-nums', className)}>\n      <span className=\"sr-only\">{text}</span>\n      {text.split('').map((char, index) => (\n        <Digit key={`${index}-${char >= '0' && char <= '9' ? 'digit' : char}`} char={char} animate={animate} />\n      ))}\n    </span>\n  );\n}\n\nexport function RangeSelector({\n  ranges,\n  value,\n  onChange,\n  reduce,\n}: {\n  ranges: MarketRange[];\n  value: string;\n  onChange: (label: string) => void;\n  reduce: boolean;\n}) {\n  const index = Math.max(0, ranges.findIndex((r) => r.label === value));\n  const width = 100 / ranges.length;\n\n  return (\n    <div\n      role=\"tablist\"\n      aria-label=\"Time range\"\n      className=\"relative inline-flex items-center rounded-full bg-black/[0.045] p-0.5 dark:bg-white/[0.07]\"\n    >\n      <span\n        aria-hidden\n        className=\"absolute inset-y-0.5 left-0.5 rounded-full bg-white shadow-sm ring-1 ring-black/[0.06] dark:bg-white/12 dark:ring-white/10\"\n        style={{\n          width: `calc(${width}% - 4px)`,\n          transform: `translateX(calc(${index * 100}% + ${index * 4}px))`,\n          transition: reduce ? undefined : 'transform 380ms cubic-bezier(0.22, 1, 0.36, 1)',\n        }}\n      />\n      {ranges.map((range) => {\n        const active = range.label === value;\n        return (\n          <button\n            key={range.label}\n            type=\"button\"\n            role=\"tab\"\n            aria-selected={active}\n            onClick={() => onChange(range.label)}\n            className={cn(\n              'relative z-10 rounded-full px-2.5 py-1 font-mono text-[11px] leading-none tracking-wide transition-colors duration-200',\n              'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black/20 dark:focus-visible:ring-white/25',\n              active\n                ? 'text-neutral-950 dark:text-white'\n                : 'text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200',\n            )}\n            style={{ flex: `0 0 ${width}%` }}\n          >\n            {range.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport type BookLevel = {\n  price: number;\n  size: number;\n  total: number;\n};\n\nexport type OrderBook = {\n  mid: number;\n  spread: number;\n  bids: BookLevel[];\n  asks: BookLevel[];\n};\n\nexport function generateOrderBook({\n  mid,\n  seed,\n  levels = 14,\n  tick,\n  depth = 900,\n}: {\n  mid: number;\n  seed: number;\n  levels?: number;\n  tick?: number;\n  depth?: number;\n}): OrderBook {\n  const rand = mulberry32(seed);\n  const step = tick ?? Math.max(0.01, round2(mid * 0.0006));\n\n  const side = (direction: 1 | -1): BookLevel[] => {\n    const out: BookLevel[] = [];\n    let total = 0;\n    for (let i = 0; i < levels; i += 1) {\n      const distance = (i + 1) / levels;\n      const base = depth * (0.18 + distance * 0.9);\n      const whale = rand() > 0.87 ? 2.4 : 1;\n      const size = Math.round(base * (0.55 + rand() * 0.9) * whale);\n      total += size;\n      out.push({\n        price: round2(mid + direction * step * (i + 1)),\n        size,\n        total,\n      });\n    }\n    return out;\n  };\n\n  const asks = side(1);\n  const bids = side(-1);\n  return { mid, spread: round2(asks[0].price - bids[0].price), bids, asks };\n}\n\nexport type TreemapInput = { label: string; weight: number; change: number; name?: string };\nexport type TreemapTile = TreemapInput & { x: number; y: number; w: number; h: number };\n\nexport function squarify(\n  items: TreemapInput[],\n  width: number,\n  height: number,\n): TreemapTile[] {\n  const sorted = [...items].filter((i) => i.weight > 0).sort((a, b) => b.weight - a.weight);\n  const totalWeight = sorted.reduce((sum, i) => sum + i.weight, 0);\n  if (!sorted.length || totalWeight <= 0 || width <= 0 || height <= 0) return [];\n\n  const scale = (width * height) / totalWeight;\n  const out: TreemapTile[] = [];\n  let x = 0;\n  let y = 0;\n  let w = width;\n  let h = height;\n  let row: TreemapInput[] = [];\n  let index = 0;\n\n  const worst = (candidate: TreemapInput[], side: number) => {\n    if (!candidate.length || side <= 0) return Infinity;\n    const areas = candidate.map((i) => i.weight * scale);\n    const sum = areas.reduce((a, b) => a + b, 0);\n    const max = Math.max(...areas);\n    const min = Math.min(...areas);\n    const side2 = side * side;\n    const sum2 = sum * sum;\n    return Math.max((side2 * max) / sum2, sum2 / (side2 * min));\n  };\n\n  const layoutRow = (candidate: TreemapInput[], side: number, horizontal: boolean) => {\n    const sum = candidate.reduce((total, i) => total + i.weight * scale, 0);\n    const thickness = sum / side;\n    let offset = 0;\n    for (const item of candidate) {\n      const length = (item.weight * scale) / thickness;\n      out.push(\n        horizontal\n          ? { ...item, x: x + offset, y, w: length, h: thickness }\n          : { ...item, x, y: y + offset, w: thickness, h: length },\n      );\n      offset += length;\n    }\n    if (horizontal) {\n      y += thickness;\n      h -= thickness;\n    } else {\n      x += thickness;\n      w -= thickness;\n    }\n  };\n\n  while (index < sorted.length) {\n    const horizontal = w >= h;\n    const side = horizontal ? w : h;\n    const next = sorted[index];\n\n    if (!row.length || worst([...row, next], side) <= worst(row, side)) {\n      row.push(next);\n      index += 1;\n    } else {\n      layoutRow(row, side, horizontal);\n      row = [];\n    }\n  }\n  if (row.length) layoutRow(row, w >= h ? w : h, w >= h);\n\n  return out;\n}\n\nexport function changeColor(change: number, cap = 4) {\n  const t = Math.max(-1, Math.min(1, change / cap));\n  if (Math.abs(t) < 0.04) return 'var(--spectrum-heat-flat)';\n  const weight = 0.22 + Math.abs(t) * 0.78;\n  const base = t > 0 ? UP : DOWN;\n  return `color-mix(in srgb, ${base} ${(weight * 100).toFixed(0)}%, var(--spectrum-heat-flat))`;\n}\n\nexport const seriesVarsClassName =\n  '[--spectrum-series-1:#2563eb] [--spectrum-series-2:#f59e0b] [--spectrum-series-3:#0d9488] [--spectrum-series-4:#7c3aed] [--spectrum-series-5:#db2777] [--spectrum-series-6:#64748b] [--spectrum-track:#ececef] [--spectrum-chart-surface:#fff] [--spectrum-chart-up:#059669] [--spectrum-chart-down:#e11d48] dark:[--spectrum-series-1:#60a5fa] dark:[--spectrum-series-2:#fbbf24] dark:[--spectrum-series-3:#2dd4bf] dark:[--spectrum-series-4:#a78bfa] dark:[--spectrum-series-5:#f472b6] dark:[--spectrum-series-6:#94a3b8] dark:[--spectrum-track:#26262b] dark:[--spectrum-chart-surface:#0a0a0a] dark:[--spectrum-chart-up:#34d399] dark:[--spectrum-chart-down:#fb7185]';\n\nexport const SERIES_COLORS = [\n  'var(--spectrum-series-1)',\n  'var(--spectrum-series-2)',\n  'var(--spectrum-series-3)',\n  'var(--spectrum-series-4)',\n  'var(--spectrum-series-5)',\n  'var(--spectrum-series-6)',\n] as const;\n\nexport const TRACK = 'var(--spectrum-track)';\n\nexport const textHalo = {\n  paintOrder: 'stroke',\n  stroke: SURFACE,\n  strokeWidth: 3.5,\n  strokeLinejoin: 'round',\n} as const satisfies React.CSSProperties;\n\nexport function intensityColor(t: number, hue = 'var(--spectrum-series-1)') {\n  const clamped = Math.max(0, Math.min(1, t));\n  if (clamped <= 0.001) return TRACK;\n  const weight = 14 + clamped * 86;\n  return `color-mix(in srgb, ${hue} ${weight.toFixed(0)}%, ${TRACK})`;\n}\n\nexport function formatCount(value: number, digits = 1) {\n  if (Math.abs(value) < 1000) return String(Math.round(value));\n  return new Intl.NumberFormat('en-US', {\n    notation: 'compact',\n    maximumFractionDigits: digits,\n  }).format(value);\n}\n\nexport function formatPct(value: number, digits = 1) {\n  return `${value.toFixed(digits)}%`;\n}\n\nexport function onFillClass(intensity: number) {\n  return intensity > 0.55\n    ? 'fill-white dark:fill-neutral-950'\n    : 'fill-neutral-900 dark:fill-white';\n}\n\nexport type ChartStatus = 'ready' | 'loading' | 'empty' | 'error';\n\nexport type SkeletonVariant = 'bars' | 'line' | 'grid' | 'rows' | 'arc' | 'cards';\n\nconst SKELETON_BARS = [46, 72, 55, 83, 41, 68, 92, 57, 76, 49, 88, 63];\nconst SKELETON_ROWS = [92, 74, 86, 61, 79, 55];\n\nfunction SkeletonShapes({\n  variant,\n  height,\n  mode,\n  reduce,\n}: {\n  variant: SkeletonVariant;\n  height: number;\n  mode: 'pulse' | 'ghost';\n  reduce: boolean;\n}) {\n  const animate = mode === 'pulse' && !reduce;\n  const breathe = (index: number): React.CSSProperties | undefined =>\n    animate\n      ? { animation: `spectrum-sk-pulse 1.5s ease-in-out ${index * 120}ms infinite alternate` }\n      : undefined;\n  const block = 'rounded-md bg-black/[0.06] dark:bg-white/[0.08]';\n\n  if (variant === 'bars') {\n    return (\n      <div className=\"flex h-full items-end gap-2 pb-5 pt-3\">\n        {SKELETON_BARS.map((h, i) => (\n          <div key={i} className={cn('flex-1', block)} style={{ height: `${h}%`, ...breathe(i) }} />\n        ))}\n      </div>\n    );\n  }\n\n  if (variant === 'line') {\n    return (\n      <div className=\"relative flex h-full flex-col justify-between py-4\">\n        {[0, 1, 2, 3].map((i) => (\n          <div key={i} className=\"h-px w-full bg-black/[0.05] dark:bg-white/[0.06]\" />\n        ))}\n        <svg\n          className=\"absolute inset-x-0 top-1/4 h-1/2 w-full\"\n          preserveAspectRatio=\"none\"\n          viewBox=\"0 0 100 40\"\n        >\n          <path\n            d=\"M0,30 C12,10 22,34 34,22 C46,10 54,28 66,16 C78,6 88,20 100,8\"\n            fill=\"none\"\n            className=\"stroke-black/[0.1] dark:stroke-white/[0.12]\"\n            strokeWidth={2}\n            vectorEffect=\"non-scaling-stroke\"\n            style={breathe(1)}\n          />\n        </svg>\n      </div>\n    );\n  }\n\n  if (variant === 'grid') {\n    return (\n      <div className=\"grid h-full grid-cols-12 grid-rows-6 gap-1.5 py-2\">\n        {Array.from({ length: 72 }, (_, i) => (\n          <div\n            key={i}\n            className={cn('h-full w-full rounded-[3px]', block)}\n            style={breathe(i % 14)}\n          />\n        ))}\n      </div>\n    );\n  }\n\n  if (variant === 'rows') {\n    return (\n      <div className=\"flex h-full flex-col justify-evenly py-2\">\n        {SKELETON_ROWS.map((w, i) => (\n          <div key={i} className={cn('h-4', block)} style={{ width: `${w}%`, ...breathe(i) }} />\n        ))}\n      </div>\n    );\n  }\n\n  if (variant === 'cards') {\n    return (\n      <div className=\"grid h-full grid-cols-2 content-center gap-3 py-2 sm:grid-cols-4\">\n        {[0, 1, 2, 3].map((i) => (\n          <div\n            key={i}\n            className=\"flex h-full min-h-16 flex-col justify-between gap-2 rounded-xl border border-black/[0.05] p-3 dark:border-white/[0.06]\"\n            style={breathe(i)}\n          >\n            <div className={cn('h-2.5 w-1/2', block)} />\n            <div className={cn('h-5 w-3/4', block)} />\n            <div className={cn('h-6 w-full', block)} />\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <div\n        className=\"rounded-full border-[14px] border-black/[0.06] dark:border-white/[0.08]\"\n        style={{ width: height * 0.6, height: height * 0.6, ...breathe(0) }}\n      />\n    </div>\n  );\n}\n\nexport function ChartSkeleton({\n  variant = 'bars',\n  height = 300,\n  className,\n}: {\n  variant?: SkeletonVariant;\n  height?: number;\n  className?: string;\n}) {\n  const reduce = usePrefersReducedMotion();\n  return (\n    <div\n      className={cn('relative w-full overflow-hidden', className)}\n      style={{ height }}\n      aria-hidden\n    >\n      <Keyframes />\n      <SkeletonShapes variant={variant} height={height} mode=\"pulse\" reduce={reduce} />\n    </div>\n  );\n}\n\nfunction StateShell({\n  height,\n  variant,\n  icon,\n  iconClassName,\n  title,\n  description,\n  action,\n}: {\n  height: number;\n  variant: SkeletonVariant;\n  icon: React.ReactNode;\n  iconClassName?: string;\n  title: string;\n  description?: string;\n  action?: React.ReactNode;\n}) {\n  const reduce = usePrefersReducedMotion();\n  return (\n    <div\n      className=\"relative flex w-full items-center justify-center overflow-hidden rounded-xl border border-black/[0.06] bg-black/[0.015] dark:border-white/[0.08] dark:bg-white/[0.02]\"\n      style={{ height }}\n      role=\"status\"\n    >\n      <Keyframes />\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-5 inset-y-4 opacity-60 dark:opacity-50\"\n        style={{\n          maskImage:\n            'radial-gradient(ellipse 62% 58% at 50% 50%, transparent 34%, black 78%)',\n          WebkitMaskImage:\n            'radial-gradient(ellipse 62% 58% at 50% 50%, transparent 34%, black 78%)',\n        }}\n      >\n        <SkeletonShapes variant={variant} height={height} mode=\"ghost\" reduce={reduce} />\n      </div>\n      <div\n        className=\"relative flex max-w-[38ch] flex-col items-center gap-1 px-6 text-center\"\n        style={reduce ? undefined : { animation: `spectrum-mc-enter 480ms ${EASE} both` }}\n      >\n        <span\n          className={cn(\n            'mb-2 flex size-10 items-center justify-center rounded-xl border border-black/[0.07] bg-white text-neutral-500 shadow-xs dark:border-white/[0.1] dark:bg-neutral-900 dark:text-neutral-400',\n            iconClassName,\n          )}\n        >\n          {icon}\n        </span>\n        <p className=\"text-[13px] font-medium text-neutral-900 dark:text-neutral-100\">{title}</p>\n        {description ? (\n          <p className=\"text-[12px] leading-relaxed text-neutral-500 dark:text-neutral-400\">\n            {description}\n          </p>\n        ) : null}\n        {action ? <div className=\"mt-3\">{action}</div> : null}\n      </div>\n    </div>\n  );\n}\n\nconst actionClass =\n  'inline-flex items-center gap-1.5 rounded-full border border-black/10 bg-white px-3 py-1.5 text-[12px] font-medium text-neutral-900 shadow-xs transition-colors hover:bg-black/[0.03] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black/20 dark:border-white/12 dark:bg-white/[0.06] dark:text-white dark:hover:bg-white/[0.1] dark:focus-visible:ring-white/25';\n\nexport function ChartEmpty({\n  height = 300,\n  variant = 'line',\n  title = 'No data yet',\n  description = 'Once events start arriving this chart will fill in automatically.',\n  action,\n}: {\n  height?: number;\n  variant?: SkeletonVariant;\n  title?: string;\n  description?: string;\n  action?: React.ReactNode;\n}) {\n  return (\n    <StateShell\n      height={height}\n      variant={variant}\n      title={title}\n      description={description}\n      action={action}\n      icon={\n        <svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden>\n          <path\n            d=\"M4 19h16M7 16V9m5 7V5m5 11v-4\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.7\"\n            strokeLinecap=\"round\"\n          />\n        </svg>\n      }\n    />\n  );\n}\n\nexport function ChartError({\n  height = 300,\n  variant = 'line',\n  title = 'Could not load this chart',\n  description = 'The request failed. Check the connection and try again.',\n  onRetry,\n  retryLabel = 'Retry',\n}: {\n  height?: number;\n  variant?: SkeletonVariant;\n  title?: string;\n  description?: string;\n  onRetry?: () => void;\n  retryLabel?: string;\n}) {\n  return (\n    <StateShell\n      height={height}\n      variant={variant}\n      title={title}\n      description={description}\n      iconClassName=\"text-rose-500/90 dark:text-rose-400/90\"\n      action={\n        onRetry ? (\n          <button type=\"button\" onClick={onRetry} className={actionClass}>\n            <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden>\n              <path\n                d=\"M20 11A8 8 0 1 0 18 16M20 5v6h-6\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.9\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            </svg>\n            {retryLabel}\n          </button>\n        ) : null\n      }\n      icon={\n        <svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden>\n          <path\n            d=\"M12 8v5m0 3.5v.5M10.3 3.9 2.6 17.3A2 2 0 0 0 4.3 20h15.4a2 2 0 0 0 1.7-2.7L13.7 3.9a2 2 0 0 0-3.4 0Z\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      }\n    />\n  );\n}\n\nexport function ChartState({\n  status = 'ready',\n  height = 300,\n  variant = 'bars',\n  empty,\n  error,\n  onRetry,\n  children,\n}: {\n  status?: ChartStatus;\n  height?: number;\n  variant?: SkeletonVariant;\n  empty?: { title?: string; description?: string; action?: React.ReactNode };\n  error?: { title?: string; description?: string; retryLabel?: string };\n  onRetry?: () => void;\n  children: React.ReactNode;\n}) {\n  if (status === 'loading') {\n    return (\n      <div aria-busy=\"true\" aria-live=\"polite\">\n        <ChartSkeleton variant={variant} height={height} />\n        <span className=\"sr-only\">Loading chart data</span>\n      </div>\n    );\n  }\n  if (status === 'empty') return <ChartEmpty height={height} variant={variant} {...empty} />;\n  if (status === 'error') {\n    return <ChartError height={height} variant={variant} onRetry={onRetry} {...error} />;\n  }\n  return <>{children}</>;\n}\n\nexport function useHoverIndexKeys({\n  count,\n  setIndex,\n  clear,\n}: {\n  count: number;\n  setIndex: React.Dispatch<React.SetStateAction<number | null>>;\n  clear?: () => void;\n}) {\n  return React.useCallback(\n    (event: React.KeyboardEvent) => {\n      const { key } = event;\n      if (key === 'Escape') {\n        event.preventDefault();\n        if (clear) clear();\n        else setIndex(null);\n        return;\n      }\n      if (key === 'Home' || key === 'End') {\n        event.preventDefault();\n        setIndex(key === 'Home' ? 0 : count - 1);\n        return;\n      }\n      if (key !== 'ArrowLeft' && key !== 'ArrowRight') return;\n      event.preventDefault();\n      const step = key === 'ArrowRight' ? 1 : -1;\n      setIndex((current) => {\n        const next = (current ?? count - 1) + step;\n        return Math.max(0, Math.min(count - 1, next));\n      });\n    },\n    [count, setIndex, clear],\n  );\n}\n\nexport function ChartDataTable({\n  caption,\n  columns,\n  rows,\n}: {\n  caption: string;\n  columns: string[];\n  rows: (string | number)[][];\n}) {\n  return (\n    <table className=\"sr-only\">\n      <caption>{caption}</caption>\n      <thead>\n        <tr>\n          {columns.map((column) => (\n            <th key={column} scope=\"col\">\n              {column}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((row, index) => (\n          <tr key={index}>\n            {row.map((cell, cellIndex) =>\n              cellIndex === 0 ? (\n                <th key={cellIndex} scope=\"row\">\n                  {cell}\n                </th>\n              ) : (\n                <td key={cellIndex}>{cell}</td>\n              ),\n            )}\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n\nexport function Stat({ ready, children }: { ready: boolean; children: React.ReactNode }) {\n  if (ready) return <>{children}</>;\n  return (\n    <span aria-hidden className=\"text-neutral-300 dark:text-neutral-600\">\n      —\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/charts/chart-engine.tsx"
    },
    {
      "path": "app/registry/charts/cohort-chart.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport {\n  type ChartStatus,\n  ChartDataTable,\n  ChartState,\n  Stat,\n  Keyframes,\n  formatCount,\n  formatPct,\n  intensityColor,\n  mulberry32,\n  onFillClass,\n  seriesVarsClassName,\n  useElementWidth,\n  usePrefersReducedMotion,\n} from './chart-engine';\n\nexport type Cohort = {\n  label: string;\n  size: number;\n  retention: number[];\n};\n\nconst MONTHS = [\n  'Sep 2025', 'Oct 2025', 'Nov 2025', 'Dec 2025', 'Jan 2026', 'Feb 2026',\n  'Mar 2026', 'Apr 2026', 'May 2026', 'Jun 2026', 'Jul 2026', 'Aug 2026',\n];\n\nfunction generateCohorts(seed: number, periods = 12): Cohort[] {\n  const rand = mulberry32(seed);\n  return MONTHS.map((label, index) => {\n    const observed = periods - index;\n    const size = Math.round(420 + rand() * 900 + index * 70);\n    const retention: number[] = [100];\n    const quality = 1 + index * 0.012;\n    let value = 100;\n    for (let p = 1; p < observed; p += 1) {\n      const decay = p === 1 ? 0.52 + rand() * 0.12 : 0.9 + rand() * 0.07;\n      value = Math.max(2, value * Math.min(0.995, decay * quality));\n      retention.push(Math.round(value * 10) / 10);\n    }\n    return { label, size, retention };\n  });\n}\n\nexport const COHORTS = generateCohorts(90_210);\n\nexport interface CohortChartProps {\n  className?: string;\n  data?: Cohort[];\n  period?: string;\n  label?: string;\n  hue?: string;\n  status?: ChartStatus;\n  onRetry?: () => void;\n}\n\nexport function CohortChart({\n  className,\n  data = COHORTS,\n  period = 'Month',\n  label = 'Signup cohort retention',\n  hue = 'var(--spectrum-series-1)',\n  status = 'ready',\n  onRetry,\n}: CohortChartProps) {\n  const reduce = usePrefersReducedMotion();\n  const [wrapRef, width] = useElementWidth<HTMLDivElement>();\n  const [hovered, setHovered] = React.useState<{ row: number; col: number } | null>(null);\n\n  const periods = React.useMemo(\n    () => Math.max(1, ...data.map((c) => c.retention.length)),\n    [data],\n  );\n\n  const averages = React.useMemo(() => {\n    const out: (number | null)[] = [];\n    for (let p = 0; p < periods; p += 1) {\n      const seen = data.map((c) => c.retention[p]).filter((v): v is number => v != null);\n      out.push(seen.length ? seen.reduce((a, b) => a + b, 0) / seen.length : null);\n    }\n    return out;\n  }, [data, periods]);\n\n  const LABEL_W = 92;\n  const SIZE_W = 54;\n  const HEAD_H = 26;\n  const ROW_H = 30;\n  const GAP = 2;\n\n  const available = Math.max(160, width - LABEL_W - SIZE_W);\n  const colW = Math.max(34, Math.floor(available / periods));\n  const w = LABEL_W + SIZE_W + periods * colW;\n  const h = HEAD_H + (data.length + 1) * ROW_H + 8;\n\n  const ready = width > 0;\n  const totalUsers = data.reduce((sum, c) => sum + c.size, 0);\n\n  const cellX = (col: number) => LABEL_W + SIZE_W + col * colW;\n\n  return (\n    <div\n      ref={wrapRef}\n      className={cn('flex w-full flex-col text-neutral-400 dark:text-neutral-500', seriesVarsClassName, className)}\n    >\n      <Keyframes />\n\n      <div className=\"mb-3 flex flex-wrap items-end justify-between gap-x-4 gap-y-2\">\n        <div>\n          <p className=\"font-mono text-[12px] font-medium tracking-wide text-neutral-950 dark:text-white\">\n            {label}\n          </p>\n          <p className=\"mt-0.5 font-mono text-[11px] tabular-nums text-neutral-500 dark:text-neutral-400\">\n            <Stat ready={status === 'ready'}>\n              {data.length} cohorts · {formatCount(totalUsers)} users\n            </Stat>\n          </p>\n        </div>\n        <p className=\"font-mono text-[11px] tabular-nums text-neutral-500 dark:text-neutral-400\">\n          <Stat ready={status === 'ready'}>\n            {period} 1 avg{' '}\n            <span className=\"font-medium text-neutral-950 dark:text-white\">\n              {averages[1] == null ? '—' : formatPct(averages[1])}\n            </span>\n          </Stat>\n        </p>\n      </div>\n\n      <ChartState\n        status={status}\n        height={h}\n        variant=\"grid\"\n        empty={{ title: 'No cohorts yet', description: 'Cohorts appear once the first signups complete a period.' }}\n        onRetry={onRetry}\n      >\n      <div className=\"relative w-full overflow-x-auto\">\n        {!ready ? null : (\n          <svg\n            width={w}\n            height={h}\n            viewBox={`0 0 ${w} ${h}`}\n            className=\"block select-none\"\n            role=\"img\"\n            aria-label={`${label}. ${data.length} cohorts over ${periods} periods. ${period} 1 average ${averages[1] == null ? 'unavailable' : formatPct(averages[1])}.`}\n            onPointerLeave={() => setHovered(null)}\n          >\n            <g className=\"font-mono uppercase tracking-wider\" fontSize={9} fill=\"currentColor\">\n              <text x={0} y={HEAD_H - 10}>Cohort</text>\n              <text x={LABEL_W} y={HEAD_H - 10}>Users</text>\n              {Array.from({ length: periods }, (_, col) => (\n                <text\n                  key={col}\n                  x={cellX(col) + colW / 2}\n                  y={HEAD_H - 10}\n                  textAnchor=\"middle\"\n                  opacity={hovered && hovered.col === col ? 1 : 0.7}\n                >\n                  {col}\n                </text>\n              ))}\n            </g>\n\n            {data.map((cohort, row) => {\n              const y = HEAD_H + row * ROW_H;\n              const rowActive = hovered?.row === row;\n              return (\n                <g key={cohort.label}>\n                  <text\n                    x={0}\n                    y={y + ROW_H / 2}\n                    dominantBaseline=\"middle\"\n                    fontSize={11}\n                    className={cn(\n                      'font-mono tabular-nums',\n                      rowActive\n                        ? 'fill-neutral-950 dark:fill-white'\n                        : 'fill-neutral-500 dark:fill-neutral-400',\n                    )}\n                  >\n                    {cohort.label}\n                  </text>\n                  <text\n                    x={LABEL_W}\n                    y={y + ROW_H / 2}\n                    dominantBaseline=\"middle\"\n                    fontSize={11}\n                    className=\"fill-neutral-400 font-mono tabular-nums dark:fill-neutral-500\"\n                  >\n                    {formatCount(cohort.size)}\n                  </text>\n\n                  {cohort.retention.map((value, col) => {\n                    const intensity = value / 100;\n                    const active = hovered?.row === row && hovered?.col === col;\n                    const inCross = hovered != null && (hovered.row === row || hovered.col === col);\n                    return (\n                      <g\n                        key={col}\n                        onPointerEnter={() => setHovered({ row, col })}\n                        style={{\n                          animation: reduce\n                            ? undefined\n                            : `spectrum-mc-fade 300ms ease-out ${Math.min(col * 22 + row * 12, 520)}ms both`,\n                        }}\n                      >\n                        <rect\n                          x={cellX(col) + GAP / 2}\n                          y={y + GAP / 2}\n                          width={colW - GAP}\n                          height={ROW_H - GAP}\n                          rx={4}\n                          fill={intensityColor(intensity, hue)}\n                          stroke={active ? 'currentColor' : 'transparent'}\n                          strokeWidth={1.5}\n                          className=\"text-neutral-900 dark:text-white\"\n                          style={{\n                            opacity: hovered && !inCross ? 0.4 : 1,\n                            transition: reduce ? undefined : 'opacity 140ms ease-out',\n                          }}\n                        />\n                        <text\n                          x={cellX(col) + colW / 2}\n                          y={y + ROW_H / 2}\n                          textAnchor=\"middle\"\n                          dominantBaseline=\"middle\"\n                          fontSize={10}\n                          fontWeight={500}\n                          className={cn('pointer-events-none font-mono tabular-nums', onFillClass(intensity))}\n                          style={{\n                            opacity: hovered && !inCross ? 0.45 : 1,\n                            transition: reduce ? undefined : 'opacity 140ms ease-out',\n                          }}\n                        >\n                          {colW >= 40 ? value.toFixed(0) : ''}\n                        </text>\n                      </g>\n                    );\n                  })}\n                </g>\n              );\n            })}\n\n            <g>\n              <line\n                x1={0}\n                x2={w}\n                y1={HEAD_H + data.length * ROW_H + 2}\n                y2={HEAD_H + data.length * ROW_H + 2}\n                stroke=\"currentColor\"\n                strokeOpacity={0.16}\n                shapeRendering=\"crispEdges\"\n              />\n              <text\n                x={0}\n                y={HEAD_H + data.length * ROW_H + ROW_H / 2 + 4}\n                dominantBaseline=\"middle\"\n                fontSize={11}\n                fontWeight={600}\n                className=\"fill-neutral-950 font-mono dark:fill-white\"\n              >\n                Average\n              </text>\n              {averages.map((value, col) =>\n                value == null ? null : (\n                  <text\n                    key={col}\n                    x={cellX(col) + colW / 2}\n                    y={HEAD_H + data.length * ROW_H + ROW_H / 2 + 4}\n                    textAnchor=\"middle\"\n                    dominantBaseline=\"middle\"\n                    fontSize={10}\n                    fontWeight={600}\n                    className=\"fill-neutral-700 font-mono tabular-nums dark:fill-neutral-200\"\n                    opacity={hovered && hovered.col !== col ? 0.45 : 1}\n                  >\n                    {colW >= 40 ? value.toFixed(0) : ''}\n                  </text>\n                ),\n              )}\n            </g>\n          </svg>\n        )}\n      </div>\n      </ChartState>\n\n      <ChartDataTable\n        caption={`${label} — retention by cohort and ${period.toLowerCase()}`}\n        columns={['Cohort', 'Users', ...Array.from({ length: periods }, (_, i) => `${period} ${i}`)]}\n        rows={data.map((c) => [\n          c.label,\n          c.size,\n          ...Array.from({ length: periods }, (_, i) =>\n            c.retention[i] == null ? '—' : formatPct(c.retention[i]),\n          ),\n        ])}\n      />\n      <p\n        className=\"mt-2 h-4 font-mono text-[11px] tabular-nums text-neutral-600 transition-opacity duration-150 dark:text-neutral-300\"\n        style={{ opacity: hovered ? 1 : 0 }}\n        aria-live=\"polite\"\n      >\n        {hovered && data[hovered.row]?.retention[hovered.col] != null\n          ? `${data[hovered.row].label} · ${period} ${hovered.col} · ${formatPct(\n              data[hovered.row].retention[hovered.col],\n            )} of ${formatCount(data[hovered.row].size)} retained`\n          : ' '}\n      </p>\n    </div>\n  );\n}\n\nexport function DefaultCohortChart(props: CohortChartProps) {\n  return <CohortChart {...props} />;\n}\n\nexport function WeeklyCohortChart(props: CohortChartProps) {\n  return (\n    <CohortChart\n      data={generateCohorts(31_337)}\n      period=\"Week\"\n      label=\"Weekly activation cohorts\"\n      hue=\"var(--spectrum-series-3)\"\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/charts/cohort-chart.tsx"
    },
    {
      "path": "app/registry/charts/chart-states.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport {\n  ChartEmpty,\n  ChartError,\n  ChartSkeleton,\n  type ChartStatus,\n  ChartState,\n  type SkeletonVariant,\n  seriesVarsClassName,\n} from './chart-engine';\nimport { CohortChart } from './cohort-chart';\n\nconst frame =\n  'rounded-xl border border-black/8 bg-white/60 p-4 dark:border-white/10 dark:bg-white/[0.02]';\n\nfunction Panel({\n  title,\n  hint,\n  children,\n  className,\n}: {\n  title: string;\n  hint?: string;\n  children: React.ReactNode;\n  className?: string;\n}) {\n  return (\n    <div className={cn(frame, className)}>\n      <div className=\"mb-3 flex items-baseline justify-between gap-3\">\n        <p className=\"font-mono text-[11px] font-medium uppercase tracking-wider text-neutral-950 dark:text-white\">\n          {title}\n        </p>\n        {hint ? (\n          <p className=\"font-mono text-[10.5px] text-neutral-400 dark:text-neutral-500\">{hint}</p>\n        ) : null}\n      </div>\n      {children}\n    </div>\n  );\n}\n\nconst VARIANTS: { variant: SkeletonVariant; label: string; use: string }[] = [\n  { variant: 'bars', label: 'bars', use: 'bar, candle, volume, histogram' },\n  { variant: 'line', label: 'line', use: 'line, area, price, depth' },\n  { variant: 'grid', label: 'grid', use: 'calendar, cohort, heatmap' },\n  { variant: 'rows', label: 'rows', use: 'order book, cohort, tables' },\n  { variant: 'arc', label: 'arc', use: 'radial, progress' },\n  { variant: 'cards', label: 'cards', use: 'stat cards, KPI tiles' },\n];\n\nexport function ChartSkeletonGallery({ className }: { className?: string }) {\n  return (\n    <div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', seriesVarsClassName, className)}>\n      {VARIANTS.map(({ variant, label, use }) => (\n        <Panel key={variant} title={`variant=\"${label}\"`} hint={use}>\n          <ChartSkeleton variant={variant} height={150} />\n        </Panel>\n      ))}\n    </div>\n  );\n}\n\nexport function ChartEmptyAndError({ className }: { className?: string }) {\n  const [reloading, setReloading] = React.useState(false);\n\n  React.useEffect(() => {\n    if (!reloading) return;\n    const id = window.setTimeout(() => setReloading(false), 1600);\n    return () => window.clearTimeout(id);\n  }, [reloading]);\n\n  return (\n    <div className={cn('grid gap-3 lg:grid-cols-2', seriesVarsClassName, className)}>\n      <Panel title=\"empty\" hint=\"no data, nothing broken\">\n        <ChartEmpty\n          height={200}\n          title=\"No events in this range\"\n          description=\"Widen the date range, or send your first event to start populating this chart.\"\n        />\n      </Panel>\n      <Panel title=\"error\" hint=\"retry is part of the state\">\n        {reloading ? (\n          <ChartSkeleton variant=\"bars\" height={200} />\n        ) : (\n          <ChartError\n            height={200}\n            title=\"Could not load analytics\"\n            description=\"The metrics API returned a 503. This is usually transient.\"\n            onRetry={() => setReloading(true)}\n          />\n        )}\n      </Panel>\n    </div>\n  );\n}\n\nconst CYCLE: ChartStatus[] = ['loading', 'ready', 'empty', 'error'];\n\nexport function ChartStatusSwitcher({ className }: { className?: string }) {\n  const [status, setStatus] = React.useState<ChartStatus>('loading');\n\n  return (\n    <div className={cn('w-full', seriesVarsClassName, className)}>\n      <div\n        role=\"tablist\"\n        aria-label=\"Chart status\"\n        className=\"mb-3 inline-flex items-center gap-0.5 rounded-full bg-black/[0.045] p-0.5 dark:bg-white/[0.07]\"\n      >\n        {CYCLE.map((option) => {\n          const active = option === status;\n          return (\n            <button\n              key={option}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={active}\n              onClick={() => setStatus(option)}\n              className={cn(\n                'rounded-full px-3 py-1 font-mono text-[11px] leading-none transition-colors',\n                'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black/20 dark:focus-visible:ring-white/25',\n                active\n                  ? 'bg-white text-neutral-950 shadow-sm dark:bg-white/12 dark:text-white'\n                  : 'text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200',\n              )}\n            >\n              {option}\n            </button>\n          );\n        })}\n      </div>\n\n      <CohortChart status={status} onRetry={() => setStatus('loading')} />\n    </div>\n  );\n}\n\nexport function ChartStateUsage({ className }: { className?: string }) {\n  const [status, setStatus] = React.useState<ChartStatus>('empty');\n\n  return (\n    <div className={cn('w-full', seriesVarsClassName, className)}>\n      <Panel title=\"ChartState\" hint=\"wrap anything\">\n        <ChartState\n          status={status}\n          height={190}\n          variant=\"line\"\n          empty={{\n            title: 'Nothing tracked yet',\n            description: 'Install the SDK and your first session will show up here.',\n            action: (\n              <button\n                type=\"button\"\n                onClick={() => setStatus('ready')}\n                className=\"rounded-full bg-neutral-900 px-3 py-1.5 text-[12px] font-medium text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-neutral-950\"\n              >\n                Load sample data\n              </button>\n            ),\n          }}\n          onRetry={() => setStatus('ready')}\n        >\n          <div className=\"flex h-[190px] flex-col items-center justify-center gap-2 rounded-xl bg-black/[0.02] dark:bg-white/[0.03]\">\n            <p className=\"font-mono text-[12px] text-neutral-500 dark:text-neutral-400\">\n              your chart renders here\n            </p>\n            <button\n              type=\"button\"\n              onClick={() => setStatus('empty')}\n              className=\"font-mono text-[11px] text-neutral-400 underline underline-offset-2 hover:text-neutral-700 dark:hover:text-neutral-200\"\n            >\n              reset to empty\n            </button>\n          </div>\n        </ChartState>\n      </Panel>\n    </div>\n  );\n}\n\nexport function DefaultChartStates(props: { className?: string }) {\n  return <ChartStatusSwitcher {...props} />;\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/charts/chart-states.tsx"
    }
  ],
  "type": "registry:component"
}
