{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"donut-chart","type":"registry:component","title":"Donut Chart","description":"Rounded share segments with a linked center readout and legend.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/donut-chart.tsx","type":"registry:component","target":"@components/charts/donut-chart.tsx","content":"\"use client\";\n// beui.dev/charts/donut-chart\n\nimport { cn } from \"@/lib/utils\";\nimport { DonutChartCenter } from \"./donut-chart/center\";\nimport { type DonutChartProps, DonutContext, useDonutModel } from \"./donut-chart/context\";\nimport { DonutChartLegend } from \"./donut-chart/legend\";\nimport { DonutChartPlot } from \"./donut-chart/plot\";\n\n/**\n * A ring of rounded segments with a linked center readout and legend.\n *\n * ```tsx\n * <DonutChart segments={segments} formatValue={usd}>\n *   <DonutChartPlot>\n *     <DonutChartCenter totalLabel=\"Monthly revenue\" />\n *   </DonutChartPlot>\n *   <DonutChartLegend />\n * </DonutChart>\n * ```\n */\nexport function DonutChart({ className, children, ...props }: DonutChartProps) {\n  const model = useDonutModel(props);\n  return (\n    <DonutContext.Provider value={model}>\n      <div className={cn(\"w-full\", className)}>\n        {children ?? (\n          <div className=\"grid items-center gap-6 sm:grid-cols-[minmax(0,16rem)_1fr]\">\n            <DonutChartPlot>\n              <DonutChartCenter />\n            </DonutChartPlot>\n            <DonutChartLegend />\n          </div>\n        )}\n      </div>\n    </DonutContext.Provider>\n  );\n}\n\nexport { DonutChartCenter, type DonutChartCenterProps } from \"./donut-chart/center\";\nexport { type DonutChartProps, useDonutChart } from \"./donut-chart/context\";\nexport { DonutChartLegend, type DonutChartLegendProps } from \"./donut-chart/legend\";\nexport type { DonutSegment, PlacedSegment as DonutPlacedSegment } from \"./donut-chart/model\";\nexport { DonutChartPlot, type DonutChartPlotProps } from \"./donut-chart/plot\";\n"},{"path":"components/charts/donut-chart/center.tsx","type":"registry:component","target":"@components/charts/donut-chart/center.tsx","content":"\"use client\";\n\n/* The value rolls digit by digit (up when it grows, down when it shrinks) in\n * a stable container, and the caption slides through a clipped line, so\n * moving between segments reads as one readout updating rather than a\n * replaced block. Reduced motion: both swap with a short fade. */\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { type ReactNode, useState } from \"react\";\nimport { DigitSwap } from \"@/components/motion/digit-swap\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useDonutChart } from \"./context\";\nimport type { PlacedSegment } from \"./model\";\n\nexport type DonutChartCenterProps = {\n  /**\n   * Custom content for the hole. Receives the focused segment, or null when\n   * nothing is focused, and the total. Defaults to a rolling value and caption.\n   */\n  children?: (segment: PlacedSegment | null, total: number) => ReactNode;\n  /** Caption under the total when nothing is focused. */\n  totalLabel?: ReactNode;\n  className?: string;\n};\n\nexport function DonutChartCenter({ children, totalLabel = \"Total\", className }: DonutChartCenterProps) {\n  const { placed, active, total, formatValue, reduce } = useDonutChart();\n  const segment = placed.find((item) => item.id === active) ?? null;\n  const value = segment?.value ?? total;\n  // Roll digits in the direction the value moved, adjusted during render so\n  // the first frame of a change already rolls the right way.\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState<\"up\" | \"down\">(\"up\");\n  if (value !== previous) {\n    setPrevious(value);\n    setDirection(value > previous ? \"up\" : \"down\");\n  }\n\n  if (children) {\n    return (\n      <div aria-live=\"polite\" className={cn(\"relative max-w-[56%]\", className)}>\n        {children(segment, total)}\n      </div>\n    );\n  }\n\n  return (\n    <div aria-live=\"polite\" className={cn(\"relative max-w-[56%]\", className)}>\n      <p className=\"text-2xl font-medium tracking-tight tabular-nums\">\n        <DigitSwap value={formatValue(value)} direction={direction} />\n      </p>\n      <div className=\"relative mt-1 h-4 overflow-hidden\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.p\n            key={segment?.id ?? \"total\"}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: \"100%\" }}\n            animate={{ opacity: 1, y: \"0%\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: \"-100%\" }}\n            transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}\n            className=\"flex items-center justify-center gap-1.5 whitespace-nowrap text-xs leading-4 text-muted-foreground\"\n          >\n            {segment ? (\n              <>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-2 shrink-0 rounded-full\"\n                  style={{ backgroundColor: segment.color }}\n                />\n                <span className=\"truncate\">\n                  {segment.name} · {(segment.share * 100).toFixed(1)}%\n                </span>\n              </>\n            ) : (\n              totalLabel\n            )}\n          </motion.p>\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/charts/donut-chart/context.tsx","type":"registry:component","target":"@components/charts/donut-chart/context.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { buildDonut, type DonutSegment } from \"./model\";\n\nexport interface DonutChartProps {\n  segments: readonly DonutSegment[];\n  /** Ring thickness in viewBox units; the chart is 200 wide. */\n  thickness?: number;\n  /** Degrees of empty space between neighbouring segments. */\n  gap?: number;\n  /** Focused segment, shared by the ring, center, and legend. Controlled. */\n  activeId?: string | null;\n  defaultActiveId?: string | null;\n  onActiveChange?: (id: string | null) => void;\n  /** Called when a legend item is clicked. */\n  onSelect?: (id: string) => void;\n  /** Marks a legend item as pressed. */\n  selectedId?: string | null;\n  formatValue?: (value: number) => string;\n  /** Accessible name for the chart. */\n  label?: string;\n  className?: string;\n  children?: ReactNode;\n}\n\nconst defaultFormat = (value: number) => value.toLocaleString(\"en-US\");\n\nexport function useDonutModel({\n  segments,\n  thickness = 22,\n  gap = 3,\n  activeId,\n  defaultActiveId = null,\n  onActiveChange,\n  onSelect,\n  selectedId = null,\n  formatValue = defaultFormat,\n  label = \"Donut chart\",\n}: DonutChartProps) {\n  const reduce = useReducedMotion() ?? false;\n  const canHover = useHoverCapable();\n  const radius = 100 - thickness / 2 - 8;\n  const { placed, total } = buildDonut(segments, { radius, thickness, gap });\n\n  const [internal, setInternal] = useState(defaultActiveId);\n  const raw = activeId === undefined ? internal : activeId;\n  // Resolve during render by id; a segment that left the data clears focus.\n  const active = raw !== null && placed.some((segment) => segment.id === raw) ? raw : null;\n\n  const latest = useRef({ controlled: activeId !== undefined, onActiveChange });\n  useLayoutEffect(() => {\n    latest.current = { controlled: activeId !== undefined, onActiveChange };\n  });\n  const setActive = useCallback((id: string | null) => {\n    if (!latest.current.controlled) setInternal(id);\n    latest.current.onActiveChange?.(id);\n  }, []);\n\n  return {\n    placed,\n    total,\n    radius,\n    thickness,\n    active,\n    setActive,\n    onSelect,\n    selectedId,\n    formatValue,\n    label,\n    reduce,\n    canHover,\n  };\n}\n\nexport type DonutModel = ReturnType<typeof useDonutModel>;\nexport const DonutContext = createContext<DonutModel | null>(null);\n\nexport function useDonutChart() {\n  const model = useContext(DonutContext);\n  if (!model) throw new Error(\"Donut chart parts must be rendered inside DonutChart.\");\n  return model;\n}\n"},{"path":"components/charts/donut-chart/legend.tsx","type":"registry:component","target":"@components/charts/donut-chart/legend.tsx","content":"\"use client\";\n\nimport { motion } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useDonutChart } from \"./context\";\nimport type { PlacedSegment } from \"./model\";\n\nexport type DonutChartLegendProps = {\n  /** Extra line under each name, such as terms or a note. */\n  renderDetail?: (segment: PlacedSegment) => ReactNode;\n  /** Trailing value. Defaults to the share and formatted value. */\n  renderValue?: (segment: PlacedSegment) => ReactNode;\n  className?: string;\n};\n\n/**\n * Legend rows share focus with the ring: hovering or focusing a row pops its\n * segment, and arrow keys move between rows. Each row carries a share bar.\n */\nexport function DonutChartLegend({ renderDetail, renderValue, className }: DonutChartLegendProps) {\n  const { placed, active, setActive, onSelect, selectedId, formatValue, reduce, canHover } =\n    useDonutChart();\n  return (\n    <ul className={cn(\"space-y-1\", className)}>\n      {placed.map((segment, index) => {\n        const focused = active === segment.id;\n        return (\n          <li key={segment.id}>\n            <button\n              type=\"button\"\n              data-donut-legend\n              aria-pressed={onSelect ? selectedId === segment.id : undefined}\n              onClick={() => onSelect?.(segment.id)}\n              onPointerEnter={canHover ? () => setActive(segment.id) : undefined}\n              onPointerLeave={canHover ? () => setActive(null) : undefined}\n              onFocus={() => setActive(segment.id)}\n              onBlur={() => setActive(null)}\n              onKeyDown={(event) => {\n                const step = event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowUp\" ? -1 : 0;\n                if (!step) return;\n                event.preventDefault();\n                const rows = event.currentTarget\n                  .closest(\"ul\")\n                  ?.querySelectorAll<HTMLButtonElement>(\"[data-donut-legend]\");\n                rows?.[(index + step + placed.length) % placed.length]?.focus();\n              }}\n              className={cn(\n                \"w-full rounded-xl px-3 py-2 text-left outline-none transition-colors duration-150\",\n                \"focus-visible:ring-2 focus-visible:ring-ring\",\n                focused ? \"bg-muted\" : \"hover:bg-muted/60\",\n                selectedId === segment.id && \"ring-1 ring-border\",\n              )}\n            >\n              <span className=\"flex items-center gap-3\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-2.5 shrink-0 rounded-full\"\n                  style={{ backgroundColor: segment.color }}\n                />\n                <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{segment.name}</span>\n                <span className=\"shrink-0 text-right text-sm tabular-nums\">\n                  {renderValue ? (\n                    renderValue(segment)\n                  ) : (\n                    <>\n                      <span className=\"font-medium\">{(segment.share * 100).toFixed(1)}%</span>\n                      <span className=\"ml-2 text-muted-foreground\">{formatValue(segment.value)}</span>\n                    </>\n                  )}\n                </span>\n              </span>\n              {renderDetail ? (\n                <span className=\"mt-0.5 block pl-5.5 text-xs text-muted-foreground\">\n                  {renderDetail(segment)}\n                </span>\n              ) : null}\n              <span aria-hidden=\"true\" className=\"mt-2 ml-5.5 block h-1 overflow-hidden rounded-full bg-muted\">\n                <motion.span\n                  className=\"block h-full origin-left rounded-full\"\n                  style={{ backgroundColor: segment.color }}\n                  initial={reduce ? false : { scaleX: 0 }}\n                  animate={{ scaleX: segment.share }}\n                  transition={reduce ? { duration: 0 } : { ...SPRING_LAYOUT, delay: 0.2 + index * 0.06 }}\n                />\n              </span>\n            </button>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n"},{"path":"components/charts/donut-chart/model.ts","type":"registry:component","target":"@components/charts/donut-chart/model.ts","content":"export interface DonutSegment {\n  id: string;\n  name: string;\n  /** Any CSS color, such as \"#2563eb\" or \"var(--chart-1)\". */\n  color: string;\n  /** Nonnegative value. Zero and invalid values are left out of the ring. */\n  value: number;\n}\n\nexport type PlacedSegment = DonutSegment & {\n  /** Share of the total, from 0 to 1. */\n  share: number;\n  /** Centerline arc path in the 200 × 200 viewBox. */\n  path: string;\n};\n\nexport const DONUT_SIZE = 200;\nconst CENTER = DONUT_SIZE / 2;\n\nconst point = (radius: number, degrees: number) => {\n  const radians = (degrees * Math.PI) / 180;\n  return [CENTER + radius * Math.sin(radians), CENTER - radius * Math.cos(radians)];\n};\n\n/**\n * Lays segments clockwise from 12 o'clock as round-capped arcs. Each arc is\n * shortened by the gap plus its own cap, so neighbouring pills keep an even\n * gap. Arcs too small for their caps collapse to a dot.\n */\nexport function buildDonut(\n  segments: readonly DonutSegment[],\n  { radius, thickness, gap }: { radius: number; thickness: number; gap: number },\n) {\n  const valid = segments.filter((segment) => Number.isFinite(segment.value) && segment.value > 0);\n  const total = valid.reduce((sum, segment) => sum + segment.value, 0);\n  const capDegrees = ((thickness / 2) / radius) * (180 / Math.PI);\n  const single = valid.length === 1;\n  let cursor = 0;\n  const placed: PlacedSegment[] = valid.map((segment) => {\n    const share = total ? segment.value / total : 0;\n    const sweep = share * 360;\n    const inset = single ? 0 : gap / 2 + capDegrees;\n    const start = cursor + inset;\n    const end = Math.max(start + 0.01, cursor + sweep - inset);\n    cursor += sweep;\n    let path: string;\n    if (single) {\n      // A full ring needs two half arcs; one arc cannot start and end together.\n      const [x1, y1] = point(radius, 0);\n      const [x2, y2] = point(radius, 180);\n      path = `M ${x1} ${y1} A ${radius} ${radius} 0 1 1 ${x2} ${y2} A ${radius} ${radius} 0 1 1 ${x1} ${y1}`;\n    } else {\n      const [x1, y1] = point(radius, start);\n      const [x2, y2] = point(radius, end);\n      path = `M ${x1} ${y1} A ${radius} ${radius} 0 ${end - start > 180 ? 1 : 0} 1 ${x2} ${y2}`;\n    }\n    return { ...segment, share, path };\n  });\n  return { placed, total };\n}\n"},{"path":"components/charts/donut-chart/plot.tsx","type":"registry:component","target":"@components/charts/donut-chart/plot.tsx","content":"\"use client\";\n\n/* The ring sweeps in once it scrolls into view: it turns from -90deg and\n * grows while segments fade in clockwise, 60ms apart. Hovering a segment, or\n * focusing its legend item, pops it outward from the ring's centre and fades\n * the rest. Everything animates transform and opacity. Hover is read from\n * static hit arcs, so a popping segment never slides out from under the\n * pointer and toggles itself. Reduced motion: the ring appears in place and\n * emphasis changes without easing. */\n\nimport { motion, useInView } from \"motion/react\";\nimport { type ReactNode, useRef, useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useDonutChart } from \"./context\";\nimport { DONUT_SIZE } from \"./model\";\n\nconst CENTER = DONUT_SIZE / 2;\n/** Scale around the ring's centre rather than each arc's own box. */\nconst RING_ORIGIN = {\n  transformBox: \"view-box\",\n  transformOrigin: `${CENTER}px ${CENTER}px`,\n} as const;\n\nexport type DonutChartPlotProps = {\n  /** Centered overlay, usually `DonutChartCenter`. */\n  children?: ReactNode;\n  className?: string;\n};\n\nexport function DonutChartPlot({ children, className }: DonutChartPlotProps) {\n  const { placed, radius, thickness, active, setActive, label, reduce, canHover } =\n    useDonutChart();\n  const ref = useRef<HTMLDivElement>(null);\n  const inView = useInView(ref, { once: true, amount: 0.4 });\n  const shown = reduce || inView;\n  // Stagger segments only for the first sweep, not when emphasis clears.\n  const [entered, setEntered] = useState(false);\n\n  return (\n    <div ref={ref} className={cn(\"relative mx-auto aspect-square w-full max-w-64\", className)}>\n      <svg\n        viewBox={`0 0 ${DONUT_SIZE} ${DONUT_SIZE}`}\n        role=\"img\"\n        aria-label={label}\n        className=\"size-full overflow-visible\"\n      >\n        <circle\n          cx={CENTER}\n          cy={CENTER}\n          r={radius}\n          fill=\"none\"\n          strokeWidth={thickness}\n          className=\"stroke-muted\"\n          opacity={0.6}\n        />\n        <motion.g\n          style={RING_ORIGIN}\n          initial={reduce ? false : { rotate: -90, scale: 0.88, opacity: 0 }}\n          animate={shown ? { rotate: 0, scale: 1, opacity: 1 } : undefined}\n          transition={{ duration: 0.9, ease: EASE_OUT }}\n          onAnimationComplete={() => setEntered(true)}\n        >\n          {placed.map((segment, index) => {\n            const focused = active === segment.id;\n            const dimmed = active !== null && !focused;\n            return (\n              <motion.path\n                key={segment.id}\n                d={segment.path}\n                fill=\"none\"\n                stroke={segment.color}\n                strokeWidth={thickness}\n                strokeLinecap=\"round\"\n                style={RING_ORIGIN}\n                initial={reduce ? false : { opacity: 0 }}\n                animate={\n                  shown\n                    ? { opacity: dimmed ? 0.3 : 1, scale: focused ? 1.05 : 1 }\n                    : undefined\n                }\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        opacity: {\n                          duration: 0.4,\n                          ease: EASE_OUT,\n                          delay: entered ? 0 : 0.15 + index * 0.06,\n                        },\n                        scale: SPRING_LAYOUT,\n                      }\n                }\n                pointerEvents=\"none\"\n                aria-hidden=\"true\"\n              />\n            );\n          })}\n        </motion.g>\n        {/* Static, slightly wider hit arcs own hover. They never scale, so the\n            pointer stays on the same segment while its visual pops. The svg's\n            img role already hides them; the legend carries keyboard access. */}\n        <g>\n          {placed.map((segment) => (\n            <path\n              key={segment.id}\n              d={segment.path}\n              fill=\"none\"\n              stroke=\"transparent\"\n              strokeWidth={thickness + 8}\n              strokeLinecap=\"round\"\n              pointerEvents={shown ? \"stroke\" : \"none\"}\n              className=\"cursor-pointer\"\n              onPointerEnter={canHover ? () => setActive(segment.id) : undefined}\n              onPointerLeave={canHover ? () => setActive(null) : undefined}\n            />\n          ))}\n        </g>\n      </svg>\n      {children ? (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-center\">\n          {children}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/digit-swap.tsx","type":"registry:component","target":"@components/motion/digit-swap.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DigitSwapDirection = \"up\" | \"down\";\n\nexport interface DigitSwapProps {\n  /** Numeric or masked value rendered in fixed character slots. */\n  value: string | number;\n  /** Replays every glyph when the value itself contains unchanged characters. */\n  animationKey?: string | number;\n  /** Direction the next glyph enters from. */\n  direction?: DigitSwapDirection;\n  /** Per-glyph transition duration in seconds. */\n  duration?: number;\n  /** Delay in seconds between neighboring glyphs. */\n  stagger?: number;\n  /** Number of final characters that receive `suffixClassName`. */\n  suffixLength?: number;\n  className?: string;\n  glyphClassName?: string;\n  suffixClassName?: string;\n}\n\ntype GlyphMotionContext = {\n  direction: DigitSwapDirection;\n  reduceMotion: boolean;\n};\n\nconst GLYPH_VARIANTS = {\n  enter: ({ direction, reduceMotion }: GlyphMotionContext) => ({\n    opacity: 0,\n    transform: reduceMotion\n      ? \"none\"\n      : `translateY(${direction === \"up\" ? \"45%\" : \"-45%\"})`,\n  }),\n  visible: {\n    opacity: 1,\n    transform: \"translateY(0%)\",\n  },\n  exit: ({ direction, reduceMotion }: GlyphMotionContext) => ({\n    opacity: 0,\n    transform: reduceMotion\n      ? \"none\"\n      : `translateY(${direction === \"up\" ? \"-45%\" : \"45%\"})`,\n  }),\n};\n\n/**\n * Fixed-slot digits and mask glyphs that roll when their value changes.\n *\n * Each slot holds an invisible in-flow copy of its character, so the slot has\n * a real text baseline (it lines up with neighbouring text) and punctuation\n * keeps its natural width. Digits use a fixed 1ch slot so rolls never jitter.\n * Slots clip with `clip-path`, which, unlike `overflow: hidden`, leaves the\n * baseline alone.\n */\nexport function DigitSwap({\n  value,\n  animationKey,\n  direction = \"up\",\n  duration = 0.18,\n  stagger = 0.006,\n  suffixLength = 0,\n  className,\n  glyphClassName,\n  suffixClassName,\n}: DigitSwapProps) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const text = String(value);\n  const suffixStart = Math.max(0, text.length - Math.max(0, suffixLength));\n  const motionContext: GlyphMotionContext = { direction, reduceMotion };\n  const glyphs = Array.from(text, (character, position) => ({\n    character,\n    id: `glyph-${position}`,\n    position,\n  }));\n\n  return (\n    <span\n      data-slot=\"digit-swap\"\n      data-direction={direction}\n      className={cn(\"inline-flex items-baseline whitespace-nowrap\", className)}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline\">\n        {glyphs.map(({ character, id, position }) => {\n          if (character === \" \") {\n            return <span key={id} className=\"inline-block w-[0.7ch]\" />;\n          }\n\n          const glyphKey =\n            animationKey === undefined\n              ? `${id}-${character}`\n              : `${id}-${character}-${animationKey}`;\n\n          return (\n            <span\n              key={id}\n              data-slot=\"digit-swap-glyph\"\n              className={cn(\n                \"relative inline-block h-[1.1em] shrink-0 leading-[1.1em] [clip-path:inset(0)]\",\n                /\\d/.test(character) && \"w-[1ch] text-center\",\n              )}\n            >\n              <span className=\"invisible\">{character}</span>\n              <AnimatePresence initial={false} custom={motionContext}>\n                <motion.span\n                  key={glyphKey}\n                  custom={motionContext}\n                  variants={GLYPH_VARIANTS}\n                  initial=\"enter\"\n                  animate=\"visible\"\n                  exit=\"exit\"\n                  transition={{\n                    duration: reduceMotion ? Math.min(duration, 0.12) : duration,\n                    delay: reduceMotion ? 0 : position * stagger,\n                    ease: EASE_OUT,\n                  }}\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center leading-none\",\n                    glyphClassName,\n                    position >= suffixStart ? suffixClassName : undefined,\n                  )}\n                >\n                  {character}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"}]}