{
  "$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\";\n\nimport * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  valueToAngle,\n  getThumbPosition,\n  describeArc,\n  roundToStep,\n  clamp,\n} from \"./lib/angle-calculations\";\nimport {\n  SVG_CONFIG,\n  RADIUS_CONFIG,\n  SLOT_NAMES,\n  VARIANT_COLOR_MAP,\n} from \"./lib/svg-constants\";\nimport {\n  pixelsToSvgUnits,\n  createSvgOverlayProps,\n  strokeWidthToSvgUnits,\n  getTrackInnerEdge,\n} from \"./lib/svg-utils\";\nimport { getValueFromPointerPosition } from \"./lib/pointer-utils\";\n\nexport type ChangeReason = \"drag\" | \"keyboard\" | \"click\";\n\ninterface CircularSliderContextValue {\n  value: number;\n  min: number;\n  max: number;\n  step: number;\n  startAngle: number;\n  direction: \"clockwise\" | \"counterclockwise\";\n  continuous: boolean;\n  disabled: boolean;\n  size: number;\n  variant: \"default\" | \"filled\";\n  isDragging: boolean;\n  isFocused: boolean;\n  handleValueChange: (newValue: number, reason: ChangeReason) => void;\n  handleValueCommitted: (value: number) => void;\n}\n\nconst CircularSliderContext = React.createContext<\n  CircularSliderContextValue | undefined\n>(undefined);\n\nfunction useCircularSliderContext() {\n  const context = React.useContext(CircularSliderContext);\n  if (!context) {\n    throw new Error(\n      \"CircularSlider components must be used within CircularSliderRoot\",\n    );\n  }\n  return context;\n}\n\nconst circularSliderVariants = cva(\n  \"group relative inline-block touch-none select-none outline-none\",\n  {\n    variants: {\n      variant: {\n        default: \"\",\n        filled: \"\",\n      },\n      disabled: {\n        true: \"opacity-60 cursor-not-allowed pointer-events-none\",\n        false: \"\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      disabled: false,\n    },\n  },\n);\n\nconst thumbVariants = cva(\n  \"absolute transition-[box-shadow] duration-200 ease-out\",\n  {\n    variants: {\n      variant: {\n        default:\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\",\n        filled: \"rounded-none bg-foreground\",\n      },\n      dragging: {\n        true: \"cursor-grabbing\",\n        false: \"cursor-grab\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      dragging: false,\n    },\n  },\n);\n\n/**\n * Wraps `value` into [min, max] for continuous mode, detecting boundary\n * crossings via `previousValue` so crossing max→min resolves to min (not max)\n * and vice-versa.\n */\nfunction wrapValueWithDirection(\n  value: number,\n  min: number,\n  max: number,\n  previousValue: number | null,\n): number {\n  const range = max - min;\n\n  if (previousValue !== null) {\n    const diff = value - previousValue;\n    const absDiff = Math.abs(diff);\n\n    if (absDiff > range / 2) {\n      if (previousValue > min + range * 0.75 && value < min + range * 0.25) {\n        return min;\n      }\n      if (previousValue < min + range * 0.25 && value > min + range * 0.75) {\n        return max;\n      }\n    }\n  }\n\n  if (value > max) {\n    return min + (value - max);\n  }\n  if (value < min) {\n    return max - (min - value);\n  }\n\n  return value;\n}\n\nexport interface CircularSliderRootProps\n  extends\n    Omit<\n      useRender.ComponentProps<\"div\">,\n      \"onChange\" | \"defaultValue\" | \"aria-valuetext\"\n    >,\n    VariantProps<typeof circularSliderVariants> {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number, reason: ChangeReason) => void;\n  onValueCommitted?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  largeStep?: number;\n  startAngle?: number;\n  direction?: \"clockwise\" | \"counterclockwise\";\n  continuous?: boolean;\n  disabled?: boolean;\n  size?: number;\n  // Form integration props\n  name?: string;\n  id?: string;\n  required?: boolean;\n  form?: string;\n  // Accessibility props\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  \"aria-describedby\"?: string;\n  \"aria-valuetext\"?: string | ((value: number) => string);\n}\n\nexport function CircularSliderRoot({\n  className,\n  render,\n  ref: forwardedRef,\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  onValueCommitted,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep = 10,\n  startAngle = 0,\n  direction = \"clockwise\",\n  continuous = true,\n  disabled = false,\n  size = 96,\n  variant = \"default\",\n  // Form integration props\n  name,\n  id,\n  required,\n  form: formProp,\n  // Accessibility props\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  \"aria-describedby\": ariaDescribedby,\n  \"aria-valuetext\": ariaValuetext,\n  ...props\n}: CircularSliderRootProps) {\n  const isControlled = valueProp !== undefined;\n  const [internalValue, setInternalValue] = React.useState(defaultValue);\n  const value = isControlled ? valueProp : internalValue;\n\n  const [isDragging, setIsDragging] = React.useState(false);\n  const [isFocused, setIsFocused] = React.useState(false);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const previousValue = React.useRef<number | null>(null);\n  // Focus modality: `:focus-visible` can't distinguish pointer-initiated\n  // programmatic focus on a range input (inputs that accept keyboard always\n  // match it), so track it ourselves. Set before the pointer-down focus call,\n  // consumed by the input's onFocus.\n  const focusFromPointer = React.useRef(false);\n\n  const handleValueChange = React.useCallback(\n    (newValue: number, reason: ChangeReason) => {\n      if (disabled) return;\n\n      let processedValue = roundToStep(newValue, step, min);\n\n      if (continuous) {\n        processedValue = wrapValueWithDirection(\n          processedValue,\n          min,\n          max,\n          previousValue.current,\n        );\n      } else {\n        processedValue = clamp(processedValue, min, max);\n      }\n\n      previousValue.current = processedValue;\n\n      if (!isControlled) {\n        setInternalValue(processedValue);\n      }\n\n      onValueChange?.(processedValue, reason);\n    },\n    [disabled, step, min, max, continuous, isControlled, onValueChange],\n  );\n\n  const handleValueCommitted = React.useCallback(\n    (committedValue: number) => {\n      if (disabled) return;\n      onValueCommitted?.(committedValue);\n    },\n    [disabled, onValueCommitted],\n  );\n\n  const handlePointerDown = React.useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n\n      // `currentTarget` is always the root div; no element ref needed.\n      const container = e.currentTarget;\n\n      const newValue = getValueFromPointerPosition(\n        e,\n        container,\n        min,\n        max,\n        startAngle,\n        direction,\n        continuous,\n      );\n\n      previousValue.current = value;\n\n      setIsDragging(true);\n      (e.target as HTMLElement).setPointerCapture(e.pointerId);\n      // Move focus to the hidden input so arrow keys work right after a\n      // pointer interaction. `preventDefault` stops the compatibility\n      // mousedown's native focus action from undoing it a moment later;\n      // `focusFromPointer` keeps the focus ring hidden for this path (see the\n      // input's onFocus).\n      e.preventDefault();\n      focusFromPointer.current = true;\n      inputRef.current?.focus({ preventScroll: true });\n\n      handleValueChange(newValue, \"drag\");\n    },\n    [\n      disabled,\n      value,\n      min,\n      max,\n      startAngle,\n      direction,\n      continuous,\n      handleValueChange,\n    ],\n  );\n\n  const handlePointerMove = React.useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      if (!isDragging || disabled) return;\n\n      const container = e.currentTarget;\n\n      const newValue = getValueFromPointerPosition(\n        e,\n        container,\n        min,\n        max,\n        startAngle,\n        direction,\n        continuous,\n      );\n\n      handleValueChange(newValue, \"drag\");\n    },\n    [\n      isDragging,\n      disabled,\n      min,\n      max,\n      startAngle,\n      direction,\n      continuous,\n      handleValueChange,\n    ],\n  );\n\n  const handlePointerUp = React.useCallback(\n    (e: React.PointerEvent) => {\n      if (!isDragging) return;\n\n      setIsDragging(false);\n      // Guarded: on the cancel/lost-capture paths capture may already be gone,\n      // and releasing an inactive pointer throws `NotFoundError`.\n      const target = e.target as HTMLElement;\n      if (target.hasPointerCapture(e.pointerId)) {\n        target.releasePointerCapture(e.pointerId);\n      }\n      handleValueCommitted(value);\n    },\n    [isDragging, value, handleValueCommitted],\n  );\n\n  const handleInputChange = React.useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      previousValue.current = value;\n      handleValueChange(parseFloat(e.target.value), \"keyboard\");\n    },\n    [value, handleValueChange],\n  );\n\n  // Only PageUp/PageDown need handling — arrows, Home, End are native range input behavior.\n  const handleInputKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (disabled) return;\n\n      // Keyboard use while focused = keyboard modality: show the ring even if\n      // focus originally arrived via pointer (mirrors :focus-visible).\n      setIsFocused(true);\n\n      previousValue.current = value;\n\n      if (e.key === \"PageUp\") {\n        e.preventDefault();\n        handleValueChange(value + largeStep, \"keyboard\");\n      } else if (e.key === \"PageDown\") {\n        e.preventDefault();\n        handleValueChange(value - largeStep, \"keyboard\");\n      }\n    },\n    [disabled, value, largeStep, handleValueChange],\n  );\n\n  const contextValue: CircularSliderContextValue = React.useMemo(\n    () => ({\n      value,\n      min,\n      max,\n      step,\n      startAngle,\n      direction,\n      continuous,\n      disabled,\n      size,\n      variant: variant ?? \"default\",\n      isDragging,\n      isFocused,\n      handleValueChange,\n      handleValueCommitted,\n    }),\n    [\n      value,\n      min,\n      max,\n      step,\n      startAngle,\n      direction,\n      continuous,\n      disabled,\n      size,\n      variant,\n      isDragging,\n      isFocused,\n      handleValueChange,\n      handleValueCommitted,\n    ],\n  );\n\n  const defaultProps = {\n    \"data-slot\": SLOT_NAMES.ROOT,\n    \"data-size\": size,\n    \"data-variant\": variant,\n    \"data-dragging\": isDragging || undefined,\n    \"data-disabled\": disabled || undefined,\n    \"data-continuous\": continuous || undefined,\n    \"data-focused\": isFocused || undefined,\n    className: cn(circularSliderVariants({ disabled }), className),\n    style: {\n      width: `${size}px`,\n      height: `${size}px`,\n    },\n    onPointerDown: handlePointerDown,\n    onPointerMove: handlePointerMove,\n    onPointerUp: handlePointerUp,\n    // Mirror pointer-up so an OS-cancelled gesture (touch interruption, context\n    // menu) or any lost capture still clears `isDragging` and commits — without\n    // these, `pointerup` never fires and the drag stays stuck. `handlePointerUp`'s\n    // `isDragging` guard makes the post-`pointerup` `lostpointercapture` a no-op.\n    onPointerCancel: handlePointerUp,\n    onLostPointerCapture: handlePointerUp,\n  };\n\n  const element = useRender({\n    defaultTagName: \"div\",\n    render,\n    ref: forwardedRef ?? null,\n    // Pointer handlers close over `previousValue` (a ref). mergeProps never\n    // invokes them or reads `.current` — the React Compiler false-positive is safe.\n    // eslint-disable-next-line react-hooks/refs\n    props: mergeProps<\"div\">(defaultProps, props),\n  });\n\n  const resolvedAriaValuetext =\n    typeof ariaValuetext === \"function\" ? ariaValuetext(value) : ariaValuetext;\n\n  return (\n    <CircularSliderContext.Provider value={contextValue}>\n      <input\n        ref={inputRef}\n        type=\"range\"\n        min={min}\n        max={max}\n        step={step}\n        value={value}\n        onChange={handleInputChange}\n        onKeyDown={handleInputKeyDown}\n        // Show the ring for keyboard focus (Tab) but not for the programmatic\n        // focus from a pointer interaction. (`:focus-visible` can't make this\n        // distinction — keyboard-accepting inputs always match it.)\n        onFocus={() => {\n          setIsFocused(!focusFromPointer.current);\n          focusFromPointer.current = false;\n        }}\n        onBlur={() => {\n          setIsFocused(false);\n          handleValueCommitted(value);\n        }}\n        disabled={disabled}\n        name={name}\n        id={id}\n        required={required}\n        form={formProp}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        aria-describedby={ariaDescribedby}\n        aria-valuetext={resolvedAriaValuetext}\n        className=\"sr-only\"\n      />\n      {element}\n    </CircularSliderContext.Provider>\n  );\n}\n\nexport interface CircularSliderTrackProps extends useRender.ComponentProps<\"svg\"> {\n  strokeWidth?: number;\n}\n\nexport function CircularSliderTrack({\n  className,\n  render,\n  strokeWidth,\n  ...props\n}: CircularSliderTrackProps) {\n  const { continuous, size, variant } = useCircularSliderContext();\n\n  const svgStrokeWidth = strokeWidthToSvgUnits(strokeWidth, size);\n\n  // For non-continuous mode, render 270° arc from 225° to 135° (gap at bottom)\n  const trackArcPath = continuous\n    ? null\n    : describeArc(\n        SVG_CONFIG.CENTER_X,\n        SVG_CONFIG.CENTER_Y,\n        RADIUS_CONFIG.TRACK,\n        225,\n        135,\n      );\n\n  const trackElement = (\n    <>\n      {variant === \"filled\" && (\n        <circle\n          cx={SVG_CONFIG.CENTER_X}\n          cy={SVG_CONFIG.CENTER_Y}\n          r={RADIUS_CONFIG.FILLED_BACKGROUND}\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\"\n          strokeWidth=\"2\"\n        />\n      )}\n\n      {continuous ? (\n        <circle\n          cx={SVG_CONFIG.CENTER_X}\n          cy={SVG_CONFIG.CENTER_Y}\n          r={RADIUS_CONFIG.TRACK}\n          fill=\"none\"\n          strokeWidth={svgStrokeWidth}\n          className={cn(\n            VARIANT_COLOR_MAP.track[\n              variant as keyof typeof VARIANT_COLOR_MAP.track\n            ],\n            \"transition-colors\",\n          )}\n        />\n      ) : (\n        <path\n          d={trackArcPath || \"\"}\n          fill=\"none\"\n          strokeWidth={svgStrokeWidth}\n          strokeLinecap=\"round\"\n          className={cn(\n            VARIANT_COLOR_MAP.track[\n              variant as keyof typeof VARIANT_COLOR_MAP.track\n            ],\n            \"transition-colors\",\n          )}\n        />\n      )}\n    </>\n  );\n\n  const defaultProps = createSvgOverlayProps(SLOT_NAMES.TRACK, className);\n\n  const element = useRender({\n    defaultTagName: \"svg\",\n    render,\n    props: mergeProps<\"svg\">(defaultProps, {\n      ...props,\n      children: trackElement,\n    }),\n  });\n\n  return element;\n}\n\nexport interface CircularSliderIndicatorProps extends useRender.ComponentProps<\"svg\"> {\n  strokeWidth?: number;\n}\n\nexport function CircularSliderIndicator({\n  className,\n  render,\n  strokeWidth,\n  ...props\n}: CircularSliderIndicatorProps) {\n  const { value, min, max, startAngle, direction, continuous, size, variant } =\n    useCircularSliderContext();\n\n  const svgStrokeWidth = strokeWidthToSvgUnits(strokeWidth, size);\n\n  // At max in continuous mode, a zero-length arc degenerates — use a full circle instead.\n  const isFullCircle = continuous && value === max;\n\n  let indicatorElement: React.ReactNode = null;\n\n  if (isFullCircle) {\n    indicatorElement = (\n      <circle\n        cx={SVG_CONFIG.CENTER_X}\n        cy={SVG_CONFIG.CENTER_Y}\n        r={RADIUS_CONFIG.TRACK}\n        fill=\"none\"\n        strokeWidth={svgStrokeWidth}\n        strokeLinecap=\"round\"\n        className={cn(\n          VARIANT_COLOR_MAP.indicator[\n            variant as keyof typeof VARIANT_COLOR_MAP.indicator\n          ],\n          \"transition-colors\",\n        )}\n      />\n    );\n  } else {\n    const arcStartAngle = valueToAngle(\n      min,\n      min,\n      max,\n      startAngle,\n      direction,\n      continuous,\n    );\n    const arcEndAngle = valueToAngle(\n      value,\n      min,\n      max,\n      startAngle,\n      direction,\n      continuous,\n    );\n    const arcPath = describeArc(\n      SVG_CONFIG.CENTER_X,\n      SVG_CONFIG.CENTER_Y,\n      RADIUS_CONFIG.TRACK,\n      arcStartAngle,\n      arcEndAngle,\n      direction,\n    );\n\n    indicatorElement = arcPath ? (\n      <path\n        d={arcPath}\n        fill=\"none\"\n        strokeWidth={svgStrokeWidth}\n        strokeLinecap=\"round\"\n        className={cn(\n          VARIANT_COLOR_MAP.indicator[\n            variant as keyof typeof VARIANT_COLOR_MAP.indicator\n          ],\n          \"transition-colors\",\n        )}\n      />\n    ) : null;\n  }\n\n  const defaultProps = createSvgOverlayProps(\n    SLOT_NAMES.INDICATOR,\n    className,\n    false,\n  );\n\n  const element = useRender({\n    defaultTagName: \"svg\",\n    render,\n    props: mergeProps<\"svg\">(defaultProps, {\n      ...props,\n      children: indicatorElement,\n    }),\n  });\n\n  return element;\n}\n\nexport interface CircularSliderThumbProps extends useRender.ComponentProps<\"div\"> {\n  size?: number;\n}\n\nexport function CircularSliderThumb({\n  className,\n  render,\n  size: thumbSize = 16,\n  ...props\n}: CircularSliderThumbProps) {\n  const {\n    value,\n    min,\n    max,\n    startAngle,\n    direction,\n    continuous,\n    isDragging,\n    size: containerSize,\n    variant,\n  } = useCircularSliderContext();\n\n  const angle = valueToAngle(\n    value,\n    min,\n    max,\n    startAngle,\n    direction,\n    continuous,\n  );\n\n  let radius = RADIUS_CONFIG.TRACK;\n  if (variant === \"filled\") {\n    const thumbSizeInSvgUnits = pixelsToSvgUnits(thumbSize, containerSize);\n    // Center offset so the thumb's outer tip aligns with the background circle edge.\n    radius = RADIUS_CONFIG.FILLED_BACKGROUND - thumbSizeInSvgUnits / 2;\n  }\n\n  const thumbPos = getThumbPosition(\n    angle,\n    radius,\n    SVG_CONFIG.CENTER_X,\n    SVG_CONFIG.CENTER_Y,\n  );\n  const leftPercent = (thumbPos.x / SVG_CONFIG.VIEWBOX_SIZE) * 100;\n  const topPercent = (thumbPos.y / SVG_CONFIG.VIEWBOX_SIZE) * 100;\n\n  // Filled thumb is a radial line — rotate by `angle` so it points toward the center.\n  const transform =\n    variant === \"filled\"\n      ? `translate(-50%, -50%) rotate(${angle}deg)`\n      : \"translate(-50%, -50%)\";\n\n  // Dynamic sizing styles\n  const thumbStyles: React.CSSProperties = {\n    left: `${leftPercent}%`,\n    top: `${topPercent}%`,\n    transform,\n    ...(variant === \"default\"\n      ? {\n          width: `${thumbSize}px`,\n          height: `${thumbSize}px`,\n        }\n      : {\n          width: \"2px\",\n          height: `${thumbSize}px`,\n        }),\n  };\n\n  const defaultProps = {\n    \"data-slot\": SLOT_NAMES.THUMB,\n    className: cn(thumbVariants({ variant, dragging: isDragging }), className),\n    style: thumbStyles,\n    role: \"presentation\",\n    \"aria-hidden\": true,\n  };\n\n  const element = useRender({\n    defaultTagName: \"div\",\n    render,\n    props: mergeProps<\"div\">(defaultProps, props),\n  });\n\n  return element;\n}\n\nexport interface CircularSliderValueProps extends useRender.ComponentProps<\"div\"> {\n  formatValue?: (value: number) => string;\n}\n\nexport function CircularSliderValue({\n  className,\n  render,\n  formatValue: formatValueProp,\n  ...props\n}: CircularSliderValueProps) {\n  const { value } = useCircularSliderContext();\n\n  const displayValue = formatValueProp\n    ? formatValueProp(value)\n    : Math.round(value).toString();\n\n  const defaultProps = {\n    \"data-slot\": SLOT_NAMES.VALUE,\n    className: cn(\n      \"absolute inset-0 flex items-center justify-center font-medium tabular-nums text-sm\",\n      className,\n    ),\n  };\n\n  const element = useRender({\n    defaultTagName: \"div\",\n    render,\n    props: mergeProps<\"div\">(defaultProps, {\n      ...props,\n      children: displayValue,\n    }),\n  });\n\n  return element;\n}\n\nexport interface CircularSliderMarkersProps extends useRender.ComponentProps<\"svg\"> {\n  count?: number;\n  length?: number;\n}\n\nexport function CircularSliderMarkers({\n  className,\n  render,\n  count = 12,\n  length,\n  ...props\n}: CircularSliderMarkersProps) {\n  const { min, max, startAngle, direction, continuous, size, variant } =\n    useCircularSliderContext();\n\n  // Default marker length: 10px (filled) / 5px (default)\n  const defaultLength = variant === \"filled\" ? 10 : 5;\n  const markerLengthInPixels = length ?? defaultLength;\n  const markerLengthInSvgUnits = pixelsToSvgUnits(markerLengthInPixels, size);\n\n  const markers = Array.from({ length: count }, (_, i) => {\n    const value = min + (i / count) * (max - min);\n    const angle = valueToAngle(\n      value,\n      min,\n      max,\n      startAngle,\n      direction,\n      continuous,\n    );\n\n    let outerRadius: number;\n    let innerRadius: number;\n\n    if (variant === \"filled\") {\n      outerRadius = RADIUS_CONFIG.FILLED_BACKGROUND;\n      innerRadius = RADIUS_CONFIG.FILLED_BACKGROUND - markerLengthInSvgUnits;\n    } else {\n      const trackInnerEdge = getTrackInnerEdge(16, size);\n      outerRadius = trackInnerEdge;\n      innerRadius = trackInnerEdge - markerLengthInSvgUnits;\n    }\n\n    const outerPos = getThumbPosition(\n      angle,\n      outerRadius,\n      SVG_CONFIG.CENTER_X,\n      SVG_CONFIG.CENTER_Y,\n    );\n    const innerPos = getThumbPosition(\n      angle,\n      innerRadius,\n      SVG_CONFIG.CENTER_X,\n      SVG_CONFIG.CENTER_Y,\n    );\n\n    return {\n      value,\n      angle,\n      outerPos,\n      innerPos,\n    };\n  });\n\n  // Calculate dynamic marker strokeWidth: scales from md baseline (1.5 at 96px)\n  const markerStrokeWidth = (size / 96) * 1.5;\n\n  const markerElements = (\n    <g className=\"stroke-muted-foreground/50\">\n      {markers.map((marker, i) => (\n        <line\n          key={i}\n          x1={marker.innerPos.x}\n          y1={marker.innerPos.y}\n          x2={marker.outerPos.x}\n          y2={marker.outerPos.y}\n          strokeWidth={markerStrokeWidth}\n          strokeLinecap=\"round\"\n        />\n      ))}\n    </g>\n  );\n\n  const defaultProps = createSvgOverlayProps(\n    SLOT_NAMES.MARKERS,\n    className,\n    false,\n  );\n\n  const element = useRender({\n    defaultTagName: \"svg\",\n    render,\n    props: mergeProps<\"svg\">(defaultProps, {\n      ...props,\n      children: markerElements,\n    }),\n  });\n\n  return element;\n}\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"
}