{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"donut-chart","type":"registry:ui","title":"Donut chart","description":"Parts of a whole as a ring, with the total in the centre.","dependencies":["clsx","d3-shape","tailwind-merge"],"devDependencies":["@types/d3-shape@^3.2.0"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/animated-counter.tsx","type":"registry:ui","target":"components/ui/animated-counter.tsx","content":"\"use client\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { cn } from \"./lib/cn\";\nimport { cssMs } from \"./lib/motion-tokens\";\nimport \"./animated-counter.css\";\nfunction CounterDigit({\n  digit,\n  direction,\n  reveal,\n}: {\n  digit: number;\n  direction: \"up\" | \"down\" | \"none\";\n  reveal: boolean;\n}) {\n  const column = useRef<HTMLSpanElement>(null),\n    first = useRef(true);\n  useLayoutEffect(() => {\n    const element = column.current;\n    if (!element) return;\n    const animate = (initial = false) => {\n      const tokens = getComputedStyle(element),\n        height = element.parentElement!.getBoundingClientRect().height;\n      const transform = tokens.transform;\n      const offset =\n        transform === \"none\"\n          ? 20 + digit\n          : -new DOMMatrixReadOnly(transform).m42 / height;\n      const start = initial ? 20 : 20 + (((offset % 10) + 10) % 10);\n      let end = 20 + digit;\n      if (direction === \"up\" && end < start) end += 10;\n      if (direction === \"down\" && end > start) end -= 10;\n      element.getAnimations().forEach((animation) => animation.cancel());\n      element.style.transform = `translateY(-${(20 + digit) * 1.2}em)`;\n      if (\n        !matchMedia(\"(prefers-reduced-motion: reduce)\").matches &&\n        (initial || direction !== \"none\")\n      )\n        element.animate(\n          [\n            { transform: `translateY(-${start * 1.2}em)` },\n            { transform: `translateY(-${end * 1.2}em)` },\n          ],\n          {\n            duration: cssMs(tokens.getPropertyValue(\"--duration-deliberate\")),\n            easing: tokens.getPropertyValue(\"--ease-out\").trim(),\n          },\n        );\n    };\n    if (first.current) {\n      first.current = false;\n      element.style.transform = `translateY(-${(20 + digit) * 1.2}em)`;\n      if (reveal) {\n        const observer = new IntersectionObserver(\n          (entries) => {\n            if (entries[0].isIntersecting) {\n              animate(true);\n              observer.disconnect();\n            }\n          },\n          { threshold: 0.6 },\n        );\n        observer.observe(element.parentElement!);\n        return () => observer.disconnect();\n      }\n    } else animate();\n  }, [digit, direction, reveal]);\n  useEffect(() => {\n    const preference = matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const cancel = () => {\n      if (preference.matches)\n        column.current\n          ?.getAnimations()\n          .forEach((animation) => animation.cancel());\n    };\n    preference.addEventListener(\"change\", cancel);\n    return () => preference.removeEventListener(\"change\", cancel);\n  }, []);\n  return (\n    <span\n      data-slot=\"counter-digit\"\n      className=\"inline-block h-[1.2em] w-[1ch] overflow-hidden\"\n    >\n      <span\n        ref={column}\n        className=\"block\"\n        style={\n          {\n            transform: \"translateY(calc(-1.2em * var(--counter-target)))\",\n            \"--counter-target\": 20 + digit,\n          } as CSSProperties\n        }\n      >\n        {Array.from({ length: 50 }, (_, i) => (\n          <span key={i} className=\"block h-[1.2em] leading-[1.2]\">\n            {i % 10}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\nexport function AnimatedCounter({\n  value,\n  label,\n  prefix = \"\",\n  suffix = \"\",\n  decimals = 0,\n  locale = \"en-US\",\n  animateOnView = false,\n  className,\n}: {\n  value: number;\n  label?: string;\n  prefix?: string;\n  suffix?: string;\n  decimals?: number;\n  locale?: string;\n  animateOnView?: boolean;\n  className?: string;\n}) {\n  const shell = useRef<HTMLSpanElement>(null),\n    content = useRef<HTMLSpanElement>(null);\n  useLayoutEffect(() => {\n    const measure = () => {\n      if (shell.current && content.current)\n        shell.current.style.width = `${content.current.offsetWidth}px`;\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (content.current) observer.observe(content.current);\n    return () => observer.disconnect();\n  }, []);\n  const [frame, setFrame] = useState({ value, prior: value });\n  if (!Object.is(frame.value, value)) setFrame({ value, prior: frame.value });\n  const direction =\n    value > frame.prior ? \"up\" : value < frame.prior ? \"down\" : \"none\";\n  const text = Number.isFinite(value)\n    ? new Intl.NumberFormat(locale, {\n        minimumFractionDigits: Math.max(0, Math.min(20, decimals)),\n        maximumFractionDigits: Math.max(0, Math.min(20, decimals)),\n      }).format(value)\n    : \"—\";\n  return (\n    <span\n      data-slot=\"animated-counter\"\n      className={cn(\"inline-flex flex-col min-w-0\", className)}\n    >\n      {label && (\n        <span className=\"mb-1 text-xs font-medium text-muted-foreground\">\n          {label}\n        </span>\n      )}\n      <span className=\"sr-only\">\n        {prefix}\n        {text}\n        {suffix}\n      </span>\n      <span\n        ref={shell}\n        data-slot=\"counter-shell\"\n        aria-hidden=\"true\"\n        className=\"relative inline-block h-[1.2em] overflow-hidden\"\n      >\n        <span\n          ref={content}\n          className=\"absolute right-0 inline-flex w-max items-center tabular-nums\"\n        >\n          {prefix}\n          {[...text].map((glyph, index) =>\n            /\\d/.test(glyph) ? (\n              <CounterDigit\n                key={`digit-${text.length - index}`}\n                digit={Number(glyph)}\n                direction={direction}\n                reveal={animateOnView}\n              />\n            ) : (\n              <span key={`separator-${text.length - index}`}>{glyph}</span>\n            ),\n          )}\n          {suffix}\n        </span>\n      </span>\n    </span>\n  );\n}\n"},{"path":"ui/chart-parts.tsx","type":"registry:ui","target":"components/ui/chart-parts.tsx","content":"\"use client\";\nimport { useEffect, useRef, type ReactNode, type KeyboardEvent } from \"react\";\nimport { line, curveMonotoneX } from \"d3-shape\";\nimport \"./shared/chip-option.css\";\nimport \"./chart-parts.css\";\nexport type ChartSeries = { id: string; label: string; color?: string };\nexport type ChartDatum = {\n  id: string;\n  label: string;\n  values: Record<string, number | undefined>;\n  x?: number;\n  event?: string;\n};\nexport type ChartPart = {\n  id: string;\n  label: string;\n  value: number;\n  color?: string;\n};\nexport const chartColor = (index: number) => `var(--chart-${(index % 6) + 1})`;\nexport const numberText = (value: number) =>\n  new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 }).format(value);\nexport function useChartReveal() {\n  const root = useRef<HTMLElement>(null);\n  useEffect(() => {\n    const element = root.current;\n    if (!element) return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries[0].isIntersecting) {\n          element.dataset.visible = \"true\";\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.3 },\n    );\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n  return root;\n}\nexport function ChartTable({\n  label,\n  headers,\n  rows,\n}: {\n  label: string;\n  headers: string[];\n  rows: (string | number)[][];\n}) {\n  return (\n    <div className=\"sr-only\"><table>\n      <caption>{label}</caption>\n      <thead>\n        <tr>\n          {headers.map((header) => (\n            <th key={header} scope=\"col\">\n              {header}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((row, index) => (\n          <tr key={index}>\n            {row.map((cell, column) =>\n              column === 0 ? (\n                <th key={column} scope=\"row\">\n                  {cell}\n                </th>\n              ) : (\n                <td key={column}>{cell}</td>\n              ),\n            )}\n          </tr>\n        ))}\n      </tbody>\n    </table></div>\n  );\n}\nexport function ChartMessage({\n  loading,\n  error,\n  empty,\n}: {\n  loading?: boolean;\n  error?: string;\n  empty?: boolean;\n}) {\n  return error || empty ? (\n    <p\n      role=\"status\"\n      className=\"grid min-h-48 place-items-center text-sm text-muted-foreground\"\n    >\n      {error ?? (loading ? \"Loading chart…\" : \"No data for this range.\")}\n    </p>\n  ) : loading ? (\n    <p role=\"status\" className=\"text-xs text-muted-foreground\">\n      Updating chart…\n    </p>\n  ) : null;\n}\nexport function ChartLegend({\n  series,\n  hidden,\n  onToggle,\n  active,\n  onActive,\n}: {\n  series: readonly ChartSeries[];\n  hidden: readonly string[];\n  onToggle: (id: string) => void;\n  active?: string | null;\n  onActive?: (id: string | null) => void;\n}) {\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Chart series\"\n      className=\"flex flex-wrap gap-2\"\n      onKeyDown={(event) => legendKeys(event)}\n    >\n      {series.map((item, index) => (\n        <button\n          key={item.id}\n          type=\"button\"\n          data-slot=\"chart-legend-item\"\n          aria-pressed={!hidden.includes(item.id)}\n          onClick={() => onToggle(item.id)}\n          onPointerEnter={() => onActive?.(item.id)}\n          onPointerLeave={() => onActive?.(null)}\n          onFocus={() => onActive?.(item.id)}\n          onBlur={() => onActive?.(null)}\n          data-active={active === item.id}\n        >\n          <span\n            aria-hidden=\"true\"\n            style={{ background: item.color ?? chartColor(index) }}\n            className=\"size-2 rounded-full\"\n          />\n          {item.label}\n        </button>\n      ))}\n    </div>\n  );\n}\nexport function legendKeys(event: KeyboardEvent<HTMLElement>) {\n  const nodes = [\n    ...event.currentTarget.querySelectorAll<HTMLButtonElement>(\"button\"),\n  ];\n  const index = nodes.indexOf(document.activeElement as HTMLButtonElement);\n  const target =\n    event.key === \"Home\"\n      ? 0\n      : event.key === \"End\"\n        ? nodes.length - 1\n        : [\"ArrowRight\", \"ArrowDown\"].includes(event.key)\n          ? (index + 1) % nodes.length\n          : [\"ArrowLeft\", \"ArrowUp\"].includes(event.key)\n            ? (index - 1 + nodes.length) % nodes.length\n            : -1;\n  if (target >= 0) {\n    event.preventDefault();\n    nodes[target]?.focus();\n  }\n}\nexport function pointKey(\n  event: KeyboardEvent<HTMLElement>,\n  active: number | null,\n  length: number,\n) {\n  const index = active ?? length - 1;\n  const step = Math.max(1, Math.floor(length / 6));\n  const next =\n    event.key === \"Home\"\n      ? 0\n      : event.key === \"End\"\n        ? length - 1\n        : event.key === \"PageUp\"\n          ? index + step\n          : event.key === \"PageDown\"\n            ? index - step\n            : [\"ArrowRight\", \"ArrowUp\"].includes(event.key)\n              ? index + 1\n              : [\"ArrowLeft\", \"ArrowDown\"].includes(event.key)\n                ? index - 1\n                : event.key === \"Escape\"\n                  ? null\n                  : undefined;\n  if (next !== undefined) event.preventDefault();\n  return next == null ? next : Math.max(0, Math.min(length - 1, next));\n}\nexport function pathFor(\n  data: readonly ChartDatum[],\n  id: string,\n  minimum: number,\n  maximum: number,\n  hidden = false,\n) {\n  if (!data.length) return \"\";\n  const first = data[0].x ?? 0,\n    last = data[data.length - 1].x ?? data.length - 1;\n  const points = data.map((item, index) => ({\n    x: (600 * ((item.x ?? index) - first)) / (last - first || 1),\n    y: item.values[id],\n  }));\n  // Monotone cubic tangents (Fritsch-Carlson): the line is smooth through every point and never overshoots it.\n  const ok = (value: number | undefined): value is number => value !== undefined && Number.isFinite(value);\n  const slope = (k: number) => {\n    const p = points[k], q = points[k + 1];\n    return p && q && ok(p.y) && ok(q.y) ? (q.y - p.y) / (q.x - p.x || 1) : Number.NaN;\n  };\n  const tangents = points.map((_, k) => {\n    const before = slope(k - 1), after = slope(k);\n    if (!Number.isFinite(before)) return Number.isFinite(after) ? after : 0;\n    if (!Number.isFinite(after)) return before;\n    return before * after <= 0 ? 0 : (2 * before * after) / (before + after);\n  });\n  const samples = Array.from({ length: 97 }, (_, index) => {\n    const x = (index / 96) * 600;\n    let right = points.findIndex((point) => point.x >= x);\n    if (right < 0) right = points.length - 1;\n    const left = Math.max(0, right - 1);\n    const a = points[left],\n      b = points[right];\n    const valid = ok(a.y) && ok(b.y);\n    const h = b.x - a.x || 1;\n    const t = Math.min(1, Math.max(0, (x - a.x) / h));\n    const value = valid\n      ? (2 * t ** 3 - 3 * t ** 2 + 1) * a.y! + (t ** 3 - 2 * t ** 2 + t) * h * tangents[left]! + (-2 * t ** 3 + 3 * t ** 2) * b.y! + (t ** 3 - t ** 2) * h * tangents[right]!\n      : Number.NaN;\n    return [\n      x,\n      hidden ? 210 : 210 - ((value - minimum) / (maximum - minimum || 1)) * 200,\n    ] as [number, number];\n  });\n  return (\n    line<[number, number]>()\n      .defined((point) => Number.isFinite(point[1]))\n      .x((point) => point[0])\n      .y((point) => point[1])\n      .curve(curveMonotoneX)(samples) ?? \"\"\n  );\n}\nexport function ChartReadout({ children }: { children: ReactNode }) {\n  return (\n    <div\n      data-slot=\"chart-readout\"\n      aria-hidden=\"true\"\n      // A ruled line under the plot, not a box: the chart already sits on a surface.\n      className=\"mt-4 min-h-11 border-t border-border pt-3 text-xs leading-5 tabular-nums\"\n    >\n      {children}\n    </div>\n  );\n}\n"},{"path":"ui/donut-chart.tsx","type":"registry:ui","target":"components/ui/donut-chart.tsx","content":"\"use client\";\nimport { useState } from \"react\";\nimport { AnimatedCounter } from \"./animated-counter\";\nimport {\n  ChartTable,\n  ChartMessage,\n  chartColor,\n  legendKeys,\n  numberText,\n  useChartReveal,\n  type ChartPart,\n} from \"./chart-parts\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/styles.css\";\nimport \"./donut-chart.css\";\n/** Series colour by position; the grouped \"Other\" slice takes the neutral that closes the palette. */\nconst colorOf = (id: string, index: number) => (id === \"__other\" ? \"var(--chart-6)\" : chartColor(index));\n\nexport function DonutChart({\n  label,\n  data,\n  unit = \"\",\n  className,\n  loading,\n  error,\n}: {\n  label: string;\n  data: readonly ChartPart[];\n  unit?: string;\n  className?: string;\n  loading?: boolean;\n  error?: string;\n}) {\n  const root = useChartReveal();\n  const [hidden, setHidden] = useState<string[]>([]),\n    [preview, setPreview] = useState<string | null>(null),\n    [pinned, setPinned] = useState<string | null>(null);\n  const positive = data.filter(\n    (item) => Number.isFinite(item.value) && item.value > 0,\n  );\n  const whole = positive.reduce((sum, item) => sum + item.value, 0);\n  const sorted = [...positive].sort((a, b) => b.value - a.value);\n  const small = sorted.filter(\n    (item, index) => index >= 5 || item.value / whole < 0.035,\n  );\n  const grouped = small.length > 1;\n  const parts = grouped\n    ? [\n        ...positive.filter((item) => !small.includes(item)),\n        {\n          id: \"__other\",\n          label: \"Other\",\n          value: small.reduce((sum, item) => sum + item.value, 0),\n        },\n      ]\n    : positive;\n  const total = parts\n    .filter((item) => !hidden.includes(item.id))\n    .reduce((sum, item) => sum + item.value, 0);\n  const active = parts.find(\n    (item) => item.id === (preview ?? pinned) && !hidden.includes(item.id),\n  );\n  return (\n    <figure\n      ref={root}\n      aria-label={label}\n      aria-busy={loading}\n      data-slot=\"donut-chart\"\n      className={cn(\"min-w-0\", className)}\n    >\n      <figcaption className=\"mb-4 text-xs font-medium text-muted-foreground\">{label}</figcaption>\n      <ChartMessage loading={loading} error={error} empty={!parts.length} />\n      {!error && !!parts.length && (\n        <div data-slot=\"donut-layout\">\n          <div className=\"relative mx-auto w-52 max-w-full\">\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 200 200\"\n              className=\"w-full -rotate-90\"\n            >\n              <circle\n                cx=\"100\"\n                cy=\"100\"\n                r=\"76\"\n                fill=\"none\"\n                stroke=\"var(--muted)\"\n                strokeWidth=\"24\"\n              />\n              {parts.map((item, index) => {\n                const share =\n                  hidden.includes(item.id) || !total\n                    ? 0\n                    : (item.value / total) * 100;\n                const offset = parts.slice(0, index).reduce((sum, part) => sum + (hidden.includes(part.id) || !total ? 0 : part.value / total * 100), 0);\n                const angle = ((offset + share / 2) / 100) * Math.PI * 2;\n                return (\n                  <circle\n                    key={item.id}\n                    data-slot=\"donut-segment\"\n                    cx=\"100\"\n                    cy=\"100\"\n                    r=\"76\"\n                    fill=\"none\"\n                    stroke={item.color ?? colorOf(item.id, index)}\n                    strokeWidth={active?.id === item.id ? 28 : 24}\n                    pathLength=\"100\"\n                    strokeDasharray={`${Math.max(0, share - 1.2)} ${100 - Math.max(0, share - 1.2)}`}\n                    strokeDashoffset={-offset}\n                    style={{\n                      opacity: active && active.id !== item.id ? 0.35 : 1,\n                      transform:\n                        active?.id === item.id\n                          ? `translate(${Math.cos(angle) * 3}px,${Math.sin(angle) * 3}px)`\n                          : \"translate(0,0)\",\n                    }}\n                    onPointerEnter={() => setPreview(item.id)}\n                    onPointerLeave={() => setPreview(null)}\n                    onClick={() =>\n                      setPinned(pinned === item.id ? null : item.id)\n                    }\n                  />\n                );\n              })}\n            </svg>\n            <div\n              className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center\"\n              aria-hidden=\"true\"\n            >\n              <span className=\"max-w-32 truncate text-xs text-muted-foreground\">\n                {active?.label ?? \"Total\"}\n              </span>\n              <AnimatedCounter\n                value={active?.value ?? total}\n                className=\"mt-1 text-2xl tracking-tight\"\n              />\n              <span className=\"mt-1 text-xs text-muted-foreground\">\n                {active\n                  ? `${Math.round((active.value / (total || 1)) * 100)}%`\n                  : unit}\n              </span>\n              {!total && (\n                <span className=\"mt-1 text-xs text-muted-foreground\">\n                  All hidden\n                </span>\n              )}\n            </div>\n          </div>\n          <div\n            role=\"group\"\n            aria-label={`${label} categories`}\n            className=\"grid content-center gap-1\"\n            onKeyDown={(event) => {\n              legendKeys(event);\n              if (event.key === \"Escape\") {\n                setPinned(null);\n                setPreview(null);\n              }\n            }}\n          >\n            {parts.map((item, index) => (\n              <div key={item.id} className=\"flex items-center gap-1\">\n                <button\n                  type=\"button\"\n                  data-slot=\"donut-legend\"\n                  aria-pressed={!hidden.includes(item.id)}\n                  aria-label={`${item.label}, ${numberText(item.value)} ${unit}, ${Math.round((item.value / whole) * 100)} percent${item.id === \"__other\" ? `, includes ${small.map((part) => part.label).join(\", \")}` : \"\"}`}\n                  onClick={() =>\n                    setHidden(\n                      hidden.includes(item.id)\n                        ? hidden.filter((key) => key !== item.id)\n                        : [...hidden, item.id],\n                    )\n                  }\n                  onFocus={() => setPreview(item.id)}\n                  onBlur={() => setPreview(null)}\n                  onPointerEnter={() => setPreview(item.id)}\n                  onPointerLeave={() => setPreview(null)}\n                  className=\"flex min-h-11 min-w-0 flex-1 items-center gap-2 rounded-control px-2 text-left text-xs outline-none hover:bg-muted active:bg-[var(--raised-press)] focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-ring\"\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"size-2 shrink-0 rounded-full\"\n                    style={{ background: item.color ?? colorOf(item.id, index) }}\n                  />\n                  <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                  <strong className=\"font-medium tabular-nums\">\n                    {numberText(item.value)}\n                  </strong>\n                  <span className=\"w-8 text-right tabular-nums text-muted-foreground\">\n                    {Math.round((item.value / whole) * 100)}%\n                  </span>\n                </button>\n                <button\n                  type=\"button\"\n                  className=\"caliper-icon-button text-xs\"\n                  aria-label={`Pin ${item.label}`}\n                  aria-pressed={pinned === item.id}\n                  onClick={() => setPinned(pinned === item.id ? null : item.id)}\n                >\n                  ⌖\n                </button>\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n      <p className=\"sr-only\" role=\"status\">\n        {label}. Visible total {numberText(total)} {unit}.\n        {active ? ` ${active.label}: ${numberText(active.value)}.` : \"\"}\n      </p>\n      <ChartTable\n        label={label}\n        headers={[\"Category\", \"Value\", \"Share\"]}\n        rows={positive.map((item) => [\n          item.label,\n          item.value,\n          `${Math.round((item.value / (whole || 1)) * 100)}%`,\n        ])}\n      />\n    </figure>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/lib/motion-tokens.ts","type":"registry:ui","target":"components/ui/lib/motion-tokens.ts","content":"/** Milliseconds from a CSS time such as \"280ms\" or \".28s\" (build tools may rewrite one as the other). */\nexport function cssMs(value: string) {\n  const text = value.trim();\n  const number = parseFloat(text);\n  if (!Number.isFinite(number)) return 0;\n  return text.endsWith(\"ms\") ? number : text.endsWith(\"s\") ? number * 1000 : number;\n}\n\n/** Reads a duration token (e.g. \"--duration-travel\") in milliseconds from an element; the tokens resolve to 0 under reduced motion. */\nexport function tokenMs(element: Element, name: string) {\n  return cssMs(getComputedStyle(element).getPropertyValue(name));\n}\n"},{"path":"ui/animated-counter.css","type":"registry:file","target":"components/ui/animated-counter.css","content":"/* animated-counter.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"counter-shell\"] {\n    transition: width var(--duration-deliberate) var(--ease-out);\n  }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"counter-shell\"] {\n      transition: none;\n    }\n  }\n}\n"},{"path":"ui/shared/chip-option.css","type":"registry:file","target":"components/ui/shared/chip-option.css","content":"/* Shared by calendar.tsx, chart-parts.tsx, chip-group.tsx, data-table.tsx, number-field.tsx, password-field.tsx, stepper.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Depth sets 2 and 3 end */\n\n  /* Press feedback (2026-10-20, motion audit): every pressable control answers a press. Compact controls take the\n     system press scale, as Button does; wide rows deepen their fill instead, since scaling a full-width row reads as a\n     jump. Disabled controls stay still. */\n  :is([data-slot=\"chip-option\"], [data-slot=\"number-field\"] button, [data-slot=\"password-toggle\"], [data-slot=\"calendar\"] button, [data-slot=\"chart-legend-item\"], [data-slot=\"sort-button\"], [data-slot=\"stepper\"] button):active:not(:disabled):not([aria-disabled=\"true\"]) {\n    scale: var(--motion-press-scale);\n  }\n}\n"},{"path":"ui/chart-parts.css","type":"registry:file","target":"components/ui/chart-parts.css","content":"/* chart-parts.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"chart-legend-item\"] {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.5rem;\n    min-height: var(--control-height);\n    padding: 0.25rem 0.5rem;\n    border-radius: var(--radius-control);\n    font-size: 0.75rem;\n    color: var(--muted-foreground);\n    outline: none;\n    transition:\n      background-color var(--duration-fast) var(--ease-out),\n      color var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"chart-legend-item\"]:hover,\n  [data-slot=\"chart-legend-item\"][data-active=\"true\"] {\n    background: var(--muted);\n    color: var(--foreground);\n  }\n  [data-slot=\"chart-legend-item\"]:focus-visible {\n    outline: 2px solid var(--ring);\n    outline-offset: 2px;\n  }\n  [data-slot=\"chart-legend-item\"][aria-pressed=\"false\"] {\n    opacity: 0.45;\n    text-decoration: line-through;\n  }\n}\n"},{"path":"ui/shared/styles.css","type":"registry:file","target":"components/ui/shared/styles.css","content":"/* Shared by animated-tabs.tsx, dock.tsx, donut-chart.tsx, sortable-list.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  .caliper-icon-button {\n    display: inline-grid;\n    place-items: center;\n    flex-shrink: 0;\n    min-width: var(--control-height);\n    min-height: var(--control-height);\n    border-radius: var(--radius-control);\n    color: var(--muted-foreground);\n    outline: none;\n    transition:\n      background-color var(--duration-fast) var(--ease-out),\n      scale var(--duration-fast) var(--ease-out);\n  }\n  .caliper-icon-button:hover {\n    background: var(--muted);\n    color: var(--foreground);\n  }\n  .caliper-icon-button:focus-visible {\n    outline: 2px solid var(--ring);\n    outline-offset: 2px;\n  }\n  .caliper-icon-button:active {\n    scale: var(--motion-press-scale);\n  }\n  .caliper-icon-button:disabled {\n    opacity: 0.4;\n    pointer-events: none;\n  }\n}\n"},{"path":"ui/donut-chart.css","type":"registry:file","target":"components/ui/donut-chart.css","content":"/* donut-chart.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"donut-legend\"][aria-pressed=\"false\"] {\n    opacity: 0.45;\n    text-decoration: line-through;\n  }\n  [data-slot=\"donut-chart\"] {\n    container-type: inline-size;\n  }\n  [data-slot=\"donut-layout\"] {\n    display: grid;\n    gap: 1.5rem;\n  }\n  @container (min-width:28rem) {\n    [data-slot=\"donut-layout\"] {\n      grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);\n    }\n  }\n  [data-slot=\"donut-segment\"] {\n    cursor: pointer;\n    transition:\n      stroke-dasharray var(--duration-travel) var(--ease-showcase),\n      stroke-dashoffset var(--duration-travel) var(--ease-showcase),\n      stroke-width var(--duration-normal) var(--ease-out),\n      transform var(--duration-normal) var(--ease-out),\n      opacity var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"donut-chart\"][data-visible=\"true\"] [data-slot=\"donut-segment\"] {\n    animation: caliper-ring-fill var(--duration-showcase) var(--ease-showcase);\n  }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"donut-segment\"] {\n      transition: none;\n      animation: none !important;\n    }\n  }\n}\n"}],"categories":["charts"],"meta":{"tier":"free","license":"MIT","family":"core-charts","docs":"/docs/components/donut-chart"}}