{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-kit",
  "title": "Chart kit",
  "description": "The shared parts of the Tiles charts: number formatting with a muted unit, nice axis ticks, a smooth line, one tooltip, the big display figure, a change pill, a pill switch and a table for screen readers.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/chart-kit/chart-kit.tsx",
      "content": "// The shared grammar for the Tiles data primitives (bar-chart, donut, dial, area-chart and the rest): a size and\n// draw-in hook, one tooltip anatomy, the big display number, a change pill, a pill switch and a hidden data table\n// for screen readers. Formatting, scales and colours live in chart-utils.ts.\n// There is no chart library. Each primitive draws its own SVG with these pieces, so the axes, tooltips and numbers\n// look the same everywhere. Series colours come from --chart-1 … --chart-5 and the signal from --primary.\nimport { AnimatePresence, motion } from 'motion/react'\nimport { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\nimport { EASE, formatValue, stepIndex, valueParts, type ValueFormat } from './chart-utils'\n\n// Tooltip -------------------------------------------------------------------------------------------------------\n\nexport type TooltipRow = { label: string; value: string; color?: string }\n\nexport type ChartTooltipProps = {\n  open: boolean\n  /** Where the tooltip points, in px inside the chart's positioned wrapper. */\n  x: number\n  y: number\n  /** The wrapper's width, so the tooltip stays inside it. */\n  bounds: number\n  /** Above the point by default; below when the point sits near the top. */\n  below?: boolean\n  /** The small label on top, such as the date. */\n  title: string\n  /** The figure itself. */\n  value: ReactNode\n  rows?: TooltipRow[]\n}\n\n/**\n * One tooltip anatomy for every chart: a dark chip with a mono label, the figure and optional rows with a colour\n * key. It is hidden from screen readers; the chart says the same through its live region. Moves glide only while\n * it is already open, so it never flies in from a corner.\n */\nexport function ChartTooltip({ open, x, y, bounds, below = false, title, value, rows = [] }: ChartTooltipProps) {\n  const box = useRef<HTMLDivElement>(null)\n  const [size, setSize] = useState({ w: 0, h: 0 })\n  const [gliding, setGliding] = useState(false)\n\n  // Resize notes arrive after layout and before paint, so a new size is in place before the frame is drawn.\n  useEffect(() => {\n    const el = box.current\n    if (!el) return\n    const ro = new ResizeObserver(() => setSize({ w: el.offsetWidth, h: el.offsetHeight }))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n  // The first frame after opening snaps into place; moves after that glide.\n  useEffect(() => {\n    if (!open) return\n    const id = requestAnimationFrame(() => setGliding(true))\n    return () => {\n      cancelAnimationFrame(id)\n      setGliding(false)\n    }\n  }, [open])\n  const { w, h } = size\n\n  const tx = Math.max(-6, Math.min(bounds - w + 6, x - w / 2))\n  const ty = below ? y + 12 : y - h - 12\n  const origin = `${Math.max(10, Math.min(w - 10, x - tx))}px ${below ? '0%' : '100%'}`\n\n  return (\n    <div\n      ref={box}\n      aria-hidden\n      className={cn(\n        'pointer-events-none absolute top-0 left-0 z-30 w-max max-w-60 rounded-[calc(var(--radius)+2px)] bg-foreground px-3 pt-2 pb-2.5 text-card',\n        'shadow-[0_1px_2px_rgba(0,0,0,0.14),0_10px_24px_-10px_rgba(0,0,0,0.4)] ease-out-quint motion-reduce:transition-none',\n        gliding ? 'transition-[opacity,transform] duration-150' : 'transition-opacity duration-150',\n      )}\n      style={{\n        opacity: open ? 1 : 0,\n        transform: `translate(${tx}px, ${ty}px) scale(${open ? 1 : 0.96})`,\n        transformOrigin: origin,\n      }}\n    >\n      <span className=\"block font-mono text-[11px] leading-tight tracking-[0.02em] text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]\">\n        {title}\n      </span>\n      <span className=\"mt-1 block text-[17px] leading-[1.1] font-semibold tracking-[-0.015em] tabular-nums\">{value}</span>\n      {rows.map((r) => (\n        <span\n          key={r.label}\n          className=\"mt-1.5 flex items-center gap-2 text-[12.5px] leading-[1.3] text-[color-mix(in_oklab,var(--card)_82%,var(--foreground))] tabular-nums\"\n        >\n          {r.color && <i className=\"size-2.5 flex-none rounded-[3px]\" style={{ background: r.color }} />}\n          {r.label}\n          <b className=\"ml-auto pl-3 font-medium text-card\">{r.value}</b>\n        </span>\n      ))}\n    </div>\n  )\n}\n\n// The display number --------------------------------------------------------------------------------------------\n\nexport type BigNumberProps = {\n  value: number\n  format?: ValueFormat\n  /** xl for the one figure a tile is about, sm for a figure inside a chart. Default \"lg\". */\n  size?: 'xl' | 'lg' | 'md' | 'sm'\n  /** Roll the digits that change. Default true; never under reduced motion. */\n  roll?: boolean\n  className?: string\n}\n\nconst SIZES = {\n  xl: 'text-[clamp(50px,5.2vw,76px)]',\n  lg: 'text-[clamp(36px,3.4vw,48px)]',\n  md: 'text-[30px]',\n  sm: 'text-[22px]',\n}\n\n/**\n * The big figure: heavy, tight and tabular, with the currency symbol and the decimals set small and muted. It uses\n * --font-display when the app defines one (Archivo at width 86 % is what it was drawn with) and the body face\n * otherwise. Screen readers get the whole value once.\n */\nexport function BigNumber({ value, format, size = 'lg', roll = true, className }: BigNumberProps) {\n  const p = valueParts(value, format)\n  const small = 'text-[0.46em] font-bold tracking-[-0.02em] text-muted-foreground'\n  const unit = p.unit && (\n    <span className={cn(small, p.unitAfter ? 'ml-[0.12em]' : 'mr-[0.12em] self-start pt-[0.12em]')}>{p.unit}</span>\n  )\n  return (\n    <span\n      className={cn(\n        'inline-flex items-baseline leading-[0.95] font-extrabold tracking-[-0.045em] whitespace-nowrap tabular-nums',\n        SIZES[size],\n        className,\n      )}\n      style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}\n    >\n      <span className=\"sr-only\">{formatValue(value, format)}</span>\n      <span aria-hidden className=\"inline-flex items-baseline\">\n        {p.sign}\n        {!p.unitAfter && unit}\n        <Digits text={p.whole} roll={roll} />\n        {p.fraction && (\n          <span className={small}>\n            <Digits text={p.fraction} roll={roll} />\n          </span>\n        )}\n        {p.unitAfter && unit}\n        {p.suffix && <span className={cn(small, 'ml-[0.06em]')}>{p.suffix}</span>}\n      </span>\n    </span>\n  )\n}\n\n/** Digits that roll up when they grow and down when they shrink, counted from the right so places stay put. */\nexport function Digits({ text, roll = true }: { text: string; roll?: boolean }) {\n  const reduced = useReducedMotion()\n  const [last, setLast] = useState({ text, dir: 1 })\n  const dir = last.text === text ? last.dir : Number(text.replace(/\\D/g, '')) >= Number(last.text.replace(/\\D/g, '')) ? 1 : -1\n  if (last.text !== text) setLast({ text, dir })\n  if (!roll || reduced) return <>{text}</>\n  const chars = text.split('')\n  return (\n    <span className=\"inline-flex\">\n      {chars.map((ch, i) => {\n        const place = chars.length - i\n        return (\n          <span key={place} className=\"relative -my-[0.08em] inline-flex overflow-hidden py-[0.08em]\">\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={dir}>\n              <motion.span\n                key={ch}\n                initial={{ y: `${dir * 60}%`, opacity: 0, filter: 'blur(2px)' }}\n                animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}\n                exit={{ y: `${dir * -60}%`, opacity: 0, filter: 'blur(2px)' }}\n                transition={{ duration: 0.3, ease: EASE, delay: Math.min(place, 6) * 0.015 }}\n                className=\"inline-block\"\n              >\n                {ch}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        )\n      })}\n    </span>\n  )\n}\n\n/** A change as a small pill with an arrow. Green when it went the good way; set `goodWhen` to \"down\" for costs. The arrow carries the direction, so the figure never shows a sign. */\nexport function DeltaPill({\n  value,\n  format = { decimals: 1, suffix: '%' },\n  goodWhen = 'up',\n  className,\n}: {\n  value: number\n  format?: ValueFormat\n  goodWhen?: 'up' | 'down'\n  className?: string\n}) {\n  const up = value > 0\n  const flat = Number(Math.abs(value).toFixed(format.decimals ?? 0)) === 0\n  const good = flat ? null : up === (goodWhen === 'up')\n  const tone = good === null ? null : good ? 'var(--success)' : 'var(--destructive)'\n  return (\n    <span\n      className={cn(\n        'inline-flex h-[26px] items-center gap-1 rounded-full pr-2.5 pl-2 text-[12.5px] leading-none font-medium whitespace-nowrap tabular-nums',\n        className,\n      )}\n      style={\n        tone\n          ? {\n              background: `color-mix(in oklab, ${tone} ${good ? 15 : 13}%, var(--card))`,\n              color: `color-mix(in oklab, ${tone} ${good ? 80 : 85}%, var(--foreground))`,\n            }\n          : { background: 'var(--muted)', color: 'var(--muted-foreground)' }\n      }\n    >\n      {!flat && (\n        <svg\n          viewBox=\"0 0 12 12\"\n          className=\"size-3 fill-none stroke-current stroke-2 [stroke-linecap:round] [stroke-linejoin:round]\"\n          aria-hidden\n        >\n          <path d={up ? 'M6 10V2M2.5 5.5 6 2l3.5 3.5' : 'M6 2v8M2.5 6.5 6 10l3.5-3.5'} />\n        </svg>\n      )}\n      {formatValue(Math.abs(value), { ...format, sign: false })}\n      <span className=\"sr-only\">{flat ? ', unchanged' : up ? ', up' : ', down'}</span>\n    </span>\n  )\n}\n\n// Controls ------------------------------------------------------------------------------------------------------\n\nexport type PillOption = { id: string; label: string }\n\n/** A segment switch: a dark pill slides to the picked option. A radio group with arrow keys, Home and End. */\nexport function Pills({\n  options,\n  value,\n  onChange,\n  label,\n  className,\n}: {\n  options: PillOption[]\n  value: string\n  onChange: (id: string) => void\n  /** Read aloud for the group, e.g. \"Period\". */\n  label: string\n  className?: string\n}) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const index = Math.max(0, options.findIndex((o) => o.id === value))\n  const onKey = (e: KeyboardEvent, i: number) => {\n    // Radio groups take both axes: down and right go forward, up and left go back.\n    const key = e.key === 'ArrowDown' ? 'ArrowRight' : e.key === 'ArrowUp' ? 'ArrowLeft' : e.key\n    const to = stepIndex(key, i, options.length, { loop: true })\n    if (to === null) return\n    e.preventDefault()\n    refs.current[to]?.focus()\n    onChange(options[to].id)\n  }\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      className={cn('relative isolate inline-grid auto-cols-fr grid-flow-col rounded-full bg-muted p-1', className)}\n    >\n      <span\n        aria-hidden\n        className=\"absolute top-1 bottom-1 left-1 -z-10 rounded-full bg-foreground transition-transform duration-[260ms] ease-out-quint motion-reduce:transition-none\"\n        style={{ width: `calc((100% - 8px) / ${options.length})`, transform: `translateX(${index * 100}%)` }}\n      />\n      {options.map((o, i) => (\n        <button\n          key={o.id}\n          ref={(el) => {\n            refs.current[i] = el\n          }}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={i === index}\n          tabIndex={i === index ? 0 : -1}\n          onClick={() => onChange(o.id)}\n          onKeyDown={(e) => onKey(e, i)}\n          className={cn(\n            'relative min-h-9 cursor-pointer rounded-full px-3.5 text-[13.5px] font-medium whitespace-nowrap focus-visible:outline-offset-0',\n            // The visible pill is 36 px; the hit area reaches the 44 px of the track.\n            'before:absolute before:inset-x-0 before:-inset-y-1 before:content-[\"\"]',\n            i === index ? 'text-card' : 'text-muted-foreground hover:text-foreground',\n          )}\n        >\n          {o.label}\n        </button>\n      ))}\n    </div>\n  )\n}\n\n// Screen readers ------------------------------------------------------------------------------------------------\n\n/** The chart's data as a table only screen readers see. The first column is read as the row heading. */\nexport function SrTable({ caption, head, rows }: { caption: string; head: string[]; rows: (string | number)[][] }) {\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((r, i) => (\n            <tr key={i}>\n              {r.map((c, j) =>\n                j ? (\n                  <td key={j}>{c}</td>\n                ) : (\n                  <th key={j} scope=\"row\">\n                    {c}\n                  </th>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/chart-kit/chart-utils.ts",
      "content": "// The pure half of the chart kit: colours, number formatting, nice axis ticks and the smooth line. No React here,\n// so it can also run on a server or in a test.\n\nexport const EASE = [0.23, 1, 0.32, 1] as const\nexport const EASE_CSS = 'cubic-bezier(0.23, 1, 0.32, 1)'\n/** A real minus sign, which lines up with the plus in tabular figures. */\nexport const MINUS = '−'\n\n/** The five series colours in their fixed order. */\nexport const SERIES = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']\nexport const seriesColor = (i: number) => SERIES[((i % SERIES.length) + SERIES.length) % SERIES.length]\n\n// Surface colours, mixed where they are used so they follow an inverted tile. One series is drawn in INK; the signal\n// (today, the latest point, the target) is the only thing in --primary.\n/** The main mark: bars, the line, the needle. */\nexport const INK = 'color-mix(in oklab, var(--foreground) 86%, var(--card))'\n/** The comparison period, behind the main mark. */\nexport const INK_GHOST = 'color-mix(in oklab, var(--foreground) 17%, var(--card))'\n/** Dashed gridlines. */\nexport const GRID = 'color-mix(in oklab, var(--foreground) 11%, var(--card))'\n/** The baseline and dashed comparison lines. */\nexport const BASE = 'color-mix(in oklab, var(--foreground) 30%, var(--card))'\n/** A recessed well, such as an empty track. */\nexport const WELL = 'color-mix(in oklab, var(--foreground) 5%, var(--card))'\n\n// Numbers -------------------------------------------------------------------------------------------------------\n\nexport type ValueFormat = {\n  /** Passed to Intl.NumberFormat. Default \"en-GB\". */\n  locale?: string\n  /** ISO code such as \"EUR\" or \"DKK\". Leave it out for a plain number. */\n  currency?: string\n  /** Digits after the decimal mark. Default 0. */\n  decimals?: number\n  /** Show a plus on positive values, as for changes. */\n  sign?: boolean\n  /** A unit set after the number, such as \"%\" or \" orders\". */\n  suffix?: string\n}\n\nexport type ValueParts = {\n  sign: '' | '+' | typeof MINUS\n  /** The currency symbol, or an empty string. */\n  unit: string\n  /** True when the locale puts the symbol after the number, as \"1.234 kr.\" does. */\n  unitAfter: boolean\n  /** The whole part with its group separators. */\n  whole: string\n  /** The decimal mark and the decimals, or an empty string. */\n  fraction: string\n  suffix: string\n}\n\nconst formatters = new Map<string, Intl.NumberFormat>()\nfunction formatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let f = formatters.get(key)\n  if (!f) {\n    f = new Intl.NumberFormat(locale, options)\n    formatters.set(key, f)\n  }\n  return f\n}\n\n/** Splits a value into the pieces the display number sets in different sizes and colours. */\nexport function valueParts(value: number, format: ValueFormat = {}): ValueParts {\n  const { locale = 'en-GB', currency, decimals = 0, sign = false, suffix = '' } = format\n  const rounded = Number(Math.abs(value).toFixed(decimals))\n  const parts = formatter(locale, {\n    style: currency ? 'currency' : 'decimal',\n    currency,\n    currencyDisplay: 'narrowSymbol',\n    minimumFractionDigits: decimals,\n    maximumFractionDigits: decimals,\n  }).formatToParts(rounded)\n  let unit = ''\n  let whole = ''\n  let fraction = ''\n  let seenNumber = false\n  let unitAfter = false\n  for (const p of parts) {\n    if (p.type === 'currency') {\n      unit = p.value\n      unitAfter = seenNumber\n    } else if (p.type === 'integer' || p.type === 'group') {\n      whole += p.value\n      seenNumber = true\n    } else if (p.type === 'decimal' || p.type === 'fraction') fraction += p.value\n  }\n  const s = rounded === 0 ? '' : value < 0 ? MINUS : sign ? '+' : ''\n  return { sign: s, unit, unitAfter, whole, fraction, suffix }\n}\n\n/** The value as one string, for tooltips, tables and screen readers. */\nexport function formatValue(value: number, format: ValueFormat = {}) {\n  const p = valueParts(value, format)\n  const space = p.unitAfter ? ' ' : ''\n  return p.unitAfter\n    ? `${p.sign}${p.whole}${p.fraction}${space}${p.unit}${p.suffix}`\n    : `${p.sign}${p.unit}${p.whole}${p.fraction}${p.suffix}`\n}\n\n/** A short value for axis ticks: \"€1.2k\", \"40\". */\nexport function formatTick(value: number, format: ValueFormat = {}) {\n  const { locale = 'en-GB', currency } = format\n  return formatter(locale, {\n    style: currency ? 'currency' : 'decimal',\n    currency,\n    currencyDisplay: 'narrowSymbol',\n    notation: Math.abs(value) >= 10000 ? 'compact' : 'standard',\n    maximumFractionDigits: Math.abs(value) >= 10000 ? 1 : 0,\n  }).format(value)\n}\n\n// Scales --------------------------------------------------------------------------------------------------------\n\n/** Round axis bounds and ticks that cover min…max in about `count` steps of 1, 2, 2.5 or 5 × 10ⁿ. */\nexport function niceScale(max: number, count = 4, min = 0) {\n  const span = Math.max(max - min, Number.EPSILON)\n  const raw = span / Math.max(1, count)\n  const mag = 10 ** Math.floor(Math.log10(raw))\n  const step = ([1, 2, 2.5, 5, 10].find((m) => m * mag >= raw) ?? 10) * mag\n  const lo = Math.floor(min / step) * step\n  const hi = Math.ceil(max / step) * step\n  const ticks: number[] = []\n  for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toFixed(10)))\n  return { min: lo, max: hi, step, ticks }\n}\n\n/** A linear map from a domain to a range. */\nexport function linear(d0: number, d1: number, r0: number, r1: number) {\n  const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0)\n  return (v: number) => r0 + (v - d0) * k\n}\n\n/** A smooth line through points that never overshoots them (monotone cubic, Fritsch–Carlson). */\nexport function smoothPath(pts: [number, number][]) {\n  const n = pts.length\n  if (n === 0) return ''\n  if (n < 3) return pts.map(([x, y], i) => `${i ? 'L' : 'M'}${r2(x)} ${r2(y)}`).join('')\n  const dx: number[] = []\n  const m: number[] = []\n  for (let i = 0; i < n - 1; i++) {\n    dx.push(pts[i + 1][0] - pts[i][0])\n    m.push((pts[i + 1][1] - pts[i][1]) / (dx[i] || 1))\n  }\n  const t = [m[0], ...m.slice(1).map((s, i) => (s * m[i] <= 0 ? 0 : (s + m[i]) / 2)), m[n - 2]]\n  for (let i = 0; i < n - 1; i++) {\n    if (m[i] === 0) {\n      t[i] = 0\n      t[i + 1] = 0\n      continue\n    }\n    const a = t[i] / m[i]\n    const b = t[i + 1] / m[i]\n    const h = a * a + b * b\n    if (h > 9) {\n      const s = 3 / Math.sqrt(h)\n      t[i] = s * a * m[i]\n      t[i + 1] = s * b * m[i]\n    }\n  }\n  let d = `M${r2(pts[0][0])} ${r2(pts[0][1])}`\n  for (let i = 0; i < n - 1; i++) {\n    const [x0, y0] = pts[i]\n    const [x1, y1] = pts[i + 1]\n    const h = dx[i] / 3\n    d += `C${r2(x0 + h)} ${r2(y0 + t[i] * h)} ${r2(x1 - h)} ${r2(y1 - t[i + 1] * h)} ${r2(x1)} ${r2(y1)}`\n  }\n  return d\n}\n\nconst r2 = (v: number) => Math.round(v * 100) / 100\n\n/** The next index for an arrow, Home or End key, or null for any other key. */\nexport function stepIndex(key: string, index: number, count: number, { loop = false, vertical = false } = {}) {\n  const next = vertical ? 'ArrowDown' : 'ArrowRight'\n  const prev = vertical ? 'ArrowUp' : 'ArrowLeft'\n  if (key === 'Home') return 0\n  if (key === 'End') return count - 1\n  if (key === next) return loop ? (index + 1) % count : Math.min(count - 1, index + 1)\n  if (key === prev) return loop ? (index - 1 + count) % count : Math.max(0, index - 1)\n  return null\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/chart-kit/use-chart.tsx",
      "content": "// The chart hooks: size and draw-in, a polite live region, and unique ids for SVG defs.\nimport { useCallback, useEffect, useId, useRef, useState } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\n/**\n * Watches an element's size and whether it has come into view. `drawn` turns true once, the first time a fifth of\n * the chart is visible, and at once under reduced motion. The ref is a callback, so a chart that mounts its frame\n * later (after loading, or when data arrives) is still measured.\n */\nexport function useChartFrame<T extends Element = HTMLDivElement>() {\n  const [node, setNode] = useState<T | null>(null)\n  const [size, setSize] = useState({ width: 0, height: 0 })\n  const [seen, setSeen] = useState(false)\n  const reduced = useReducedMotion()\n\n  useEffect(() => {\n    if (!node) return\n    const ro = new ResizeObserver(([e]) => {\n      const width = Math.round(e.contentRect.width)\n      const height = Math.round(e.contentRect.height)\n      setSize((s) => (s.width === width && s.height === height ? s : { width, height }))\n    })\n    ro.observe(node)\n    return () => ro.disconnect()\n  }, [node])\n\n  useEffect(() => {\n    if (!node || seen) return\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (e.isIntersecting) setSeen(true)\n      },\n      { threshold: 0.2 },\n    )\n    io.observe(node)\n    return () => io.disconnect()\n  }, [node, seen])\n\n  return { ref: setNode, width: size.width, height: size.height, drawn: seen || reduced, reduced }\n}\n\n/** A polite live region and a function that reads a sentence into it. Render `region` once inside the chart. */\nexport function useAnnounce() {\n  const [text, setText] = useState('')\n  const timer = useRef(0)\n  const say = useCallback((t: string) => {\n    // Clear first, so the same sentence twice is read twice.\n    setText('')\n    window.clearTimeout(timer.current)\n    timer.current = window.setTimeout(() => setText(t), 30)\n  }, [])\n  useEffect(() => () => window.clearTimeout(timer.current), [])\n  const region = (\n    <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n      {text}\n    </span>\n  )\n  return { say, region }\n}\n\n/** A stable id prefix for SVG defs (patterns, clip paths) that stays unique per chart. */\nexport function useSvgId(name: string) {\n  return `${name}-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "cssVars": {
    "theme": {
      "color-chart-1": "var(--chart-1)",
      "color-chart-2": "var(--chart-2)",
      "color-chart-3": "var(--chart-3)",
      "color-chart-4": "var(--chart-4)",
      "color-chart-5": "var(--chart-5)",
      "color-success": "var(--success)",
      "ease-out-quint": "cubic-bezier(0.23, 1, 0.32, 1)"
    },
    "light": {
      "chart-1": "oklch(0.53 0.125 215)",
      "chart-2": "oklch(0.75 0.13 78)",
      "chart-3": "oklch(0.45 0.125 345)",
      "chart-4": "oklch(0.7 0.13 145)",
      "chart-5": "oklch(0.62 0.12 295)",
      "success": "oklch(0.55 0.13 155)"
    },
    "dark": {
      "chart-1": "oklch(0.64 0.11 210)",
      "chart-2": "oklch(0.66 0.125 75)",
      "chart-3": "oklch(0.53 0.125 345)",
      "chart-4": "oklch(0.62 0.12 145)",
      "chart-5": "oklch(0.58 0.12 295)",
      "success": "oklch(0.72 0.13 155)"
    }
  },
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}