{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gradient-editor",
  "type": "registry:ui",
  "title": "Gradient Editor",
  "description": "CSS gradient editor with draggable stops and live preview.",
  "dependencies": [],
  "registryDependencies": [
    "https://control-ui.dev/r/color-picker.json",
    "https://control-ui.dev/r/core.json",
    "https://control-ui.dev/r/select.json",
    "https://control-ui.dev/r/surface-variants.json"
  ],
  "files": [
    {
      "path": "src/registry/knob-contracts/gradient-editor-knobs.ts",
      "target": "@components/control-ui/knob-contracts/gradient-editor-knobs.ts",
      "type": "registry:component",
      "content": "// Generated from src/registry/sources/control-ui/recipes/gradient-editor.css by scripts/gen-knob-contracts.ts — run `bun run sync:knobs`.\nexport const gradientEditorKnobs = [\n  \"--cui-gradient-editor-preview-radius\",\n  \"--cui-gradient-editor-preview-ring-color\",\n  \"--cui-gradient-editor-track-radius\",\n  \"--cui-gradient-editor-stop-radius\",\n  \"--cui-gradient-editor-stop-border-color\",\n  \"--cui-gradient-editor-stop-shadow\",\n  \"--cui-gradient-editor-add-radius\",\n  \"--cui-gradient-editor-add-border-color\",\n] as const;\nexport type GradientEditorKnobStyle = Partial<Record<(typeof gradientEditorKnobs)[number], string>>;\n"
    },
    {
      "path": "src/registry/sources/control-ui/recipes/gradient-editor.css",
      "target": "@components/control-ui/styles/recipes/gradient-editor.css",
      "type": "registry:file",
      "content": "@layer components {\n  :where([data-control-family=\"gradient-editor\"][data-slot=\"root\"]) {\n    --cui-gradient-editor-add-border-color: var(--border);\n    --cui-gradient-editor-add-radius: 9999px;\n    --cui-gradient-editor-preview-radius: var(--radius-field);\n    --cui-gradient-editor-preview-ring-color: oklch(from var(--border) l c h / 0.6);\n    --cui-gradient-editor-stop-border-color: oklch(1 0 0);\n    --cui-gradient-editor-stop-radius: 9999px;\n    --cui-gradient-editor-stop-shadow: 0 0 0 1px oklch(from var(--foreground) l c h / 0.4);\n    --cui-gradient-editor-track-radius: 9999px;\n  }\n  :where([data-control-family=\"gradient-editor\"][data-slot=\"preview\"]) {\n    border-radius: var(--cui-gradient-editor-preview-radius);\n    box-shadow: inset 0 0 0 1px var(--cui-gradient-editor-preview-ring-color);\n  }\n\n  :where([data-control-family=\"gradient-editor\"][data-slot=\"track\"]) {\n    border: 0;\n    border-radius: var(--cui-gradient-editor-track-radius);\n    box-shadow: inset 0 0 0 1px oklch(from var(--border) l c h / 0.6);\n  }\n\n  :where([data-control-family=\"gradient-editor\"][data-slot=\"stop\"]) {\n    border: 2px solid var(--cui-gradient-editor-stop-border-color);\n    border-radius: var(--cui-gradient-editor-stop-radius);\n    box-shadow: var(--cui-gradient-editor-stop-shadow);\n    outline: none;\n    transition: scale var(--duration-fast) var(--ease-standard);\n  }\n\n  :where([data-control-family=\"gradient-editor\"][data-slot=\"stop-add\"]) {\n    border: 1px dashed var(--cui-gradient-editor-add-border-color);\n    border-radius: var(--cui-gradient-editor-add-radius);\n    color: var(--muted-foreground);\n    outline: none;\n    transition:\n      border-color var(--duration-fast) var(--ease-standard),\n      color var(--duration-fast) var(--ease-standard);\n  }\n\n  @media (hover: hover) {\n    :where([data-control-family=\"gradient-editor\"][data-slot=\"stop-add\"]:hover) {\n      border-color: oklch(from var(--foreground) l c h / 0.4);\n      color: var(--foreground);\n    }\n  }\n\n  :where([data-control-family=\"gradient-editor\"][data-slot=\"stop\"][data-selected=\"true\"]) {\n    scale: 115% 115%;\n  }\n}\n\n@property --cui-gradient-editor-preview-radius {\n  syntax: \"<length-percentage>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-gradient-editor-preview-ring-color {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-gradient-editor-track-radius {\n  syntax: \"<length-percentage>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-gradient-editor-stop-radius {\n  syntax: \"<length-percentage>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-gradient-editor-stop-border-color {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-gradient-editor-stop-shadow {\n  syntax: \"*\";\n  inherits: true;\n}\n\n@property --cui-gradient-editor-add-radius {\n  syntax: \"<length-percentage>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-gradient-editor-add-border-color {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/ui/gradient-editor.tsx",
      "target": "@components/control-ui/ui/gradient-editor.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport type {\n  ComponentProps,\n  CSSProperties,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n  MouseEvent as ReactMouseEvent,\n  PointerEvent as ReactPointerEvent,\n  RefObject,\n} from \"react\";\nimport { createContext, useContext, useRef, useState } from \"react\";\nimport type { ControlSize } from \"@/components/control-ui/control-variants\";\nimport type { GradientEditorKnobStyle } from \"@/components/control-ui/knob-contracts/gradient-editor-knobs\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { formatGradient } from \"@/components/control-ui/lib/color\";\nimport {\n  ColorPicker,\n  ColorPickerAlpha,\n  ColorPickerArea,\n  ColorPickerContent,\n  ColorPickerFormatSelect,\n  ColorPickerHue,\n  ColorPickerInput,\n  ColorPickerTrigger,\n} from \"@/components/control-ui/ui/color-picker\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/control-ui/ui/select\";\n\nexport type GradientType = \"linear\" | \"radial\" | \"conic\";\n\nexport type GradientStop = { id: string; position: number; color: string };\n\nexport type GradientEditorProps = Omit<ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  value?: string;\n  defaultStops?: GradientStop[];\n  defaultType?: GradientType;\n  defaultAngle?: number;\n  onValueChange?: (value: string) => void;\n} & { style?: CSSProperties & GradientEditorKnobStyle };\n\nexport type GradientEditorPreviewProps = Omit<ComponentProps<\"div\">, \"style\"> & {\n  style?: CSSProperties & GradientEditorKnobStyle;\n};\n\nexport type GradientEditorTrackProps = Omit<ComponentProps<\"fieldset\">, \"style\"> & {\n  style?: CSSProperties & GradientEditorKnobStyle;\n};\n\nexport type GradientEditorStopProps = Omit<ComponentProps<\"button\">, \"style\"> & {\n  stop: GradientStop;\n  style?: CSSProperties & GradientEditorKnobStyle;\n};\n\nexport type GradientEditorStopAddProps = Omit<ComponentProps<\"button\">, \"onClick\" | \"style\"> & {\n  style?: CSSProperties & GradientEditorKnobStyle;\n};\n\nexport type GradientEditorTypeSelectProps = {\n  size?: ControlSize;\n  className?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n};\n\nconst DEFAULT_STOPS: GradientStop[] = [\n  { id: \"stop-1\", position: 0, color: \"#7c3aed\" },\n  { id: \"stop-2\", position: 1, color: \"#3b82f6\" },\n];\n\nconst isGradientType = (v: string): v is GradientType => v === \"linear\" || v === \"radial\" || v === \"conic\";\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\ntype GradientEditorContextValue = {\n  stops: GradientStop[];\n  type: GradientType;\n  angle: number;\n  selectedId: string;\n  gradient: string;\n  trackRef: RefObject<HTMLFieldSetElement | null>;\n  select: (id: string) => void;\n  setStopColor: (id: string, color: string) => void;\n  setStopPosition: (id: string, position: number) => void;\n  addStop: (position: number) => void;\n  removeStop: (id: string) => void;\n  setType: (type: GradientType) => void;\n};\n\nconst GradientEditorContext = createContext<GradientEditorContextValue | null>(null);\n\nfunction useGradientEditor(): GradientEditorContextValue {\n  const ctx = useContext(GradientEditorContext);\n  if (!ctx) throw new Error(\"GradientEditor parts must be rendered inside <GradientEditor>.\");\n  return ctx;\n}\n\nexport function GradientEditor({\n  defaultStops = DEFAULT_STOPS,\n  defaultType = \"linear\",\n  defaultAngle = 90,\n  onValueChange,\n  className,\n  children,\n  value: _value,\n  ...props\n}: GradientEditorProps) {\n  const [stops, setStops] = useState<GradientStop[]>(defaultStops);\n  const [type, setTypeState] = useState<GradientType>(defaultType);\n  const [angle] = useState(defaultAngle);\n  const [selectedId, setSelectedId] = useState(defaultStops[0]?.id ?? \"stop-1\");\n  const idCounter = useRef(defaultStops.length);\n  const trackRef = useRef<HTMLFieldSetElement | null>(null);\n\n  const gradient = formatGradient(stops, type, angle);\n\n  function updateStops(nextStops: GradientStop[]) {\n    setStops(nextStops);\n    onValueChange?.(formatGradient(nextStops, type, angle));\n  }\n\n  const setStopColor = (id: string, color: string) => updateStops(stops.map((stop) => (stop.id === id ? { ...stop, color } : stop)));\n  const setStopPosition = (id: string, position: number) =>\n    updateStops(stops.map((stop) => (stop.id === id ? { ...stop, position: clamp01(position) } : stop)));\n  const addStop = (position: number) => {\n    idCounter.current += 1;\n    const id = `stop-${idCounter.current}`;\n    const near = [...stops].sort((a, b) => Math.abs(a.position - position) - Math.abs(b.position - position))[0];\n    updateStops([...stops, { id, position: clamp01(position), color: near?.color ?? \"#ffffff\" }]);\n    setSelectedId(id);\n  };\n  const removeStop = (id: string) => {\n    if (stops.length <= 2) return; // a gradient needs at least two stops\n    const nextStops = stops.filter((stop) => stop.id !== id);\n    updateStops(nextStops);\n    if (selectedId === id) setSelectedId(nextStops[0]?.id ?? selectedId);\n  };\n  const setType = (nextType: GradientType) => {\n    setTypeState(nextType);\n    onValueChange?.(formatGradient(stops, nextType, angle));\n  };\n\n  const ctx: GradientEditorContextValue = {\n    stops,\n    type,\n    angle,\n    selectedId,\n    gradient,\n    trackRef,\n    select: setSelectedId,\n    setStopColor,\n    setStopPosition,\n    addStop,\n    removeStop,\n    setType,\n  };\n\n  return (\n    <GradientEditorContext.Provider value={ctx}>\n      <div\n        data-control-ui=\"gradient-editor\"\n        data-control-family=\"gradient-editor\"\n        data-slot=\"root\"\n        className={cn(\"grid gap-3\", className)}\n        {...props}\n      >\n        {children}\n      </div>\n    </GradientEditorContext.Provider>\n  );\n}\n\nexport function GradientEditorPreview({\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"Gradient preview\",\n  ...props\n}: GradientEditorPreviewProps) {\n  const { gradient } = useGradientEditor();\n  return (\n    <div\n      data-control-ui=\"gradient-editor\"\n      data-control-family=\"gradient-editor\"\n      data-slot=\"preview\"\n      role=\"img\"\n      aria-label={ariaLabel}\n      className={cn(\"h-32 w-full\", className)}\n      style={{ ...style, backgroundImage: gradient }}\n      {...props}\n    />\n  );\n}\n\nexport function GradientEditorTrack({\n  className,\n  children,\n  style,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  ...props\n}: GradientEditorTrackProps) {\n  const { stops, trackRef, addStop } = useGradientEditor();\n  const stripe = formatGradient(stops, \"linear\", 90);\n  return (\n    <fieldset\n      ref={trackRef}\n      data-control-ui=\"gradient-editor\"\n      data-control-family=\"gradient-editor\"\n      data-slot=\"track\"\n      aria-label={ariaLabelledBy === undefined ? (ariaLabel ?? \"Gradient stops\") : ariaLabel}\n      aria-labelledby={ariaLabelledBy}\n      className={cn(\"relative m-0 h-6 min-w-0 w-full cursor-copy p-0\", className)}\n      style={{ ...style, backgroundImage: stripe }}\n      onPointerDown={(event) => {\n        // only bare track adds stop — click on handle is that handle's to deal with\n        if (event.target !== event.currentTarget) return;\n        const rect = event.currentTarget.getBoundingClientRect();\n        addStop((event.clientX - rect.left) / rect.width);\n      }}\n      {...props}\n    >\n      {children ?? stops.map((stop) => <GradientEditorStop key={stop.id} stop={stop} />)}\n    </fieldset>\n  );\n}\n\nexport function GradientEditorStop({\n  stop,\n  className,\n  style,\n  onPointerDown,\n  onKeyDown,\n  onFocus,\n  onDoubleClick,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  ...props\n}: GradientEditorStopProps) {\n  const { stops, selectedId, select, setStopPosition, removeStop, trackRef } = useGradientEditor();\n  const selected = stop.id === selectedId;\n  const canRemove = stops.length > 2;\n\n  function handlePointerDown(event: ReactPointerEvent<HTMLButtonElement>) {\n    onPointerDown?.(event);\n    if (event.defaultPrevented) return;\n    select(stop.id);\n    event.currentTarget.focus();\n    event.preventDefault();\n    const track = trackRef.current;\n    if (!track) return;\n    const move = (moveEvent: globalThis.PointerEvent) => {\n      const rect = track.getBoundingClientRect();\n      setStopPosition(stop.id, (moveEvent.clientX - rect.left) / rect.width);\n    };\n    const up = () => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      document.body.style.userSelect = \"\";\n    };\n    document.body.style.userSelect = \"none\";\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n  }\n\n  function handleFocus(event: ReactFocusEvent<HTMLButtonElement>) {\n    onFocus?.(event);\n    if (!event.defaultPrevented) select(stop.id);\n  }\n\n  function handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n\n    const step = event.shiftKey ? 0.1 : 0.01;\n    switch (event.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        event.preventDefault();\n        setStopPosition(stop.id, stop.position - step);\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        event.preventDefault();\n        setStopPosition(stop.id, stop.position + step);\n        break;\n      case \"PageDown\":\n        event.preventDefault();\n        setStopPosition(stop.id, stop.position - 0.1);\n        break;\n      case \"PageUp\":\n        event.preventDefault();\n        setStopPosition(stop.id, stop.position + 0.1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        setStopPosition(stop.id, 0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        setStopPosition(stop.id, 1);\n        break;\n      case \"Backspace\":\n      case \"Delete\":\n        if (!canRemove) return;\n        event.preventDefault();\n        removeStop(stop.id);\n        break;\n    }\n  }\n\n  function handleDoubleClick(event: ReactMouseEvent<HTMLButtonElement>) {\n    onDoubleClick?.(event);\n    if (!event.defaultPrevented) removeStop(stop.id);\n  }\n\n  const percent = Math.round(stop.position * 100);\n  const defaultLabel = `Gradient stop at ${percent}%. Use arrow keys to move${canRemove ? \", Delete to remove\" : \"\"}.`;\n\n  return (\n    <button\n      type=\"button\"\n      data-control-ui=\"gradient-editor\"\n      data-control-family=\"gradient-editor\"\n      data-slot=\"stop\"\n      data-selected={selected ? \"true\" : undefined}\n      aria-label={ariaLabelledBy === undefined ? (ariaLabel ?? defaultLabel) : ariaLabel}\n      aria-labelledby={ariaLabelledBy}\n      onPointerDown={handlePointerDown}\n      onFocus={handleFocus}\n      onKeyDown={handleKeyDown}\n      onDoubleClick={handleDoubleClick}\n      className={cn(\n        \"-translate-x-1/2 absolute top-1/2 size-5 -translate-y-1/2 cursor-grab overflow-hidden active:cursor-grabbing\",\n        className,\n      )}\n      style={{ ...style, left: `${stop.position * 100}%`, backgroundColor: stop.color }}\n      {...props}\n    />\n  );\n}\n\nexport function GradientEditorStopAdd({ className, children, ...props }: GradientEditorStopAddProps) {\n  const { addStop } = useGradientEditor();\n  return (\n    <button\n      type=\"button\"\n      data-control-ui=\"gradient-editor\"\n      data-control-family=\"gradient-editor\"\n      data-slot=\"stop-add\"\n      aria-label=\"Add gradient stop\"\n      onClick={() => addStop(0.5)}\n      className={cn(\"flex size-7 shrink-0 cursor-pointer items-center justify-center\", className)}\n      {...props}\n    >\n      {children ?? (\n        <svg\n          viewBox=\"0 0 16 16\"\n          className=\"size-3.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"M8 3v10M3 8h10\" />\n        </svg>\n      )}\n    </button>\n  );\n}\n\nexport function GradientEditorStopColor() {\n  const { stops, selectedId, setStopColor } = useGradientEditor();\n  const selected = stops.find((s) => s.id === selectedId) ?? stops[0];\n  if (!selected) return null;\n  return (\n    <ColorPicker value={selected.color} onValueChange={(color) => setStopColor(selected.id, color)} defaultFormat=\"hex\">\n      <ColorPickerTrigger />\n      <ColorPickerContent>\n        <ColorPickerArea />\n        <ColorPickerHue />\n        <ColorPickerAlpha />\n        <div className=\"flex gap-2\">\n          <ColorPickerFormatSelect />\n          <ColorPickerInput className=\"flex-1\" />\n        </div>\n      </ColorPickerContent>\n    </ColorPicker>\n  );\n}\n\nexport function GradientEditorTypeSelect({\n  size = \"sm\",\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n}: GradientEditorTypeSelectProps) {\n  const { type, setType } = useGradientEditor();\n  return (\n    <Select\n      value={type}\n      onValueChange={(value) => {\n        if (isGradientType(value)) setType(value);\n      }}\n    >\n      <SelectTrigger\n        size={size}\n        aria-label={ariaLabelledBy === undefined ? (ariaLabel ?? \"Gradient type\") : ariaLabel}\n        aria-labelledby={ariaLabelledBy}\n        className={className}\n      >\n        <SelectValue />\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem value=\"linear\">Linear</SelectItem>\n        <SelectItem value=\"radial\">Radial</SelectItem>\n        <SelectItem value=\"conic\">Conic</SelectItem>\n      </SelectContent>\n    </Select>\n  );\n}\n"
    }
  ],
  "css": {
    "@import \"../components/control-ui/styles/recipes/gradient-editor.css\"": {}
  },
  "meta": {}
}
