{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circular-slider",
  "title": "Circular Slider",
  "description": "A circular-slider component.",
  "dependencies": [
    "class-variance-authority"
  ],
  "files": [
    {
      "path": "registry/default/circular-slider/circular-slider.tsx",
      "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport { cva, type VariantProps } from \"class-variance-authority\";\r\nimport { mergeProps } from \"@base-ui/react/merge-props\";\r\nimport { useRender } from \"@base-ui/react/use-render\";\r\nimport { cn } from \"@/lib/utils\";\r\nimport {\r\n  valueToAngle,\r\n  getThumbPosition,\r\n  describeArc,\r\n  roundToStep,\r\n  clamp,\r\n} from \"./lib/angle-calculations\";\r\nimport {\r\n  SVG_CONFIG,\r\n  RADIUS_CONFIG,\r\n  SLOT_NAMES,\r\n  VARIANT_COLOR_MAP,\r\n} from \"./lib/svg-constants\";\r\nimport {\r\n  pixelsToSvgUnits,\r\n  createSvgOverlayProps,\r\n  strokeWidthToSvgUnits,\r\n  getTrackInnerEdge,\r\n} from \"./lib/svg-utils\";\r\nimport { getValueFromPointerPosition } from \"./lib/pointer-utils\";\r\n\r\nexport type ChangeReason = \"drag\" | \"keyboard\" | \"click\";\r\n\r\ninterface CircularSliderContextValue {\r\n  value: number;\r\n  min: number;\r\n  max: number;\r\n  step: number;\r\n  startAngle: number;\r\n  direction: \"clockwise\" | \"counterclockwise\";\r\n  continuous: boolean;\r\n  disabled: boolean;\r\n  size: number;\r\n  variant: \"default\" | \"filled\";\r\n  isDragging: boolean;\r\n  isFocused: boolean;\r\n  handleValueChange: (newValue: number, reason: ChangeReason) => void;\r\n  handleValueCommitted: (value: number) => void;\r\n}\r\n\r\nconst CircularSliderContext = React.createContext<\r\n  CircularSliderContextValue | undefined\r\n>(undefined);\r\n\r\nfunction useCircularSliderContext() {\r\n  const context = React.useContext(CircularSliderContext);\r\n  if (!context) {\r\n    throw new Error(\r\n      \"CircularSlider components must be used within CircularSliderRoot\",\r\n    );\r\n  }\r\n  return context;\r\n}\r\n\r\nconst circularSliderVariants = cva(\r\n  \"group relative inline-block touch-none select-none outline-none\",\r\n  {\r\n    variants: {\r\n      variant: {\r\n        default: \"\",\r\n        filled: \"\",\r\n      },\r\n      disabled: {\r\n        true: \"opacity-60 cursor-not-allowed pointer-events-none\",\r\n        false: \"\",\r\n      },\r\n    },\r\n    defaultVariants: {\r\n      variant: \"default\",\r\n      disabled: false,\r\n    },\r\n  },\r\n);\r\n\r\nconst thumbVariants = cva(\r\n  \"absolute transition-[box-shadow] duration-200 ease-out\",\r\n  {\r\n    variants: {\r\n      variant: {\r\n        default:\r\n          \"rounded-full border-3 border-primary bg-background group-data-focused:z-10 group-data-focused:ring-2 group-data-focused:ring-ring group-data-focused:ring-offset-2 group-data-focused:ring-offset-background\",\r\n        filled: \"rounded-none bg-foreground\",\r\n      },\r\n      dragging: {\r\n        true: \"cursor-grabbing\",\r\n        false: \"cursor-grab\",\r\n      },\r\n    },\r\n    defaultVariants: {\r\n      variant: \"default\",\r\n      dragging: false,\r\n    },\r\n  },\r\n);\r\n\r\n/**\r\n * Wraps `value` into [min, max] for continuous mode, detecting boundary\r\n * crossings via `previousValue` so crossing max→min resolves to min (not max)\r\n * and vice-versa.\r\n */\r\nfunction wrapValueWithDirection(\r\n  value: number,\r\n  min: number,\r\n  max: number,\r\n  previousValue: number | null,\r\n): number {\r\n  const range = max - min;\r\n\r\n  if (previousValue !== null) {\r\n    const diff = value - previousValue;\r\n    const absDiff = Math.abs(diff);\r\n\r\n    if (absDiff > range / 2) {\r\n      if (previousValue > min + range * 0.75 && value < min + range * 0.25) {\r\n        return min;\r\n      }\r\n      if (previousValue < min + range * 0.25 && value > min + range * 0.75) {\r\n        return max;\r\n      }\r\n    }\r\n  }\r\n\r\n  if (value > max) {\r\n    return min + (value - max);\r\n  }\r\n  if (value < min) {\r\n    return max - (min - value);\r\n  }\r\n\r\n  return value;\r\n}\r\n\r\nexport interface CircularSliderRootProps\r\n  extends\r\n    Omit<\r\n      useRender.ComponentProps<\"div\">,\r\n      \"onChange\" | \"defaultValue\" | \"aria-valuetext\"\r\n    >,\r\n    VariantProps<typeof circularSliderVariants> {\r\n  value?: number;\r\n  defaultValue?: number;\r\n  onValueChange?: (value: number, reason: ChangeReason) => void;\r\n  onValueCommitted?: (value: number) => void;\r\n  min?: number;\r\n  max?: number;\r\n  step?: number;\r\n  largeStep?: number;\r\n  startAngle?: number;\r\n  direction?: \"clockwise\" | \"counterclockwise\";\r\n  continuous?: boolean;\r\n  disabled?: boolean;\r\n  size?: number;\r\n  // Form integration props\r\n  name?: string;\r\n  id?: string;\r\n  required?: boolean;\r\n  form?: string;\r\n  // Accessibility props\r\n  \"aria-label\"?: string;\r\n  \"aria-labelledby\"?: string;\r\n  \"aria-describedby\"?: string;\r\n  \"aria-valuetext\"?: string | ((value: number) => string);\r\n}\r\n\r\nexport function CircularSliderRoot({\r\n  className,\r\n  render,\r\n  ref: forwardedRef,\r\n  value: valueProp,\r\n  defaultValue = 0,\r\n  onValueChange,\r\n  onValueCommitted,\r\n  min = 0,\r\n  max = 100,\r\n  step = 1,\r\n  largeStep = 10,\r\n  startAngle = 0,\r\n  direction = \"clockwise\",\r\n  continuous = true,\r\n  disabled = false,\r\n  size = 96,\r\n  variant = \"default\",\r\n  // Form integration props\r\n  name,\r\n  id,\r\n  required,\r\n  form: formProp,\r\n  // Accessibility props\r\n  \"aria-label\": ariaLabel,\r\n  \"aria-labelledby\": ariaLabelledby,\r\n  \"aria-describedby\": ariaDescribedby,\r\n  \"aria-valuetext\": ariaValuetext,\r\n  ...props\r\n}: CircularSliderRootProps) {\r\n  const isControlled = valueProp !== undefined;\r\n  const [internalValue, setInternalValue] = React.useState(defaultValue);\r\n  const value = isControlled ? valueProp : internalValue;\r\n\r\n  const [isDragging, setIsDragging] = React.useState(false);\r\n  const [isFocused, setIsFocused] = React.useState(false);\r\n  const inputRef = React.useRef<HTMLInputElement>(null);\r\n  const previousValue = React.useRef<number | null>(null);\r\n  // Focus modality: `:focus-visible` can't distinguish pointer-initiated\r\n  // programmatic focus on a range input (inputs that accept keyboard always\r\n  // match it), so track it ourselves. Set before the pointer-down focus call,\r\n  // consumed by the input's onFocus.\r\n  const focusFromPointer = React.useRef(false);\r\n\r\n  const handleValueChange = React.useCallback(\r\n    (newValue: number, reason: ChangeReason) => {\r\n      if (disabled) return;\r\n\r\n      let processedValue = roundToStep(newValue, step, min);\r\n\r\n      if (continuous) {\r\n        processedValue = wrapValueWithDirection(\r\n          processedValue,\r\n          min,\r\n          max,\r\n          previousValue.current,\r\n        );\r\n      } else {\r\n        processedValue = clamp(processedValue, min, max);\r\n      }\r\n\r\n      previousValue.current = processedValue;\r\n\r\n      if (!isControlled) {\r\n        setInternalValue(processedValue);\r\n      }\r\n\r\n      onValueChange?.(processedValue, reason);\r\n    },\r\n    [disabled, step, min, max, continuous, isControlled, onValueChange],\r\n  );\r\n\r\n  const handleValueCommitted = React.useCallback(\r\n    (committedValue: number) => {\r\n      if (disabled) return;\r\n      onValueCommitted?.(committedValue);\r\n    },\r\n    [disabled, onValueCommitted],\r\n  );\r\n\r\n  const handlePointerDown = React.useCallback(\r\n    (e: React.PointerEvent<HTMLDivElement>) => {\r\n      if (disabled) return;\r\n\r\n      // `currentTarget` is always the root div; no element ref needed.\r\n      const container = e.currentTarget;\r\n\r\n      const newValue = getValueFromPointerPosition(\r\n        e,\r\n        container,\r\n        min,\r\n        max,\r\n        startAngle,\r\n        direction,\r\n        continuous,\r\n      );\r\n\r\n      previousValue.current = value;\r\n\r\n      setIsDragging(true);\r\n      (e.target as HTMLElement).setPointerCapture(e.pointerId);\r\n      // Move focus to the hidden input so arrow keys work right after a\r\n      // pointer interaction. `preventDefault` stops the compatibility\r\n      // mousedown's native focus action from undoing it a moment later;\r\n      // `focusFromPointer` keeps the focus ring hidden for this path (see the\r\n      // input's onFocus).\r\n      e.preventDefault();\r\n      focusFromPointer.current = true;\r\n      inputRef.current?.focus({ preventScroll: true });\r\n\r\n      handleValueChange(newValue, \"drag\");\r\n    },\r\n    [\r\n      disabled,\r\n      value,\r\n      min,\r\n      max,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n      handleValueChange,\r\n    ],\r\n  );\r\n\r\n  const handlePointerMove = React.useCallback(\r\n    (e: React.PointerEvent<HTMLDivElement>) => {\r\n      if (!isDragging || disabled) return;\r\n\r\n      const container = e.currentTarget;\r\n\r\n      const newValue = getValueFromPointerPosition(\r\n        e,\r\n        container,\r\n        min,\r\n        max,\r\n        startAngle,\r\n        direction,\r\n        continuous,\r\n      );\r\n\r\n      handleValueChange(newValue, \"drag\");\r\n    },\r\n    [\r\n      isDragging,\r\n      disabled,\r\n      min,\r\n      max,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n      handleValueChange,\r\n    ],\r\n  );\r\n\r\n  const handlePointerUp = React.useCallback(\r\n    (e: React.PointerEvent) => {\r\n      if (!isDragging) return;\r\n\r\n      setIsDragging(false);\r\n      // Guarded: on the cancel/lost-capture paths capture may already be gone,\r\n      // and releasing an inactive pointer throws `NotFoundError`.\r\n      const target = e.target as HTMLElement;\r\n      if (target.hasPointerCapture(e.pointerId)) {\r\n        target.releasePointerCapture(e.pointerId);\r\n      }\r\n      handleValueCommitted(value);\r\n    },\r\n    [isDragging, value, handleValueCommitted],\r\n  );\r\n\r\n  const handleInputChange = React.useCallback(\r\n    (e: React.ChangeEvent<HTMLInputElement>) => {\r\n      previousValue.current = value;\r\n      handleValueChange(parseFloat(e.target.value), \"keyboard\");\r\n    },\r\n    [value, handleValueChange],\r\n  );\r\n\r\n  // Only PageUp/PageDown need handling — arrows, Home, End are native range input behavior.\r\n  const handleInputKeyDown = React.useCallback(\r\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\r\n      if (disabled) return;\r\n\r\n      // Keyboard use while focused = keyboard modality: show the ring even if\r\n      // focus originally arrived via pointer (mirrors :focus-visible).\r\n      setIsFocused(true);\r\n\r\n      previousValue.current = value;\r\n\r\n      if (e.key === \"PageUp\") {\r\n        e.preventDefault();\r\n        handleValueChange(value + largeStep, \"keyboard\");\r\n      } else if (e.key === \"PageDown\") {\r\n        e.preventDefault();\r\n        handleValueChange(value - largeStep, \"keyboard\");\r\n      }\r\n    },\r\n    [disabled, value, largeStep, handleValueChange],\r\n  );\r\n\r\n  const contextValue: CircularSliderContextValue = React.useMemo(\r\n    () => ({\r\n      value,\r\n      min,\r\n      max,\r\n      step,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n      disabled,\r\n      size,\r\n      variant: variant ?? \"default\",\r\n      isDragging,\r\n      isFocused,\r\n      handleValueChange,\r\n      handleValueCommitted,\r\n    }),\r\n    [\r\n      value,\r\n      min,\r\n      max,\r\n      step,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n      disabled,\r\n      size,\r\n      variant,\r\n      isDragging,\r\n      isFocused,\r\n      handleValueChange,\r\n      handleValueCommitted,\r\n    ],\r\n  );\r\n\r\n  const defaultProps = {\r\n    \"data-slot\": SLOT_NAMES.ROOT,\r\n    \"data-size\": size,\r\n    \"data-variant\": variant,\r\n    \"data-dragging\": isDragging || undefined,\r\n    \"data-disabled\": disabled || undefined,\r\n    \"data-continuous\": continuous || undefined,\r\n    \"data-focused\": isFocused || undefined,\r\n    className: cn(circularSliderVariants({ disabled }), className),\r\n    style: {\r\n      width: `${size}px`,\r\n      height: `${size}px`,\r\n    },\r\n    onPointerDown: handlePointerDown,\r\n    onPointerMove: handlePointerMove,\r\n    onPointerUp: handlePointerUp,\r\n    // Mirror pointer-up so an OS-cancelled gesture (touch interruption, context\r\n    // menu) or any lost capture still clears `isDragging` and commits — without\r\n    // these, `pointerup` never fires and the drag stays stuck. `handlePointerUp`'s\r\n    // `isDragging` guard makes the post-`pointerup` `lostpointercapture` a no-op.\r\n    onPointerCancel: handlePointerUp,\r\n    onLostPointerCapture: handlePointerUp,\r\n  };\r\n\r\n  const element = useRender({\r\n    defaultTagName: \"div\",\r\n    render,\r\n    ref: forwardedRef ?? null,\r\n    // Pointer handlers close over `previousValue` (a ref). mergeProps never\r\n    // invokes them or reads `.current` — the React Compiler false-positive is safe.\r\n    // eslint-disable-next-line react-hooks/refs\r\n    props: mergeProps<\"div\">(defaultProps, props),\r\n  });\r\n\r\n  const resolvedAriaValuetext =\r\n    typeof ariaValuetext === \"function\" ? ariaValuetext(value) : ariaValuetext;\r\n\r\n  return (\r\n    <CircularSliderContext.Provider value={contextValue}>\r\n      <input\r\n        ref={inputRef}\r\n        type=\"range\"\r\n        min={min}\r\n        max={max}\r\n        step={step}\r\n        value={value}\r\n        onChange={handleInputChange}\r\n        onKeyDown={handleInputKeyDown}\r\n        // Show the ring for keyboard focus (Tab) but not for the programmatic\r\n        // focus from a pointer interaction. (`:focus-visible` can't make this\r\n        // distinction — keyboard-accepting inputs always match it.)\r\n        onFocus={() => {\r\n          setIsFocused(!focusFromPointer.current);\r\n          focusFromPointer.current = false;\r\n        }}\r\n        onBlur={() => {\r\n          setIsFocused(false);\r\n          handleValueCommitted(value);\r\n        }}\r\n        disabled={disabled}\r\n        name={name}\r\n        id={id}\r\n        required={required}\r\n        form={formProp}\r\n        aria-label={ariaLabel}\r\n        aria-labelledby={ariaLabelledby}\r\n        aria-describedby={ariaDescribedby}\r\n        aria-valuetext={resolvedAriaValuetext}\r\n        className=\"sr-only\"\r\n      />\r\n      {element}\r\n    </CircularSliderContext.Provider>\r\n  );\r\n}\r\n\r\nexport interface CircularSliderTrackProps extends useRender.ComponentProps<\"svg\"> {\r\n  strokeWidth?: number;\r\n}\r\n\r\nexport function CircularSliderTrack({\r\n  className,\r\n  render,\r\n  strokeWidth,\r\n  ...props\r\n}: CircularSliderTrackProps) {\r\n  const { continuous, size, variant } = useCircularSliderContext();\r\n\r\n  const svgStrokeWidth = strokeWidthToSvgUnits(strokeWidth, size);\r\n\r\n  // For non-continuous mode, render 270° arc from 225° to 135° (gap at bottom)\r\n  const trackArcPath = continuous\r\n    ? null\r\n    : describeArc(\r\n        SVG_CONFIG.CENTER_X,\r\n        SVG_CONFIG.CENTER_Y,\r\n        RADIUS_CONFIG.TRACK,\r\n        225,\r\n        135,\r\n      );\r\n\r\n  const trackElement = (\r\n    <>\r\n      {variant === \"filled\" && (\r\n        <circle\r\n          cx={SVG_CONFIG.CENTER_X}\r\n          cy={SVG_CONFIG.CENTER_Y}\r\n          r={RADIUS_CONFIG.FILLED_BACKGROUND}\r\n          className=\"fill-muted group-data-focused:stroke-ring/50 stroke-transparent outline-0 outline-offset-0 outline-transparent transition-[outline-width,outline-offset,outline-color,stroke,fill] duration-100 ease-out outline-solid group-data-focused:stroke-4\"\r\n          strokeWidth=\"2\"\r\n        />\r\n      )}\r\n\r\n      {continuous ? (\r\n        <circle\r\n          cx={SVG_CONFIG.CENTER_X}\r\n          cy={SVG_CONFIG.CENTER_Y}\r\n          r={RADIUS_CONFIG.TRACK}\r\n          fill=\"none\"\r\n          strokeWidth={svgStrokeWidth}\r\n          className={cn(\r\n            VARIANT_COLOR_MAP.track[\r\n              variant as keyof typeof VARIANT_COLOR_MAP.track\r\n            ],\r\n            \"transition-colors\",\r\n          )}\r\n        />\r\n      ) : (\r\n        <path\r\n          d={trackArcPath || \"\"}\r\n          fill=\"none\"\r\n          strokeWidth={svgStrokeWidth}\r\n          strokeLinecap=\"round\"\r\n          className={cn(\r\n            VARIANT_COLOR_MAP.track[\r\n              variant as keyof typeof VARIANT_COLOR_MAP.track\r\n            ],\r\n            \"transition-colors\",\r\n          )}\r\n        />\r\n      )}\r\n    </>\r\n  );\r\n\r\n  const defaultProps = createSvgOverlayProps(SLOT_NAMES.TRACK, className);\r\n\r\n  const element = useRender({\r\n    defaultTagName: \"svg\",\r\n    render,\r\n    props: mergeProps<\"svg\">(defaultProps, {\r\n      ...props,\r\n      children: trackElement,\r\n    }),\r\n  });\r\n\r\n  return element;\r\n}\r\n\r\nexport interface CircularSliderIndicatorProps extends useRender.ComponentProps<\"svg\"> {\r\n  strokeWidth?: number;\r\n}\r\n\r\nexport function CircularSliderIndicator({\r\n  className,\r\n  render,\r\n  strokeWidth,\r\n  ...props\r\n}: CircularSliderIndicatorProps) {\r\n  const { value, min, max, startAngle, direction, continuous, size, variant } =\r\n    useCircularSliderContext();\r\n\r\n  const svgStrokeWidth = strokeWidthToSvgUnits(strokeWidth, size);\r\n\r\n  // At max in continuous mode, a zero-length arc degenerates — use a full circle instead.\r\n  const isFullCircle = continuous && value === max;\r\n\r\n  let indicatorElement: React.ReactNode = null;\r\n\r\n  if (isFullCircle) {\r\n    indicatorElement = (\r\n      <circle\r\n        cx={SVG_CONFIG.CENTER_X}\r\n        cy={SVG_CONFIG.CENTER_Y}\r\n        r={RADIUS_CONFIG.TRACK}\r\n        fill=\"none\"\r\n        strokeWidth={svgStrokeWidth}\r\n        strokeLinecap=\"round\"\r\n        className={cn(\r\n          VARIANT_COLOR_MAP.indicator[\r\n            variant as keyof typeof VARIANT_COLOR_MAP.indicator\r\n          ],\r\n          \"transition-colors\",\r\n        )}\r\n      />\r\n    );\r\n  } else {\r\n    const arcStartAngle = valueToAngle(\r\n      min,\r\n      min,\r\n      max,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n    );\r\n    const arcEndAngle = valueToAngle(\r\n      value,\r\n      min,\r\n      max,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n    );\r\n    const arcPath = describeArc(\r\n      SVG_CONFIG.CENTER_X,\r\n      SVG_CONFIG.CENTER_Y,\r\n      RADIUS_CONFIG.TRACK,\r\n      arcStartAngle,\r\n      arcEndAngle,\r\n      direction,\r\n    );\r\n\r\n    indicatorElement = arcPath ? (\r\n      <path\r\n        d={arcPath}\r\n        fill=\"none\"\r\n        strokeWidth={svgStrokeWidth}\r\n        strokeLinecap=\"round\"\r\n        className={cn(\r\n          VARIANT_COLOR_MAP.indicator[\r\n            variant as keyof typeof VARIANT_COLOR_MAP.indicator\r\n          ],\r\n          \"transition-colors\",\r\n        )}\r\n      />\r\n    ) : null;\r\n  }\r\n\r\n  const defaultProps = createSvgOverlayProps(\r\n    SLOT_NAMES.INDICATOR,\r\n    className,\r\n    false,\r\n  );\r\n\r\n  const element = useRender({\r\n    defaultTagName: \"svg\",\r\n    render,\r\n    props: mergeProps<\"svg\">(defaultProps, {\r\n      ...props,\r\n      children: indicatorElement,\r\n    }),\r\n  });\r\n\r\n  return element;\r\n}\r\n\r\nexport interface CircularSliderThumbProps extends useRender.ComponentProps<\"div\"> {\r\n  size?: number;\r\n}\r\n\r\nexport function CircularSliderThumb({\r\n  className,\r\n  render,\r\n  size: thumbSize = 16,\r\n  ...props\r\n}: CircularSliderThumbProps) {\r\n  const {\r\n    value,\r\n    min,\r\n    max,\r\n    startAngle,\r\n    direction,\r\n    continuous,\r\n    isDragging,\r\n    size: containerSize,\r\n    variant,\r\n  } = useCircularSliderContext();\r\n\r\n  const angle = valueToAngle(\r\n    value,\r\n    min,\r\n    max,\r\n    startAngle,\r\n    direction,\r\n    continuous,\r\n  );\r\n\r\n  let radius = RADIUS_CONFIG.TRACK;\r\n  if (variant === \"filled\") {\r\n    const thumbSizeInSvgUnits = pixelsToSvgUnits(thumbSize, containerSize);\r\n    // Center offset so the thumb's outer tip aligns with the background circle edge.\r\n    radius = RADIUS_CONFIG.FILLED_BACKGROUND - thumbSizeInSvgUnits / 2;\r\n  }\r\n\r\n  const thumbPos = getThumbPosition(\r\n    angle,\r\n    radius,\r\n    SVG_CONFIG.CENTER_X,\r\n    SVG_CONFIG.CENTER_Y,\r\n  );\r\n  const leftPercent = (thumbPos.x / SVG_CONFIG.VIEWBOX_SIZE) * 100;\r\n  const topPercent = (thumbPos.y / SVG_CONFIG.VIEWBOX_SIZE) * 100;\r\n\r\n  // Filled thumb is a radial line — rotate by `angle` so it points toward the center.\r\n  const transform =\r\n    variant === \"filled\"\r\n      ? `translate(-50%, -50%) rotate(${angle}deg)`\r\n      : \"translate(-50%, -50%)\";\r\n\r\n  // Dynamic sizing styles\r\n  const thumbStyles: React.CSSProperties = {\r\n    left: `${leftPercent}%`,\r\n    top: `${topPercent}%`,\r\n    transform,\r\n    ...(variant === \"default\"\r\n      ? {\r\n          width: `${thumbSize}px`,\r\n          height: `${thumbSize}px`,\r\n        }\r\n      : {\r\n          width: \"2px\",\r\n          height: `${thumbSize}px`,\r\n        }),\r\n  };\r\n\r\n  const defaultProps = {\r\n    \"data-slot\": SLOT_NAMES.THUMB,\r\n    className: cn(thumbVariants({ variant, dragging: isDragging }), className),\r\n    style: thumbStyles,\r\n    role: \"presentation\",\r\n    \"aria-hidden\": true,\r\n  };\r\n\r\n  const element = useRender({\r\n    defaultTagName: \"div\",\r\n    render,\r\n    props: mergeProps<\"div\">(defaultProps, props),\r\n  });\r\n\r\n  return element;\r\n}\r\n\r\nexport interface CircularSliderValueProps extends useRender.ComponentProps<\"div\"> {\r\n  formatValue?: (value: number) => string;\r\n}\r\n\r\nexport function CircularSliderValue({\r\n  className,\r\n  render,\r\n  formatValue: formatValueProp,\r\n  ...props\r\n}: CircularSliderValueProps) {\r\n  const { value } = useCircularSliderContext();\r\n\r\n  const displayValue = formatValueProp\r\n    ? formatValueProp(value)\r\n    : Math.round(value).toString();\r\n\r\n  const defaultProps = {\r\n    \"data-slot\": SLOT_NAMES.VALUE,\r\n    className: cn(\r\n      \"absolute inset-0 flex items-center justify-center font-medium tabular-nums text-sm\",\r\n      className,\r\n    ),\r\n  };\r\n\r\n  const element = useRender({\r\n    defaultTagName: \"div\",\r\n    render,\r\n    props: mergeProps<\"div\">(defaultProps, {\r\n      ...props,\r\n      children: displayValue,\r\n    }),\r\n  });\r\n\r\n  return element;\r\n}\r\n\r\nexport interface CircularSliderMarkersProps extends useRender.ComponentProps<\"svg\"> {\r\n  count?: number;\r\n  length?: number;\r\n}\r\n\r\nexport function CircularSliderMarkers({\r\n  className,\r\n  render,\r\n  count = 12,\r\n  length,\r\n  ...props\r\n}: CircularSliderMarkersProps) {\r\n  const { min, max, startAngle, direction, continuous, size, variant } =\r\n    useCircularSliderContext();\r\n\r\n  // Default marker length: 10px (filled) / 5px (default)\r\n  const defaultLength = variant === \"filled\" ? 10 : 5;\r\n  const markerLengthInPixels = length ?? defaultLength;\r\n  const markerLengthInSvgUnits = pixelsToSvgUnits(markerLengthInPixels, size);\r\n\r\n  const markers = Array.from({ length: count }, (_, i) => {\r\n    const value = min + (i / count) * (max - min);\r\n    const angle = valueToAngle(\r\n      value,\r\n      min,\r\n      max,\r\n      startAngle,\r\n      direction,\r\n      continuous,\r\n    );\r\n\r\n    let outerRadius: number;\r\n    let innerRadius: number;\r\n\r\n    if (variant === \"filled\") {\r\n      outerRadius = RADIUS_CONFIG.FILLED_BACKGROUND;\r\n      innerRadius = RADIUS_CONFIG.FILLED_BACKGROUND - markerLengthInSvgUnits;\r\n    } else {\r\n      const trackInnerEdge = getTrackInnerEdge(16, size);\r\n      outerRadius = trackInnerEdge;\r\n      innerRadius = trackInnerEdge - markerLengthInSvgUnits;\r\n    }\r\n\r\n    const outerPos = getThumbPosition(\r\n      angle,\r\n      outerRadius,\r\n      SVG_CONFIG.CENTER_X,\r\n      SVG_CONFIG.CENTER_Y,\r\n    );\r\n    const innerPos = getThumbPosition(\r\n      angle,\r\n      innerRadius,\r\n      SVG_CONFIG.CENTER_X,\r\n      SVG_CONFIG.CENTER_Y,\r\n    );\r\n\r\n    return {\r\n      value,\r\n      angle,\r\n      outerPos,\r\n      innerPos,\r\n    };\r\n  });\r\n\r\n  // Calculate dynamic marker strokeWidth: scales from md baseline (1.5 at 96px)\r\n  const markerStrokeWidth = (size / 96) * 1.5;\r\n\r\n  const markerElements = (\r\n    <g className=\"stroke-muted-foreground/50\">\r\n      {markers.map((marker, i) => (\r\n        <line\r\n          key={i}\r\n          x1={marker.innerPos.x}\r\n          y1={marker.innerPos.y}\r\n          x2={marker.outerPos.x}\r\n          y2={marker.outerPos.y}\r\n          strokeWidth={markerStrokeWidth}\r\n          strokeLinecap=\"round\"\r\n        />\r\n      ))}\r\n    </g>\r\n  );\r\n\r\n  const defaultProps = createSvgOverlayProps(\r\n    SLOT_NAMES.MARKERS,\r\n    className,\r\n    false,\r\n  );\r\n\r\n  const element = useRender({\r\n    defaultTagName: \"svg\",\r\n    render,\r\n    props: mergeProps<\"svg\">(defaultProps, {\r\n      ...props,\r\n      children: markerElements,\r\n    }),\r\n  });\r\n\r\n  return element;\r\n}\r\n",
      "type": "registry:ui",
      "target": "components/ui/cubby-ui/circular-slider/circular-slider.tsx"
    },
    {
      "path": "registry/default/circular-slider/lib/angle-calculations.ts",
      "content": "/**\n * Utility functions for circular slider angle and position calculations\n */\n\n/**\n * Normalize an angle to 0-360 range\n */\nexport function normalizeAngle(angle: number): number {\n  const normalized = angle % 360;\n  return normalized < 0 ? normalized + 360 : normalized;\n}\n\n/**\n * Convert a value to an angle based on min/max range\n * @param value - The value to convert\n * @param min - Minimum value\n * @param max - Maximum value\n * @param startAngle - Starting angle in degrees (0 = top)\n * @param direction - Direction of rotation\n * @param continuous - Whether the slider is in continuous mode (360° arc) or non-continuous mode (270° arc)\n * @returns Angle in degrees\n */\nexport function valueToAngle(\n  value: number,\n  min: number,\n  max: number,\n  startAngle: number = 0,\n  direction: \"clockwise\" | \"counterclockwise\" = \"clockwise\",\n  continuous: boolean = true,\n): number {\n  const range = max - min;\n  // Use 270° arc for non-continuous mode so min and max are visually separated\n  // Use 360° arc for continuous mode (full circle)\n  const arcDegrees = continuous ? 360 : 270;\n  // For non-continuous mode, offset by 225° to center the gap at bottom (180°)\n  // Gap will be from 135° to 225° (90° gap centered at 180° = bottom)\n  const offset = continuous ? 0 : 225;\n  const normalizedValue = ((value - min) / range) * arcDegrees;\n  const angle =\n    direction === \"clockwise\"\n      ? startAngle + normalizedValue + offset\n      : startAngle - normalizedValue + offset;\n  return normalizeAngle(angle);\n}\n\n/**\n * Convert an angle to a value based on min/max range\n * @param angle - Angle in degrees\n * @param min - Minimum value\n * @param max - Maximum value\n * @param startAngle - Starting angle in degrees\n * @param direction - Direction of rotation\n * @param continuous - Whether the slider is in continuous mode (360° arc) or non-continuous mode (270° arc)\n * @returns The calculated value\n */\nexport function angleToValue(\n  angle: number,\n  min: number,\n  max: number,\n  startAngle: number = 0,\n  direction: \"clockwise\" | \"counterclockwise\" = \"clockwise\",\n  continuous: boolean = true,\n): number {\n  // For non-continuous mode, clamp angles that fall in the gap area\n  if (!continuous) {\n    const normalizedAngle = normalizeAngle(angle);\n\n    // Gap is centered at 180° (bottom), spanning 135° to 225° (90° gap)\n    if (normalizedAngle >= 135 && normalizedAngle <= 225) {\n      // Clamp to nearest edge of the gap\n      // 135° to 180° → clamp to 135° (max value position)\n      // 180° to 225° → clamp to 225° (min value position)\n      angle = normalizedAngle <= 180 ? 135 : 225;\n    }\n  }\n\n  // For non-continuous mode, subtract the 225° offset to account for gap positioning\n  const offset = continuous ? 0 : 225;\n  const adjustedAngle = angle - offset;\n  const normalizedAngle = normalizeAngle(adjustedAngle);\n  const normalizedStart = normalizeAngle(startAngle);\n\n  let diff =\n    direction === \"clockwise\"\n      ? normalizedAngle - normalizedStart\n      : normalizedStart - normalizedAngle;\n\n  if (diff < 0) diff += 360;\n\n  const range = max - min;\n  // Use 270° arc for non-continuous mode so min and max are visually separated\n  // Use 360° arc for continuous mode (full circle)\n  const arcDegrees = continuous ? 360 : 270;\n  const value = min + (diff / arcDegrees) * range;\n\n  return value;\n}\n\n/**\n * Calculate angle from mouse/touch position relative to circle center\n * @param x - X coordinate\n * @param y - Y coordinate\n * @param centerX - Circle center X\n * @param centerY - Circle center Y\n * @returns Angle in degrees (0 = top, increases clockwise)\n */\nexport function positionToAngle(\n  x: number,\n  y: number,\n  centerX: number,\n  centerY: number,\n): number {\n  const radians = Math.atan2(y - centerY, x - centerX);\n  // Convert to degrees and adjust so 0° is at top (subtract 90°)\n  const degrees = radians * (180 / Math.PI) + 90;\n  return normalizeAngle(degrees);\n}\n\n/**\n * Calculate thumb position on circle based on angle\n * @param angle - Angle in degrees (0 = top)\n * @param radius - Circle radius\n * @param centerX - Circle center X\n * @param centerY - Circle center Y\n * @returns Object with x and y coordinates\n */\nexport function getThumbPosition(\n  angle: number,\n  radius: number,\n  centerX: number = 100,\n  centerY: number = 100,\n): { x: number; y: number } {\n  // Subtract 90° because 0° should be at top\n  const radians = ((angle - 90) * Math.PI) / 180;\n  // Round to 4 decimal places to avoid hydration errors from floating-point precision\n  const round = (n: number) => Math.round(n * 10000) / 10000;\n  return {\n    x: round(centerX + radius * Math.cos(radians)),\n    y: round(centerY + radius * Math.sin(radians)),\n  };\n}\n\n/**\n * Create SVG arc path for the indicator\n * @param centerX - Circle center X\n * @param centerY - Circle center Y\n * @param radius - Circle radius\n * @param startAngle - Start angle in degrees (0 = top)\n * @param endAngle - End angle in degrees\n * @param direction - Direction to draw the arc (clockwise or counterclockwise)\n * @returns SVG path string\n */\nexport function describeArc(\n  centerX: number,\n  centerY: number,\n  radius: number,\n  startAngle: number,\n  endAngle: number,\n  direction: \"clockwise\" | \"counterclockwise\" = \"clockwise\",\n): string {\n  const start = getThumbPosition(startAngle, radius, centerX, centerY);\n  const end = getThumbPosition(endAngle, radius, centerX, centerY);\n\n  const normalizedStart = normalizeAngle(startAngle);\n  const normalizedEnd = normalizeAngle(endAngle);\n\n  let arcAngle =\n    direction === \"clockwise\"\n      ? normalizedEnd - normalizedStart\n      : normalizedStart - normalizedEnd;\n  if (arcAngle < 0) arcAngle += 360;\n\n  const largeArcFlag = arcAngle > 180 ? 1 : 0;\n  // Sweep flag: 1 for clockwise, 0 for counterclockwise\n  const sweepFlag = direction === \"clockwise\" ? 1 : 0;\n\n  if (arcAngle === 0) {\n    return \"\";\n  }\n\n  return [\n    \"M\",\n    start.x,\n    start.y,\n    \"A\",\n    radius,\n    radius,\n    0,\n    largeArcFlag,\n    sweepFlag,\n    end.x,\n    end.y,\n  ].join(\" \");\n}\n\n/**\n * Round a value to the nearest step\n * @param value - Value to round\n * @param step - Step size\n * @param min - Minimum value (for precision)\n * @returns Rounded value\n */\nexport function roundToStep(value: number, step: number, min: number): number {\n  const steps = Math.round((value - min) / step);\n  return min + steps * step;\n}\n\n/**\n * Clamp a value between min and max\n */\nexport function clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/circular-slider/lib/angle-calculations.ts"
    },
    {
      "path": "registry/default/circular-slider/lib/pointer-utils.ts",
      "content": "import * as React from \"react\";\nimport { positionToAngle, angleToValue } from \"./angle-calculations\";\n\n/**\n * Get the center coordinates of a circle container\n */\nexport function getCircleCenter(container: HTMLElement): {\n  x: number;\n  y: number;\n} {\n  const rect = container.getBoundingClientRect();\n  return {\n    x: rect.left + rect.width / 2,\n    y: rect.top + rect.height / 2,\n  };\n}\n\n/**\n * Convert pointer position to slider value\n * Consolidates the angle and value calculation logic\n */\nexport function getValueFromPointerPosition(\n  e: React.PointerEvent,\n  container: HTMLElement,\n  min: number,\n  max: number,\n  startAngle: number,\n  direction: \"clockwise\" | \"counterclockwise\",\n  continuous: boolean,\n): number {\n  const { x: centerX, y: centerY } = getCircleCenter(container);\n  const angle = positionToAngle(e.clientX, e.clientY, centerX, centerY);\n  return angleToValue(angle, min, max, startAngle, direction, continuous);\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/circular-slider/lib/pointer-utils.ts"
    },
    {
      "path": "registry/default/circular-slider/lib/svg-constants.ts",
      "content": "/**\n * SVG Constants for Circular Slider\n * Centralized configuration for SVG coordinates, dimensions, and styling\n */\n\nexport const DEFAULT_STROKE_WIDTH = 16;\n\nexport const SVG_CONFIG = {\n  VIEWBOX_SIZE: 200,\n  CENTER_X: 100,\n  CENTER_Y: 100,\n  VIEWBOX_STRING: \"0 0 200 200\",\n} as const;\n\nexport const RADIUS_CONFIG = {\n  TRACK: 80,\n  FILLED_BACKGROUND: 90,\n} as const;\n\nexport const SLOT_NAMES = {\n  ROOT: \"circular-slider\",\n  TRACK: \"circular-slider-track\",\n  INDICATOR: \"circular-slider-indicator\",\n  THUMB: \"circular-slider-thumb\",\n  VALUE: \"circular-slider-value\",\n  MARKERS: \"circular-slider-markers\",\n} as const;\n\nexport const VARIANT_COLOR_MAP = {\n  track: {\n    default: \"stroke-border\",\n    filled: \"stroke-transparent\",\n  },\n  indicator: {\n    default: \"stroke-primary\",\n    filled: \"stroke-transparent\",\n  },\n} as const;\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/circular-slider/lib/svg-constants.ts"
    },
    {
      "path": "registry/default/circular-slider/lib/svg-utils.ts",
      "content": "import { cn } from \"@/lib/utils\";\nimport {\n  SVG_CONFIG,\n  RADIUS_CONFIG,\n  DEFAULT_STROKE_WIDTH,\n} from \"./svg-constants\";\n\n/**\n * Convert pixels to SVG viewBox units\n */\nexport function pixelsToSvgUnits(\n  pixels: number,\n  containerSize: number,\n): number {\n  return pixels * (SVG_CONFIG.VIEWBOX_SIZE / containerSize);\n}\n\n/**\n * Calculate the inner edge of the track based on stroke width\n */\nexport function getTrackInnerEdge(\n  strokeWidthInPixels: number,\n  containerSize: number,\n): number {\n  const strokeWidthInSvgUnits = pixelsToSvgUnits(\n    strokeWidthInPixels,\n    containerSize,\n  );\n  return RADIUS_CONFIG.TRACK - strokeWidthInSvgUnits / 2;\n}\n\n/**\n * Create common props for SVG overlay elements\n */\nexport function createSvgOverlayProps(\n  slot: string,\n  className?: string,\n  pointerEvents: boolean = true,\n) {\n  return {\n    \"data-slot\": slot,\n    viewBox: SVG_CONFIG.VIEWBOX_STRING,\n    className: cn(\n      \"absolute inset-0 w-full h-full\",\n      !pointerEvents && \"pointer-events-none\",\n      className,\n    ),\n  };\n}\n\n/**\n * Convert strokeWidth prop (in pixels) to SVG units\n * Returns the converted value or default if not provided\n */\nexport function strokeWidthToSvgUnits(\n  strokeWidth: number | undefined,\n  size: number,\n): number {\n  const pixels = strokeWidth ?? DEFAULT_STROKE_WIDTH;\n  return pixelsToSvgUnits(pixels, size);\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/circular-slider/lib/svg-utils.ts"
    }
  ],
  "type": "registry:ui"
}