{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gradient-editor",
  "title": "Gradient Editor",
  "description": "Linear / radial / conic gradient editor with draggable stops, position picker, oklch-default interpolation, and reusable color-picker stops.",
  "dependencies": [],
  "registryDependencies": [
    "button",
    "popover",
    "color-picker",
    "unit-input"
  ],
  "files": [
    {
      "path": "src/components/ui/gradient-editor/index.ts",
      "content": "export type { GradientEditorProps } from \"./gradient-editor\"\nexport { GradientEditor } from \"./gradient-editor\"\nexport { isGradientString } from \"./gradient-editor.helpers\"\nexport type {\n  ConicGradientString,\n  GradientStop,\n  GradientString,\n  GradientStringMap,\n  GradientType,\n  GradientTypeOf,\n  InterpolationHueMethod,\n  InterpolationOf,\n  InterpolationSpace,\n  LinearGradientString,\n  PolarSpace,\n  RadialGradientString,\n} from \"./gradient-editor.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/gradient-editor/index.ts"
    },
    {
      "path": "src/components/ui/gradient-editor/gradient-editor.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport { ColorPicker } from \"@/components/ui/color-picker\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { UnitInput } from \"@/components/ui/unit-input\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  formatGradient,\n  fromUnitString,\n  GRADIENT_TYPES,\n  INTERPOLATION_SPACES,\n  type InternalState,\n  nextStopId,\n  POLAR_SPACES,\n  parseGradient,\n  toDeg,\n  toPct,\n} from \"./gradient-editor.helpers\"\nimport type {\n  GradientString,\n  GradientStringMap,\n  GradientType,\n  InternalStop,\n  InterpolationHueMethod,\n  InterpolationSpace,\n  PolarSpace,\n} from \"./gradient-editor.types\"\n\n// ---------------------------------------------------------------------------\n// Component (top of file)\n// ---------------------------------------------------------------------------\n\nexport interface GradientEditorProps<\n  TType extends GradientType | undefined = undefined,\n> {\n  value: GradientString | (string & {})\n  onChange: (\n    value: TType extends GradientType\n      ? GradientStringMap[TType]\n      : GradientString,\n  ) => void\n  type?: TType\n  maxStops?: number\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport function GradientEditor<TType extends GradientType | undefined>({\n  value,\n  onChange,\n  type: typeProp,\n  maxStops = 8,\n  className,\n  \"aria-label\": ariaLabel = \"Edit gradient\",\n}: GradientEditorProps<TType>) {\n  const parsed = useMemo(() => parseGradient(value), [value])\n  if (!parsed) {\n    return (\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"inline-block h-5 w-12 rounded border bg-muted\",\n          className,\n        )}\n        style={{ backgroundColor: value }}\n        data-slot=\"gradient-editor-fallback\"\n      />\n    )\n  }\n  return (\n    <GradientEditorBody\n      value={value}\n      initial={parsed}\n      onChange={onChange as (next: string) => void}\n      typeProp={typeProp}\n      maxStops={maxStops}\n      className={className}\n      ariaLabel={ariaLabel}\n    />\n  )\n}\n\ninterface GradientEditorBodyProps {\n  value: string\n  initial: InternalState\n  onChange: (next: string) => void\n  typeProp: GradientType | undefined\n  maxStops: number\n  className: string | undefined\n  ariaLabel: string\n}\n\nfunction GradientEditorBody({\n  value,\n  initial,\n  onChange,\n  typeProp,\n  maxStops,\n  className,\n  ariaLabel,\n}: GradientEditorBodyProps) {\n  // Internal state owns marker/handle positions. We do NOT derive them from\n  // each re-parse of `value`, because round-tripping through CSS gradient\n  // strings rounds sub-percent positions/angles to integers. Resync only when\n  // `value` changes from outside (not from our own emit, guarded by ref).\n  const [internal, setInternal] = useState<InternalState>(initial)\n  const [selectedStopIndex, setSelectedStopIndex] = useState(0)\n  const lastEmittedRef = useRef<string | null>(null)\n\n  useEffect(() => {\n    if (value === lastEmittedRef.current) return\n    const next = parseGradient(value)\n    if (next) setInternal(next)\n  }, [value])\n\n  const activeType: GradientType = typeProp ?? internal.type\n  const showTypeSwitcher = typeProp == null\n\n  const emit = (next: InternalState) => {\n    setInternal(next)\n    const formatted = formatGradient(next)\n    lastEmittedRef.current = formatted\n    onChange(formatted)\n  }\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          aria-label={ariaLabel}\n          className={cn(\n            \"h-5 w-12 shrink-0 cursor-pointer rounded outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n            className,\n          )}\n          data-slot=\"gradient-editor-trigger\"\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"block h-full w-full rounded border\"\n            style={{ background: value }}\n          />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"w-fit min-w-[320px] p-3\"\n        align=\"start\"\n        data-slot=\"gradient-editor\"\n      >\n        <div className=\"flex flex-col gap-3\">\n          {showTypeSwitcher && (\n            <TypeSwitcher\n              type={activeType}\n              onChange={(next) => emit({ ...internal, type: next })}\n            />\n          )}\n          <GradientPreview\n            state={{ ...internal, type: activeType }}\n            selectedIndex={selectedStopIndex}\n            onSelectStop={setSelectedStopIndex}\n            onMoveStop={(i, position) => {\n              const moved = { ...internal.stops[i], position }\n              // Re-sort so array order matches visual order — `formatGradient`\n              // emits stops in array order, and a stop positioned below its\n              // predecessor renders wrong (browsers clamp it). Mirror the\n              // `onAddStop` pattern: sort, then re-find the moved stop by\n              // reference so the dragged handle stays selected/tracked even\n              // after it leapfrogs a neighbor.\n              const stops = internal.stops\n                .map((s, idx) => (idx === i ? moved : s))\n                .sort((a, b) => a.position - b.position)\n              const newIndex = stops.indexOf(moved)\n              setSelectedStopIndex(newIndex)\n              emit({ ...internal, stops })\n              return newIndex\n            }}\n            onAddStop={(position) => {\n              if (internal.stops.length >= maxStops) return -1\n              // Inherit the last stop's color (fallback to black if somehow empty).\n              const newColor =\n                internal.stops[internal.stops.length - 1]?.color ?? \"#000000\"\n              const newStop = { id: nextStopId(), color: newColor, position }\n              const stops = [...internal.stops, newStop].sort(\n                (a, b) => a.position - b.position,\n              )\n              // Find by reference (handles duplicate positions correctly).\n              const newIndex = stops.indexOf(newStop)\n              setSelectedStopIndex(newIndex)\n              emit({ ...internal, stops })\n              return newIndex\n            }}\n            onDeleteStop={(i) => {\n              if (internal.stops.length <= 2) return\n              const stops = internal.stops.filter((_, idx) => idx !== i)\n              // Clamp selection to remaining range; prefer the previous\n              // sibling so the detail row tracks the deleted stop's neighbor.\n              setSelectedStopIndex(\n                Math.max(\n                  0,\n                  Math.min(selectedStopIndex, stops.length - 1, i - 1),\n                ),\n              )\n              emit({ ...internal, stops })\n            }}\n            maxStops={maxStops}\n          />\n          <StopDetailRow\n            stop={internal.stops[selectedStopIndex] ?? internal.stops[0]}\n            canDelete={internal.stops.length > 2}\n            onChange={(next) => {\n              const stops = internal.stops.map((s, idx) =>\n                idx === selectedStopIndex ? next : s,\n              )\n              emit({ ...internal, stops })\n            }}\n            onDelete={() => {\n              if (internal.stops.length <= 2) return\n              const stops = internal.stops.filter(\n                (_, idx) => idx !== selectedStopIndex,\n              )\n              setSelectedStopIndex(Math.max(0, selectedStopIndex - 1))\n              emit({ ...internal, stops })\n            }}\n          />\n          {activeType === \"linear\" && (\n            <LinearControls\n              angle={internal.angle}\n              onChange={(angle) => emit({ ...internal, angle })}\n            />\n          )}\n          {activeType === \"radial\" && (\n            <RadialControls\n              state={{\n                shape: internal.shape,\n                size: internal.size,\n                position: internal.position,\n              }}\n              onChange={(partial) => emit({ ...internal, ...partial })}\n            />\n          )}\n          {activeType === \"conic\" && (\n            <ConicControls\n              fromAngle={internal.fromAngle}\n              position={internal.position}\n              onChange={(partial) => emit({ ...internal, ...partial })}\n            />\n          )}\n          <InterpolationPicker\n            space={internal.interpolation.space}\n            hueMethod={internal.interpolation.hueMethod}\n            onSpaceChange={(space) =>\n              emit({\n                ...internal,\n                interpolation: { ...internal.interpolation, space },\n              })\n            }\n            onHueMethodChange={(hueMethod) =>\n              emit({\n                ...internal,\n                interpolation: { ...internal.interpolation, hueMethod },\n              })\n            }\n          />\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Sub-components\n// ---------------------------------------------------------------------------\n\nfunction InterpolationPicker({\n  space,\n  hueMethod,\n  onSpaceChange,\n  onHueMethodChange,\n}: {\n  space: InterpolationSpace\n  hueMethod?: InterpolationHueMethod\n  onSpaceChange: (next: InterpolationSpace) => void\n  onHueMethodChange: (next: InterpolationHueMethod) => void\n}) {\n  const isPolar = POLAR_SPACES.includes(space as PolarSpace)\n  return (\n    <div\n      className=\"flex items-center gap-2\"\n      data-slot=\"gradient-editor-interpolation\"\n    >\n      <select\n        value={space}\n        onChange={(e) => onSpaceChange(e.target.value as InterpolationSpace)}\n        className=\"h-7 rounded border bg-background px-2 font-mono text-xs\"\n        aria-label=\"Interpolation space\"\n      >\n        {INTERPOLATION_SPACES.map((s) => (\n          <option key={s} value={s}>\n            in {s}\n          </option>\n        ))}\n      </select>\n      {isPolar && (\n        <select\n          value={hueMethod ?? \"shorter\"}\n          onChange={(e) =>\n            onHueMethodChange(e.target.value as InterpolationHueMethod)\n          }\n          className=\"h-7 rounded border bg-background px-2 font-mono text-xs\"\n          aria-label=\"Hue method\"\n        >\n          <option value=\"shorter\">shorter hue</option>\n          <option value=\"longer\">longer hue</option>\n        </select>\n      )}\n    </div>\n  )\n}\n\n// Angle dial geometry, in SVG userspace units of the 40x40 viewBox below.\nconst DIAL_CENTER = 20 // viewBox midpoint (40 / 2) — origin for the indicator line\nconst DIAL_RADIUS = 16 // indicator-line length; leaves a ~4u margin to the dial edge\n// CSS gradient angles measure 0deg = up, clockwise; atan2 measures 0 = +x axis.\n// Offsetting by a quarter-turn rotates between the two conventions.\nconst ATAN2_TO_CSS_ANGLE_OFFSET = 90 // atan2 (0 = right) → CSS angle (0 = up)\nconst ANGLE_SNAP_STEP = 15 // shift-drag / shift-arrow snap increment, in degrees\n\nfunction AngleDial({\n  angle,\n  onChange,\n}: {\n  angle: number\n  onChange: (next: number) => void\n}) {\n  const handlePointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect()\n    const cx = rect.left + rect.width / 2\n    const cy = rect.top + rect.height / 2\n    const dx = event.clientX - cx\n    const dy = event.clientY - cy\n    // 0deg = up; rotate by +90 to align CSS angle convention\n    let deg = (Math.atan2(dy, dx) * 180) / Math.PI + ATAN2_TO_CSS_ANGLE_OFFSET\n    if (deg < 0) deg += 360\n    if (event.shiftKey)\n      deg = Math.round(deg / ANGLE_SNAP_STEP) * ANGLE_SNAP_STEP\n    onChange(Math.round(deg) % 360)\n  }\n  // Convert to radians for the indicator line; CSS 0deg = up means angle-90 in atan2 terms.\n  const rad = ((angle - ATAN2_TO_CSS_ANGLE_OFFSET) * Math.PI) / 180\n  const x = DIAL_CENTER + DIAL_RADIUS * Math.cos(rad)\n  const y = DIAL_CENTER + DIAL_RADIUS * Math.sin(rad)\n  return (\n    <div\n      role=\"slider\"\n      aria-label=\"Angle\"\n      aria-valuemin={0}\n      aria-valuemax={360}\n      aria-valuenow={Math.round(angle)}\n      tabIndex={0}\n      className=\"size-10 shrink-0 cursor-grab touch-none rounded-full border bg-muted/40\"\n      onPointerDown={(event) => {\n        event.currentTarget.setPointerCapture(event.pointerId)\n        handlePointer(event)\n      }}\n      onPointerMove={(event) => {\n        if (event.buttons) handlePointer(event)\n      }}\n      onKeyDown={(event) => {\n        const step = event.shiftKey ? ANGLE_SNAP_STEP : 1\n        if (event.key === \"ArrowLeft\") onChange((angle - step + 360) % 360)\n        if (event.key === \"ArrowRight\") onChange((angle + step) % 360)\n      }}\n      data-slot=\"gradient-editor-angle-dial\"\n    >\n      <svg\n        viewBox={`0 0 ${DIAL_CENTER * 2} ${DIAL_CENTER * 2}`}\n        className=\"h-full w-full\"\n        aria-hidden=\"true\"\n      >\n        <title>Angle dial</title>\n        <circle cx={DIAL_CENTER} cy={DIAL_CENTER} r=\"1.5\" fill=\"currentColor\" />\n        <line\n          x1={DIAL_CENTER}\n          y1={DIAL_CENTER}\n          x2={x}\n          y2={y}\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n        />\n      </svg>\n    </div>\n  )\n}\n\nfunction LinearControls({\n  angle,\n  onChange,\n}: {\n  angle: number\n  onChange: (next: number) => void\n}) {\n  return (\n    <div\n      className=\"flex items-center gap-3\"\n      data-slot=\"gradient-editor-linear-controls\"\n    >\n      <AngleDial angle={angle} onChange={onChange} />\n      <UnitInput\n        unit=\"deg\"\n        value={toDeg(angle)}\n        onChange={(v) => onChange(fromUnitString(v))}\n        min={0}\n        max={360}\n        aria-label=\"Angle in degrees\"\n        className=\"h-7 w-16\"\n      />\n    </div>\n  )\n}\n\nfunction PositionPicker({\n  x,\n  y,\n  onChange,\n}: {\n  x: number\n  y: number\n  onChange: (next: { x: number; y: number }) => void\n}) {\n  const handlePointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect()\n    const nx = Math.max(\n      0,\n      Math.min(100, ((event.clientX - rect.left) / rect.width) * 100),\n    )\n    const ny = Math.max(\n      0,\n      Math.min(100, ((event.clientY - rect.top) / rect.height) * 100),\n    )\n    onChange({ x: Math.round(nx), y: Math.round(ny) })\n  }\n  return (\n    <div className=\"flex items-center gap-3\">\n      <div\n        className=\"relative size-16 shrink-0 cursor-crosshair touch-none rounded border bg-muted/40\"\n        onPointerDown={(event) => {\n          event.currentTarget.setPointerCapture(event.pointerId)\n          handlePointer(event)\n        }}\n        onPointerMove={(event) => {\n          if (event.buttons) handlePointer(event)\n        }}\n        data-slot=\"gradient-editor-position-pad\"\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow ring-1 ring-black/40\"\n          style={{ left: `${x}%`, top: `${y}%` }}\n        />\n      </div>\n      <div className=\"flex flex-col gap-1\">\n        <div className=\"flex items-center gap-1 font-mono text-[10px] text-muted-foreground\">\n          <span aria-hidden=\"true\">x:</span>\n          <UnitInput\n            unit=\"%\"\n            value={toPct(x)}\n            onChange={(v) => onChange({ x: fromUnitString(v), y })}\n            min={0}\n            max={100}\n            aria-label=\"Position x\"\n            className=\"h-6 w-12\"\n          />\n        </div>\n        <div className=\"flex items-center gap-1 font-mono text-[10px] text-muted-foreground\">\n          <span aria-hidden=\"true\">y:</span>\n          <UnitInput\n            unit=\"%\"\n            value={toPct(y)}\n            onChange={(v) => onChange({ x, y: fromUnitString(v) })}\n            min={0}\n            max={100}\n            aria-label=\"Position y\"\n            className=\"h-6 w-12\"\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction RadialControls({\n  state,\n  onChange,\n}: {\n  state: Pick<InternalState, \"shape\" | \"size\" | \"position\">\n  onChange: (next: Pick<InternalState, \"shape\" | \"size\" | \"position\">) => void\n}) {\n  return (\n    <div\n      className=\"flex flex-col gap-3\"\n      data-slot=\"gradient-editor-radial-controls\"\n    >\n      <div className=\"flex items-center gap-2\">\n        <select\n          value={state.shape}\n          onChange={(e) =>\n            onChange({\n              ...state,\n              shape: e.target.value as \"circle\" | \"ellipse\",\n            })\n          }\n          className=\"h-7 rounded border bg-background px-2 font-mono text-xs\"\n          aria-label=\"Radial shape\"\n        >\n          <option value=\"ellipse\">ellipse</option>\n          <option value=\"circle\">circle</option>\n        </select>\n        <select\n          value={state.size}\n          onChange={(e) =>\n            onChange({\n              ...state,\n              size: e.target.value as InternalState[\"size\"],\n            })\n          }\n          className=\"h-7 rounded border bg-background px-2 font-mono text-xs\"\n          aria-label=\"Radial size\"\n        >\n          <option value=\"farthest-corner\">farthest-corner</option>\n          <option value=\"closest-corner\">closest-corner</option>\n          <option value=\"farthest-side\">farthest-side</option>\n          <option value=\"closest-side\">closest-side</option>\n        </select>\n      </div>\n      <PositionPicker\n        x={state.position.x}\n        y={state.position.y}\n        onChange={(pos) => onChange({ ...state, position: pos })}\n      />\n    </div>\n  )\n}\n\nfunction ConicControls({\n  fromAngle,\n  position,\n  onChange,\n}: {\n  fromAngle: number\n  position: { x: number; y: number }\n  onChange: (next: {\n    fromAngle: number\n    position: { x: number; y: number }\n  }) => void\n}) {\n  return (\n    <div\n      className=\"flex flex-col gap-3\"\n      data-slot=\"gradient-editor-conic-controls\"\n    >\n      <div className=\"flex items-center gap-3\">\n        <AngleDial\n          angle={fromAngle}\n          onChange={(next) => onChange({ fromAngle: next, position })}\n        />\n        <UnitInput\n          unit=\"deg\"\n          value={toDeg(fromAngle)}\n          onChange={(v) => onChange({ fromAngle: fromUnitString(v), position })}\n          min={0}\n          max={360}\n          aria-label=\"Conic from-angle in degrees\"\n          className=\"h-7 w-16\"\n        />\n      </div>\n      <PositionPicker\n        x={position.x}\n        y={position.y}\n        onChange={(pos) => onChange({ fromAngle, position: pos })}\n      />\n    </div>\n  )\n}\n\nfunction TypeSwitcher({\n  type,\n  onChange,\n}: {\n  type: GradientType\n  onChange: (next: GradientType) => void\n}) {\n  return (\n    <div\n      role=\"tablist\"\n      aria-label=\"Gradient type\"\n      className=\"flex gap-1\"\n      data-slot=\"gradient-editor-types\"\n    >\n      {GRADIENT_TYPES.map((t) => (\n        <Button\n          key={t}\n          type=\"button\"\n          role=\"tab\"\n          aria-selected={t === type}\n          size=\"sm\"\n          variant={t === type ? \"secondary\" : \"ghost\"}\n          onClick={() => onChange(t)}\n          className=\"h-7 px-2 font-mono text-xs\"\n        >\n          {t}\n        </Button>\n      ))}\n    </div>\n  )\n}\n\nfunction StopDetailRow({\n  stop,\n  canDelete,\n  onChange,\n  onDelete,\n}: {\n  stop: InternalStop\n  canDelete: boolean\n  onChange: (next: InternalStop) => void\n  onDelete: () => void\n}) {\n  return (\n    <div\n      className=\"flex items-center gap-2\"\n      data-slot=\"gradient-editor-detail-row\"\n    >\n      <ColorPicker\n        value={stop.color}\n        onChange={(next) => onChange({ ...stop, color: next })}\n      />\n      <UnitInput\n        unit=\"%\"\n        value={toPct(stop.position)}\n        onChange={(v) =>\n          onChange({\n            ...stop,\n            position: Math.max(0, Math.min(100, fromUnitString(v))),\n          })\n        }\n        min={0}\n        max={100}\n        aria-label=\"Stop position\"\n        className=\"h-7 w-16\"\n      />\n      <button\n        type=\"button\"\n        onClick={onDelete}\n        disabled={!canDelete}\n        aria-label=\"Delete stop\"\n        className=\"ml-auto flex size-7 items-center justify-center rounded-md border text-muted-foreground transition hover:border-white/25 hover:text-foreground disabled:opacity-30 disabled:hover:border-current disabled:hover:text-current\"\n        data-slot=\"gradient-editor-delete-stop\"\n      >\n        ×\n      </button>\n    </div>\n  )\n}\n\nfunction GradientPreview({\n  state,\n  selectedIndex,\n  onSelectStop,\n  onMoveStop,\n  onAddStop,\n  onDeleteStop,\n  maxStops,\n}: {\n  state: InternalState\n  selectedIndex: number\n  onSelectStop: (i: number) => void\n  /**\n   * Moves stop `i` to `position`, re-sorts, and returns the moved stop's new\n   * sorted index. Drag handlers must feed this back into their tracked index so\n   * they keep moving the SAME stop after it crosses a neighbor.\n   */\n  onMoveStop: (i: number, position: number) => number\n  /** Returns the new stop's sorted index, or -1 if not added. */\n  onAddStop: (position: number) => number\n  onDeleteStop: (i: number) => void\n  maxStops: number\n}) {\n  // Always render as horizontal linear during editing so the 1D stop track makes sense.\n  const previewBg = formatGradient({\n    ...state,\n    type: \"linear\",\n    angle: 90,\n  })\n\n  // Track-level pointer drag: when user clicks empty area to add a stop,\n  // continue dragging the new stop within the same press.\n  const dragStateRef = useRef<{ stopIndex: number; pointerId: number } | null>(\n    null,\n  )\n  // Handle-level drag: a stop's array index changes when a re-sort moves it\n  // past a neighbor. The handle keeps pointer capture on the same DOM node\n  // (which React rebinds to a different stop by index key), so we cannot reuse\n  // the closure index across moves. Track the live index here and feed it the\n  // value `onMoveStop` returns after sorting.\n  const handleDragIndexRef = useRef<number | null>(null)\n  // Across-press drag detection — needed to distinguish click-add → quick\n  // click-drag (where browser fires dblclick at the end because both clicks\n  // land near each other) from true double-clicks. Either click in the pair\n  // having a drag suppresses the dblclick-delete.\n  const wasDraggedThisPressRef = useRef(false)\n  const previousPressWasDragRef = useRef(false)\n\n  const captureDragHistoryAtPointerDown = () => {\n    previousPressWasDragRef.current = wasDraggedThisPressRef.current\n    wasDraggedThisPressRef.current = false\n  }\n\n  const computePct = (clientX: number, rect: DOMRect) =>\n    Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100))\n\n  const handleTrackPointerDown = (\n    event: React.PointerEvent<HTMLDivElement>,\n  ) => {\n    if (event.target !== event.currentTarget) return\n    captureDragHistoryAtPointerDown()\n    if (state.stops.length >= maxStops) return\n    const rect = event.currentTarget.getBoundingClientRect()\n    const pct = computePct(event.clientX, rect)\n    const newIndex = onAddStop(Math.round(pct))\n    if (newIndex < 0) return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    dragStateRef.current = {\n      stopIndex: newIndex,\n      pointerId: event.pointerId,\n    }\n  }\n\n  const handleTrackPointerMove = (\n    event: React.PointerEvent<HTMLDivElement>,\n  ) => {\n    if (!dragStateRef.current) return\n    if (event.pointerId !== dragStateRef.current.pointerId) return\n    wasDraggedThisPressRef.current = true\n    const rect = event.currentTarget.getBoundingClientRect()\n    const pct = computePct(event.clientX, rect)\n    // Track the moved stop by its new sorted index so the SAME stop keeps\n    // moving after it leapfrogs a neighbor (re-sort changes its array index).\n    dragStateRef.current.stopIndex = onMoveStop(\n      dragStateRef.current.stopIndex,\n      Math.round(pct),\n    )\n  }\n\n  const handleTrackPointerUp = () => {\n    dragStateRef.current = null\n  }\n\n  return (\n    <div className=\"flex flex-col gap-1\" data-slot=\"gradient-editor-preview\">\n      <div\n        className=\"relative h-20 w-full rounded border\"\n        style={{ background: previewBg }}\n        data-slot=\"gradient-editor-track\"\n        onPointerDown={handleTrackPointerDown}\n        onPointerMove={handleTrackPointerMove}\n        onPointerUp={handleTrackPointerUp}\n        onPointerCancel={handleTrackPointerUp}\n      >\n        {state.stops.map((stop, i) => (\n          <button\n            key={stop.id}\n            type=\"button\"\n            aria-label={`Stop ${i + 1} at ${Math.round(stop.position)}%`}\n            onClick={() => onSelectStop(i)}\n            onDoubleClick={() => {\n              if (\n                wasDraggedThisPressRef.current ||\n                previousPressWasDragRef.current\n              ) {\n                // User dragged during one of the two clicks; treat as a\n                // drag gesture, not a double-click. Don't delete.\n                return\n              }\n              if (state.stops.length > 2) onDeleteStop(i)\n            }}\n            onPointerDown={(event) => {\n              event.stopPropagation()\n              event.currentTarget.setPointerCapture(event.pointerId)\n              captureDragHistoryAtPointerDown()\n              handleDragIndexRef.current = i\n              onSelectStop(i)\n            }}\n            onPointerMove={(event) => {\n              if (event.buttons) {\n                wasDraggedThisPressRef.current = true\n                const trackRect =\n                  event.currentTarget.parentElement?.getBoundingClientRect()\n                if (!trackRect) return\n                const pct = computePct(event.clientX, trackRect)\n                // Use the live tracked index (seeded at pointerdown, updated on\n                // each move) so a re-sort that reorders this stop doesn't make\n                // us start dragging whichever stop now sits at the old index.\n                const current = handleDragIndexRef.current ?? i\n                handleDragIndexRef.current = onMoveStop(\n                  current,\n                  Math.round(pct),\n                )\n              }\n            }}\n            onPointerUp={() => {\n              handleDragIndexRef.current = null\n            }}\n            onPointerCancel={() => {\n              handleDragIndexRef.current = null\n            }}\n            className={cn(\n              \"absolute inset-y-0 -translate-x-1/2 cursor-grab rounded-sm border border-black/40 shadow-[0_0_0_1px_white] transition\",\n              i === selectedIndex\n                ? \"z-10 w-1.5 ring-2 ring-primary\"\n                : \"w-1 hover:w-1.5\",\n            )}\n            style={{\n              left: `${stop.position}%`,\n              backgroundColor: stop.color,\n            }}\n            data-slot=\"gradient-editor-handle\"\n            data-selected={i === selectedIndex || undefined}\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gradient-editor/gradient-editor.tsx"
    },
    {
      "path": "src/components/ui/gradient-editor/gradient-editor.helpers.ts",
      "content": "import type { ColorString } from \"@/components/ui/color-picker\"\nimport { parseColor } from \"@/components/ui/color-picker/color-picker.helpers\"\nimport type {\n  ConicGradientString,\n  GradientStop,\n  GradientString,\n  GradientType,\n  InternalStop,\n  InterpolationHueMethod,\n  InterpolationSpace,\n  LinearGradientString,\n  PolarSpace,\n  RadialGradientString,\n} from \"./gradient-editor.types\"\n\nlet stopIdSeq = 0\n/** Monotonic id for a freshly-born stop — a stable React key across re-sorts. */\nexport const nextStopId = (): string => `gs-${stopIdSeq++}`\n\nexport interface InternalState {\n  type: GradientType\n  stops: InternalStop[]\n  /** Linear only. Degrees, 0 = to top, 90 = to right, 180 = to bottom (CSS default), 270 = to left. */\n  angle: number\n  /** Radial only. */\n  shape: \"circle\" | \"ellipse\"\n  /** Radial only. */\n  size: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\"\n  /** Radial + conic. Percentages 0..100. */\n  position: { x: number; y: number }\n  /** Conic only. Degrees. */\n  fromAngle: number\n  interpolation: {\n    space: InterpolationSpace\n    hueMethod?: InterpolationHueMethod\n  }\n}\n\nexport interface Interpolation {\n  space: InterpolationSpace\n  hueMethod?: InterpolationHueMethod\n}\n\nexport const INTERPOLATION_SPACES = [\n  \"srgb\",\n  \"oklch\",\n  \"oklab\",\n  \"hsl\",\n  \"hwb\",\n] as const\nexport const POLAR_SPACES: readonly PolarSpace[] = [\"oklch\", \"hsl\", \"hwb\"]\nexport const GRADIENT_TYPES: readonly GradientType[] = [\n  \"linear\",\n  \"radial\",\n  \"conic\",\n]\n\nexport const toDeg = (n: number) => `${Math.round(n)}deg`\nexport const toPct = (n: number) => `${Math.round(n)}%`\nexport const fromUnitString = (s: string) => {\n  const n = Number.parseFloat(s)\n  return Number.isNaN(n) ? 0 : n\n}\n\nconst SIDE_TO_ANGLE: Record<string, number> = {\n  \"to top\": 0,\n  \"to top right\": 45,\n  \"to right\": 90,\n  \"to bottom right\": 135,\n  \"to bottom\": 180,\n  \"to bottom left\": 225,\n  \"to left\": 270,\n  \"to top left\": 315,\n}\n\nconst RADIAL_SHAPES = [\"circle\", \"ellipse\"] as const\nconst RADIAL_SIZES = [\n  \"closest-side\",\n  \"closest-corner\",\n  \"farthest-side\",\n  \"farthest-corner\",\n] as const\n\n// ---------------------------------------------------------------------------\n// Parsing / formatting\n// ---------------------------------------------------------------------------\n\n/**\n * Split a string on commas, ignoring commas inside parens. Trims each segment.\n *\n * @example\n * splitTopLevelCommas(\"rgb(1, 2, 3), red\") // [\"rgb(1, 2, 3)\", \"red\"]\n */\nexport function splitTopLevelCommas(input: string): string[] {\n  const trimmed = input.trim()\n  if (trimmed === \"\") return []\n  const out: string[] = []\n  let depth = 0\n  let start = 0\n  for (let i = 0; i < trimmed.length; i++) {\n    const ch = trimmed[i]\n    if (ch === \"(\") depth++\n    else if (ch === \")\") depth--\n    else if (ch === \",\" && depth === 0) {\n      out.push(trimmed.slice(start, i).trim())\n      start = i + 1\n    }\n  }\n  out.push(trimmed.slice(start).trim())\n  return out\n}\n\n/**\n * Parse a single gradient stop. The position is optional; when absent,\n * returns `position: null` so the caller can auto-distribute.\n *\n * @example\n * parseStop(\"#ff0000 50%\") // { color: \"#ff0000\", position: 50 }\n * parseStop(\"oklch(0.5 0.1 240)\") // { color: \"oklch(0.5 0.1 240)\", position: null }\n */\nexport function parseStop(\n  input: string,\n): { color: ColorString; position: number | null } | null {\n  // The position (if present) is the LAST whitespace-separated token outside parens.\n  // Walk backwards, find the split point.\n  const trimmed = input.trim()\n  let depth = 0\n  let splitAt = -1\n  for (let i = trimmed.length - 1; i >= 0; i--) {\n    const ch = trimmed[i]\n    if (ch === \")\") depth++\n    else if (ch === \"(\") depth--\n    else if (ch === \" \" && depth === 0) {\n      splitAt = i\n      break\n    }\n  }\n\n  let colorPart = trimmed\n  let positionPart: string | null = null\n  if (splitAt !== -1) {\n    const tail = trimmed.slice(splitAt + 1).trim()\n    if (tail.endsWith(\"%\")) {\n      const n = Number.parseFloat(tail.slice(0, -1))\n      if (!Number.isNaN(n)) {\n        colorPart = trimmed.slice(0, splitAt).trim()\n        positionPart = tail\n      }\n    }\n  }\n\n  if (parseColor(colorPart) == null) return null\n  return {\n    color: colorPart as ColorString,\n    position: positionPart == null ? null : Number.parseFloat(positionPart),\n  }\n}\n\nexport function formatStop(stop: GradientStop): string {\n  return `${stop.color} ${Math.round(stop.position)}%`\n}\n\n/**\n * Parse a CSS interpolation clause like `in oklch longer hue`.\n * Returns null if the prefix is missing or the space is unrecognized.\n */\nexport function parseInterpolation(input: string): Interpolation | null {\n  const trimmed = input.trim()\n  if (!trimmed.startsWith(\"in \")) return null\n  const body = trimmed.slice(3).trim()\n  // Possible forms: \"<space>\", \"<space> longer hue\", \"<space> shorter hue\"\n  const huePartMatch = body.match(/^(\\S+)\\s+(longer|shorter)\\s+hue$/)\n  if (huePartMatch) {\n    const space = huePartMatch[1] as InterpolationSpace\n    if (!INTERPOLATION_SPACES.includes(space)) return null\n    return { space, hueMethod: huePartMatch[2] as InterpolationHueMethod }\n  }\n  const space = body as InterpolationSpace\n  if (!INTERPOLATION_SPACES.includes(space)) return null\n  return { space, hueMethod: undefined }\n}\n\n/**\n * Format an interpolation as a token for inclusion in a gradient prelude.\n * Returns the bare token (e.g. `\"in oklch\"` or `\"in oklch longer hue\"`) with\n * NO comma — the caller decides how to join it with the rest of the prelude.\n * Returns empty string when the space is `srgb` with no hue method\n * (CSS default — keep output clean).\n *\n * Important: the `in <space>` clause must be adjacent to the prelude (no\n * comma separating them). Browsers reject e.g.\n * `radial-gradient(in oklch, ellipse at 50% 50%, …)` as invalid CSS — the\n * `in oklch` must instead sit next to the shape/at-position prelude:\n * `radial-gradient(ellipse at 50% 50% in oklch, …)`.\n */\nexport function formatInterpolation(interp: Interpolation): string {\n  // srgb is CSS default — omit unless hue method is set (which is N/A for cartesian).\n  if (interp.space === \"srgb\") return \"\"\n  const isPolar = POLAR_SPACES.includes(interp.space as PolarSpace)\n  if (isPolar && interp.hueMethod) {\n    return `in ${interp.space} ${interp.hueMethod} hue`\n  }\n  return `in ${interp.space}`\n}\n\n/**\n * Parse a CSS gradient string into the editor's internal state.\n * Returns null on parse failure or when the gradient has fewer than 2 stops.\n */\nexport function parseGradient(value: string): InternalState | null {\n  const trimmed = value.trim()\n  const prefixMatch = trimmed.match(/^(linear|radial|conic)-gradient\\((.*)\\)$/s)\n  if (!prefixMatch) return null\n  const type = prefixMatch[1] as GradientType\n  const body = prefixMatch[2]\n\n  const segments = splitTopLevelCommas(body)\n  if (segments.length === 0) return null\n\n  // Try to extract interpolation. Two forms accepted:\n  //   Form A — first segment is the bare `in <space>[ <method> hue]?` clause\n  //            (e.g. `linear-gradient(in oklch, red, blue)` when no prelude).\n  //   Form B — interpolation token is suffixed onto the prelude, separated by\n  //            whitespace (e.g. `radial-gradient(ellipse at 50% 50% in oklch, …)`).\n  //            This is the canonical CSS-valid form for radial/conic.\n  let interpolation: {\n    space: InterpolationSpace\n    hueMethod?: InterpolationHueMethod\n  } = {\n    space: \"srgb\",\n  }\n  let preludeAndStops = segments\n  const interpFromFirst = parseInterpolation(segments[0])\n  if (interpFromFirst) {\n    interpolation = interpFromFirst\n    preludeAndStops = segments.slice(1)\n  } else {\n    // Try Form B: extract trailing `in <space>[ <method> hue]?` from segment 0.\n    const suffixMatch = segments[0].match(\n      /\\s+(in\\s+(?:srgb|oklch|oklab|hsl|hwb)(?:\\s+(?:shorter|longer)\\s+hue)?)$/i,\n    )\n    if (suffixMatch) {\n      const parsed = parseInterpolation(suffixMatch[1])\n      if (parsed) {\n        interpolation = parsed\n        preludeAndStops = [\n          segments[0].slice(0, suffixMatch.index ?? 0).trim(),\n          ...segments.slice(1),\n        ]\n      }\n    }\n  }\n\n  // Determine if the next segment is a prelude (angle/shape/from) or a stop.\n  let preludeIndex = 0\n  const first = preludeAndStops[0] ?? \"\"\n  const looksLikePrelude =\n    /^(-?\\d+(\\.\\d+)?deg|to )/.test(first) || // linear angle (signed)\n    /^(circle|ellipse|closest|farthest)/.test(first) || // radial shape/size\n    first.startsWith(\"at \") || // radial position alone\n    first.startsWith(\"from \") // conic\n\n  // Type-specific prelude extraction\n  let angle = 180 // linear default = to bottom\n  let shape: InternalState[\"shape\"] = \"ellipse\"\n  let size: InternalState[\"size\"] = \"farthest-corner\"\n  let position = { x: 50, y: 50 }\n  let fromAngle = 0\n\n  if (looksLikePrelude && type === \"linear\") {\n    const prelude = preludeAndStops[0]\n    if (SIDE_TO_ANGLE[prelude] != null) {\n      angle = SIDE_TO_ANGLE[prelude]\n    } else {\n      const m = prelude.match(/^(-?\\d+(?:\\.\\d+)?)deg$/)\n      if (m) angle = Number.parseFloat(m[1])\n      else return null\n    }\n    preludeIndex = 1\n  } else if (looksLikePrelude && type === \"radial\") {\n    const prelude = preludeAndStops[0]\n    // Tokens: [shape] [size] [at <pos>]\n    const tokens = prelude.split(/\\s+/)\n    let i = 0\n    if (RADIAL_SHAPES.includes(tokens[i] as (typeof RADIAL_SHAPES)[number])) {\n      shape = tokens[i] as InternalState[\"shape\"]\n      i++\n    }\n    if (RADIAL_SIZES.includes(tokens[i] as (typeof RADIAL_SIZES)[number])) {\n      size = tokens[i] as InternalState[\"size\"]\n      i++\n    }\n    if (tokens[i] === \"at\") {\n      i++\n      const x = parsePercent(tokens[i++])\n      const y = parsePercent(tokens[i++])\n      if (x == null || y == null) return null\n      position = { x, y }\n    }\n    preludeIndex = 1\n  } else if (looksLikePrelude && type === \"conic\") {\n    const prelude = preludeAndStops[0]\n    // Tokens: [from <angle>] [at <pos>]\n    const tokens = prelude.split(/\\s+/)\n    let i = 0\n    if (tokens[i] === \"from\") {\n      i++\n      const m = tokens[i++].match(/^(-?\\d+(?:\\.\\d+)?)deg$/)\n      if (!m) return null\n      fromAngle = Number.parseFloat(m[1])\n    }\n    if (tokens[i] === \"at\") {\n      i++\n      const x = parsePercent(tokens[i++])\n      const y = parsePercent(tokens[i++])\n      if (x == null || y == null) return null\n      position = { x, y }\n    }\n    preludeIndex = 1\n  }\n\n  // Remaining segments are stops.\n  const stopSegments = preludeAndStops.slice(preludeIndex)\n  if (stopSegments.length < 2) return null\n\n  const rawStops = stopSegments.map(parseStop)\n  const validStops: NonNullable<(typeof rawStops)[number]>[] = []\n  for (const raw of rawStops) {\n    if (raw == null) return null\n    validStops.push(raw)\n  }\n\n  // Auto-distribute positions when null.\n  const count = validStops.length\n  const stops: InternalStop[] = validStops.map((raw, i) => ({\n    id: nextStopId(),\n    color: raw.color,\n    position: raw.position != null ? raw.position : (i / (count - 1)) * 100,\n  }))\n\n  return {\n    type,\n    stops,\n    angle,\n    shape,\n    size,\n    position,\n    fromAngle,\n    interpolation,\n  }\n}\n\nfunction parsePercent(input: string | undefined): number | null {\n  if (!input) return null\n  if (!input.endsWith(\"%\")) return null\n  const n = Number.parseFloat(input.slice(0, -1))\n  return Number.isNaN(n) ? null : n\n}\n\n/**\n * Runtime type guard. Narrows wide `string` to `GradientString`.\n *\n * @example\n * const v: string = userInput\n * if (isGradientString(v)) {\n *   // v is now GradientString\n * }\n */\nexport function isGradientString(value: string): value is GradientString\nexport function isGradientString<S extends string>(\n  value: S,\n): value is S &\n  (LinearGradientString | RadialGradientString | ConicGradientString)\nexport function isGradientString(value: string): boolean {\n  return parseGradient(value) !== null\n}\n\n/**\n * Serialize internal state to a CSS gradient string.\n *\n * Emission rule: the interpolation `in <space>` clause sits adjacent to the\n * prelude with a space separator, NOT comma-separated. Browsers reject\n * `radial-gradient(in oklch, ellipse at 50% 50%, …)` as invalid syntax;\n * the correct form is `radial-gradient(ellipse at 50% 50% in oklch, …)`.\n */\nexport function formatGradient(\n  state: Omit<InternalState, \"stops\"> & { stops: GradientStop[] },\n): string {\n  const interpToken = formatInterpolation(state.interpolation)\n  const stops = state.stops.map(formatStop).join(\", \")\n  const joinInterp = (positional: string) =>\n    interpToken ? `${positional} ${interpToken}` : positional\n  switch (state.type) {\n    case \"linear\":\n      return `linear-gradient(${joinInterp(`${state.angle}deg`)}, ${stops})`\n    case \"radial\":\n      return `radial-gradient(${joinInterp(\n        `${state.shape} ${state.size} at ${Math.round(state.position.x)}% ${Math.round(state.position.y)}%`,\n      )}, ${stops})`\n    case \"conic\":\n      return `conic-gradient(${joinInterp(\n        `from ${state.fromAngle}deg at ${Math.round(state.position.x)}% ${Math.round(state.position.y)}%`,\n      )}, ${stops})`\n  }\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gradient-editor/gradient-editor.helpers.ts"
    },
    {
      "path": "src/components/ui/gradient-editor/gradient-editor.types.ts",
      "content": "// =====================================================================\n// 1. SUGGESTION STRINGS — IntelliSense surface + onChange return types.\n// =====================================================================\n\nexport type LinearGradientString =\n  | `linear-gradient(${string})`\n  | `linear-gradient(in ${string}, ${string})`\n\nexport type RadialGradientString =\n  | `radial-gradient(${string})`\n  | `radial-gradient(in ${string}, ${string})`\n\nexport type ConicGradientString =\n  | `conic-gradient(${string})`\n  | `conic-gradient(in ${string}, ${string})`\n\nexport type GradientString =\n  | LinearGradientString\n  | RadialGradientString\n  | ConicGradientString\n\nexport interface GradientStringMap {\n  linear: LinearGradientString\n  radial: RadialGradientString\n  conic: ConicGradientString\n}\n\nexport type GradientType = keyof GradientStringMap\n\n// =====================================================================\n// 2. INTERPOLATION\n// =====================================================================\n\nexport type InterpolationSpace = \"srgb\" | \"oklch\" | \"oklab\" | \"hsl\" | \"hwb\"\nexport type InterpolationHueMethod = \"shorter\" | \"longer\"\n\n/** Polar spaces support hue interpolation method. Cartesian (srgb, oklab) don't. */\nexport type PolarSpace = Extract<InterpolationSpace, \"oklch\" | \"hsl\" | \"hwb\">\n\n// =====================================================================\n// 3. UTILITY TYPES — extract structural info at the type level.\n// =====================================================================\n\n/**\n * Extract gradient type from a literal.\n * @example\n * type T = GradientTypeOf<\"linear-gradient(red, blue)\">  // \"linear\"\n */\nexport type GradientTypeOf<S extends string> =\n  S extends `linear-gradient(${string}`\n    ? \"linear\"\n    : S extends `radial-gradient(${string}`\n      ? \"radial\"\n      : S extends `conic-gradient(${string}`\n        ? \"conic\"\n        : never\n\n/**\n * Extract interpolation space from a literal, if declared.\n * @example\n * type T = InterpolationOf<\"linear-gradient(in oklch, red, blue)\">  // \"oklch\"\n */\nexport type InterpolationOf<S extends string> =\n  S extends `${string}-gradient(in ${infer Space}, ${string}`\n    ? Space extends `${infer Pure} longer hue` | `${infer Pure} shorter hue`\n      ? Pure\n      : Space\n    : never\n\n// =====================================================================\n// 4. INTERNAL STOP REPRESENTATION (exported for advanced use)\n// =====================================================================\n\nimport type { ColorString } from \"@/components/ui/color-picker\"\n\n/**\n * A single color stop in the editor's internal representation.\n * Reuses ColorString from the color-picker registry item.\n */\nexport interface GradientStop {\n  /** Color in any of the 6 supported color modes. */\n  color: ColorString\n  /** Position 0..100. */\n  position: number\n}\n\n/**\n * A stop in live editor state, carrying a stable `id` for React keys. The id is\n * assigned when a stop is born (parsed from a string or added) and travels with\n * it through re-sorts, so list reconciliation tracks each stop by identity\n * rather than array index.\n */\nexport interface InternalStop extends GradientStop {\n  id: string\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gradient-editor/gradient-editor.types.ts"
    }
  ],
  "type": "registry:ui"
}