{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "description": "An OKLCH-aware popover color picker with OKLCH, HSL, RGB, and hex input formats.",
  "registryDependencies": [
    "button",
    "popover",
    "select",
    "tooltip",
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/color-picker.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport {\n  clamp,\n  cuspColorForHue,\n  formatColorValue,\n  formatCssColor,\n  getOklchPlanePixels,\n  hslToRgb,\n  hsvToHsl,\n  normalizeHue,\n  oklchPlaneToRgb,\n  oklchToRgb,\n  parseColorInput,\n  rgbaToHex,\n  rgbToHsv,\n  rgbToOklch,\n  rgbToOklchPlane,\n  type ColorFormat,\n  type ColorParts,\n  type HslColor,\n  type HsvColor,\n  type OklchColor,\n  type RgbColor,\n} from \"@/lib/color-picker-utils\"\nimport type { RegistryIconProps } from \"@/lib/registry-icon-props\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n} from \"@/components/ui/select\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/components/icon-placeholder\"\n\nconst cossInputKeyboardFocusRing =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\nconst cossInputWrapperFocusRing =\n  \"focus-within:border-input-focus focus-within:ring-1 focus-within:ring-input-focus/24 focus-within:ring-offset-0 dark:focus-within:border-input-focus dark:focus-within:ring-input-focus/24\"\nconst cossOutlineEdge =\n  \"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit]\"\nconst cossOutlineEdgeHighlight =\n  \"before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]\"\n\ninterface HsvaColor {\n  h: number\n  s: number\n  v: number\n  a: number\n}\n\ntype EyeDropperConstructor = new () => {\n  open: () => Promise<{ sRGBHex: string }>\n}\n\ndeclare global {\n  interface Window {\n    EyeDropper?: EyeDropperConstructor\n  }\n}\n\nconst COLOR_FORMAT_OPTIONS: Array<{ value: ColorFormat; label: string }> = [\n  { value: \"oklch\", label: \"OKLCH\" },\n  { value: \"hsl\", label: \"HSL\" },\n  { value: \"rgb\", label: \"RGB\" },\n  { value: \"hex\", label: \"Hex\" },\n]\n\nconst OK_HUE_TRACK = `linear-gradient(to bottom, ${Array.from(\n  { length: 25 },\n  (_, i) => rgbaToHex(cuspColorForHue(i * 15))\n).join(\", \")})`\n\n// Ignore hue noise from RGB round trips.\nfunction withHueContinuity<T extends { h: number }>(\n  next: T,\n  previousHue: number,\n  chromaFraction: number\n): T {\n  const delta = Math.abs(next.h - previousHue) % 360\n  const angular = Math.min(delta, 360 - delta)\n  const tolerance = Math.min(10, 1.5 / Math.max(chromaFraction, 0.15))\n  return angular < tolerance ? { ...next, h: previousHue } : next\n}\n\nconst triggerGroupClass =\n  \"relative flex h-10 w-full min-w-0 items-center rounded-md border border-input bg-popover not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-input-focus/32 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] not-has-disabled:not-has-focus-visible:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:border-input-focus has-focus-visible:ring-[3px] has-disabled:opacity-64 has-[:disabled,:focus-visible]:shadow-none dark:bg-input/32 dark:ring-input-focus/32 dark:has-focus-visible:border-input-focus dark:not-has-focus-visible:border-white/[0.08] dark:not-has-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-has-disabled:not-has-focus-visible:before:shadow-[0_-1px_--theme(--color-white/6%)]\"\n\nconst fieldSurfaceClass = cn(\n  \"relative rounded-md border border-input bg-popover text-foreground shadow-xs/5 transition-shadow not-dark:bg-clip-padding dark:border-white/[0.08] dark:bg-input/32\",\n  cossOutlineEdge,\n  cossOutlineEdgeHighlight,\n  cossInputWrapperFocusRing\n)\n\nconst channelInputClass =\n  \"h-full w-full min-w-0 border-0 bg-transparent px-1 py-0 text-center text-sm leading-none tabular-nums text-foreground outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus:border-0 focus:outline-none focus:ring-0\"\n\nconst thumbShadow = \"shadow-[0_0_0_1px_rgb(0_0_0/24%),0_1px_2px_rgb(0_0_0/32%)]\"\n\nfunction Checkerboard({\n  className,\n  size = 8,\n}: {\n  className?: string\n  size?: number\n}) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"pointer-events-none absolute inset-0 bg-white [--checker:#00000024] dark:bg-neutral-700 dark:[--checker:#ffffff2e]\",\n        className\n      )}\n      style={{\n        backgroundImage:\n          \"conic-gradient(var(--checker) 0 25%, transparent 0 50%, var(--checker) 0 75%, transparent 0)\",\n        backgroundSize: `${size}px ${size}px`,\n      }}\n    />\n  )\n}\n\ninterface DraftInputProps\n  extends Omit<\n    React.InputHTMLAttributes<HTMLInputElement>,\n    \"value\" | \"onChange\"\n  > {\n  value: string\n  onCommitText: (text: string) => void\n  onStep?: (direction: 1 | -1, big: boolean) => void\n  copyValue?: () => string\n  inputRef?: React.MutableRefObject<HTMLInputElement | null>\n}\n\nfunction DraftInput({\n  value,\n  onCommitText,\n  onStep,\n  copyValue,\n  inputRef,\n  ...props\n}: DraftInputProps) {\n  const [draft, setDraft] = React.useState<string | null>(null)\n  const internalRef = React.useRef<HTMLInputElement | null>(null)\n  // Keep mouseup from clearing the focus selection.\n  const keepSelectionRef = React.useRef(false)\n\n  const setRefs = (node: HTMLInputElement | null) => {\n    internalRef.current = node\n    if (inputRef) inputRef.current = node\n  }\n\n  const commitDraft = () => {\n    if (draft !== null && draft.trim() !== value) onCommitText(draft)\n    setDraft(null)\n  }\n\n  const reselect = () => {\n    requestAnimationFrame(() => internalRef.current?.select())\n  }\n\n  return (\n    <input\n      autoComplete=\"off\"\n      autoCorrect=\"off\"\n      spellCheck={false}\n      type=\"text\"\n      {...props}\n      ref={setRefs}\n      value={draft ?? value}\n      onFocus={(event) => {\n        setDraft(event.currentTarget.value)\n        event.currentTarget.select()\n        keepSelectionRef.current = true\n      }}\n      onMouseUp={(event) => {\n        if (keepSelectionRef.current) {\n          event.preventDefault()\n          keepSelectionRef.current = false\n        }\n      }}\n      onChange={(event) => setDraft(event.target.value)}\n      onBlur={commitDraft}\n      onCopy={(event) => {\n        if (!copyValue) return\n        const input = event.currentTarget\n        const fullySelected =\n          input.value.length > 0 &&\n          input.selectionStart === 0 &&\n          input.selectionEnd === input.value.length\n        if (!fullySelected) return\n        event.preventDefault()\n        event.clipboardData.setData(\"text/plain\", copyValue())\n      }}\n      onKeyDown={(event) => {\n        if (event.key === \"Enter\") {\n          commitDraft()\n          reselect()\n        } else if (event.key === \"Escape\") {\n          if (draft !== null) {\n            event.stopPropagation()\n            setDraft(null)\n          }\n        } else if (\n          onStep &&\n          (event.key === \"ArrowUp\" || event.key === \"ArrowDown\")\n        ) {\n          event.preventDefault()\n          setDraft(null)\n          onStep(event.key === \"ArrowUp\" ? 1 : -1, event.shiftKey)\n          reselect()\n        }\n      }}\n    />\n  )\n}\n\ninterface ChannelSpec {\n  key: string\n  label: string\n  ariaLabel: string\n  value: string\n  numericValue: number\n  step: number\n  bigStep: number\n  commit: (next: number) => void\n}\n\nfunction parseChannelText(text: string): number | null {\n  const numeric = Number.parseFloat(text.replace(/[%°]/g, \"\").trim())\n  return Number.isFinite(numeric) ? numeric : null\n}\n\nfunction ChannelGroup({\n  channels,\n  copyValue,\n  disabled,\n}: {\n  channels: ChannelSpec[]\n  copyValue: string\n  disabled?: boolean\n}) {\n  return (\n    <div className=\"flex items-start gap-1.5\">\n      <div className=\"min-w-0 flex-1\">\n        <div className={cn(fieldSurfaceClass, \"grid h-8 grid-cols-3\")}>\n          {channels.map((channel) => (\n            <DraftInput\n              key={channel.key}\n              aria-label={channel.ariaLabel}\n              className={channelInputClass}\n              disabled={disabled}\n              inputMode=\"decimal\"\n              value={channel.value}\n              onCommitText={(text) => {\n                const numeric = parseChannelText(text)\n                if (numeric !== null) channel.commit(numeric)\n              }}\n              onStep={(direction, big) =>\n                channel.commit(\n                  channel.numericValue +\n                    direction * (big ? channel.bigStep : channel.step)\n                )\n              }\n            />\n          ))}\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-y-0 left-1/3 w-px bg-input dark:bg-white/[0.08]\"\n          />\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-y-0 left-2/3 w-px bg-input dark:bg-white/[0.08]\"\n          />\n        </div>\n        <div className=\"mt-1 grid h-4 grid-cols-3 items-center text-center text-xs font-medium text-muted-foreground\">\n          {channels.map((channel) => (\n            <span key={channel.key}>{channel.label}</span>\n          ))}\n        </div>\n      </div>\n      <CopyColorButton text={copyValue} />\n    </div>\n  )\n}\n\nfunction SaturationValueArea({\n  hue,\n  s,\n  v,\n  color,\n  onMove,\n}: {\n  hue: number\n  s: number\n  v: number\n  color: string\n  onMove: (s: number, v: number) => void\n}) {\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null)\n  // Avoid redraws for imperceptible hue changes.\n  const quantizedHue = Math.round(normalizeHue(hue) * 2) / 2\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    const context = canvas?.getContext(\"2d\")\n    if (!canvas || !context) return\n    const image = context.createImageData(canvas.width, canvas.height)\n    image.data.set(\n      getOklchPlanePixels(canvas.width, canvas.height, quantizedHue)\n    )\n    context.putImageData(image, 0, 0)\n  }, [quantizedHue])\n\n  const update = (event: React.PointerEvent<HTMLButtonElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect()\n    const inset = 7\n    onMove(\n      clamp(\n        ((event.clientX - rect.left - inset) / (rect.width - inset * 2)) * 100,\n        0,\n        100\n      ),\n      clamp(\n        100 -\n          ((event.clientY - rect.top - inset) / (rect.height - inset * 2)) *\n            100,\n        0,\n        100\n      )\n    )\n  }\n\n  return (\n    <button\n      type=\"button\"\n      aria-label=\"Chroma and lightness\"\n      className={cn(\n        \"relative size-53 shrink-0 cursor-crosshair touch-none rounded-lg\",\n        cossInputKeyboardFocusRing\n      )}\n      onPointerDown={(event) => {\n        event.preventDefault()\n        event.currentTarget.setPointerCapture(event.pointerId)\n        event.currentTarget.focus()\n        update(event)\n      }}\n      onPointerMove={(event) => {\n        if ((event.buttons & 1) === 0) return\n        update(event)\n      }}\n      onKeyDown={(event) => {\n        const delta = event.shiftKey ? 10 : 1\n        let next: [number, number] | null = null\n        if (event.key === \"ArrowLeft\") next = [s - delta, v]\n        if (event.key === \"ArrowRight\") next = [s + delta, v]\n        if (event.key === \"ArrowUp\") next = [s, v + delta]\n        if (event.key === \"ArrowDown\") next = [s, v - delta]\n        if (next) {\n          event.preventDefault()\n          onMove(clamp(next[0], 0, 100), clamp(next[1], 0, 100))\n        }\n      }}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        width={168}\n        height={168}\n        className=\"pointer-events-none absolute inset-0 size-full rounded-[inherit]\"\n      />\n      <span\n        className={cn(\n          \"pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] border-white\",\n          thumbShadow\n        )}\n        style={{\n          left: `calc(7px + (100% - 14px) * ${clamp(s, 0, 100) / 100})`,\n          top: `calc(7px + (100% - 14px) * ${clamp(100 - v, 0, 100) / 100})`,\n          backgroundColor: color,\n        }}\n      />\n    </button>\n  )\n}\n\nfunction VerticalSlider({\n  ariaLabel,\n  ariaValueMax,\n  ariaValueNow,\n  ariaValueText,\n  fraction,\n  onFraction,\n  onStep,\n  track,\n}: {\n  ariaLabel: string\n  ariaValueMax: number\n  ariaValueNow: number\n  ariaValueText?: string\n  fraction: number\n  onFraction: (fraction: number) => void\n  onStep: (direction: 1 | -1, big: boolean) => void\n  track: React.ReactNode\n}) {\n  const update = (event: React.PointerEvent<HTMLButtonElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect()\n    const inset = 2.5\n    onFraction(\n      clamp(\n        (event.clientY - rect.top - inset) / (rect.height - inset * 2),\n        0,\n        1\n      )\n    )\n  }\n\n  return (\n    <button\n      type=\"button\"\n      role=\"slider\"\n      aria-label={ariaLabel}\n      aria-orientation=\"vertical\"\n      aria-valuemin={0}\n      aria-valuemax={ariaValueMax}\n      aria-valuenow={ariaValueNow}\n      aria-valuetext={ariaValueText}\n      className={cn(\n        \"relative w-3 shrink-0 touch-none self-stretch rounded-full\",\n        cossInputKeyboardFocusRing\n      )}\n      onPointerDown={(event) => {\n        event.preventDefault()\n        event.currentTarget.setPointerCapture(event.pointerId)\n        event.currentTarget.focus()\n        update(event)\n      }}\n      onPointerMove={(event) => {\n        if ((event.buttons & 1) === 0) return\n        update(event)\n      }}\n      onKeyDown={(event) => {\n        let direction: 1 | -1 | null = null\n        if (event.key === \"ArrowUp\" || event.key === \"ArrowRight\") direction = 1\n        if (event.key === \"ArrowDown\" || event.key === \"ArrowLeft\")\n          direction = -1\n        if (direction) {\n          event.preventDefault()\n          onStep(direction, event.shiftKey)\n        }\n      }}\n    >\n      <span className=\"absolute inset-0 overflow-hidden rounded-full ring-1 ring-black/8 ring-inset dark:ring-white/12\">\n        {track}\n      </span>\n      <span\n        className=\"pointer-events-none absolute left-1/2 h-[5px] w-[calc(100%+3px)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/20%),0_1px_2px_rgb(0_0_0/24%)]\"\n        style={{ top: `calc(2.5px + (100% - 5px) * ${clamp(fraction, 0, 1)})` }}\n      />\n    </button>\n  )\n}\n\nfunction CopyColorButton({\n  text,\n  label = \"Copy color\",\n}: {\n  text: string\n  label?: string\n}) {\n  const [copied, setCopied] = React.useState(false)\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(text)\n      setCopied(true)\n      window.setTimeout(() => setCopied(false), 1200)\n    } catch {\n      // Clipboard access can be unavailable outside secure browser contexts.\n    }\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"icon-sm\"\n            aria-label={label}\n            className=\"size-8! shrink-0\"\n            onClick={handleCopy}\n          />\n        }\n      >\n        {copied ? (\n          <IconPlaceholder\n            lucide=\"Check\"\n            tabler=\"IconCheck\"\n            hugeicons=\"Tick02Icon\"\n            phosphor=\"CheckIcon\"\n            remixicon=\"RiCheckLine\"\n            className=\"size-4\"\n          />\n        ) : (\n          <IconPlaceholder\n            lucide=\"Copy\"\n            tabler=\"IconCopy\"\n            hugeicons=\"Copy01Icon\"\n            phosphor=\"CopyIcon\"\n            remixicon=\"RiFileCopyLine\"\n            className=\"size-4\"\n          />\n        )}\n      </TooltipTrigger>\n      <TooltipContent side=\"top\">{copied ? \"Copied\" : \"Copy\"}</TooltipContent>\n    </Tooltip>\n  )\n}\n\nfunction AlphaScrubField({\n  alphaPercent,\n  disabled,\n  onCommit,\n  scrubbable = true,\n  className,\n  inputClassName,\n  suffixClassName,\n}: {\n  alphaPercent: number\n  disabled?: boolean\n  onCommit: (percent: number) => void\n  scrubbable?: boolean\n  className?: string\n  inputClassName?: string\n  suffixClassName?: string\n}) {\n  const inputRef = React.useRef<HTMLInputElement | null>(null)\n  const scrubRef = React.useRef<{\n    startX: number\n    startPercent: number\n    scrubbed: boolean\n  } | null>(null)\n  const rounded = Math.round(alphaPercent)\n\n  const scrubHandlers: React.HTMLAttributes<HTMLDivElement> = scrubbable\n    ? {\n        onPointerDown: (event) => {\n          if (disabled) return\n          if (document.activeElement === inputRef.current) return\n          event.preventDefault()\n          event.currentTarget.setPointerCapture(event.pointerId)\n          scrubRef.current = {\n            startX: event.clientX,\n            startPercent: alphaPercent,\n            scrubbed: false,\n          }\n        },\n        onPointerMove: (event) => {\n          const scrub = scrubRef.current\n          if (!scrub || (event.buttons & 1) === 0) return\n          const dx = event.clientX - scrub.startX\n          if (!scrub.scrubbed && Math.abs(dx) < 3) return\n          scrub.scrubbed = true\n          onCommit(clamp(scrub.startPercent + dx / 2, 0, 100))\n        },\n        onPointerUp: () => {\n          const scrub = scrubRef.current\n          scrubRef.current = null\n          if (scrub && !scrub.scrubbed) inputRef.current?.focus()\n        },\n        onPointerCancel: () => {\n          scrubRef.current = null\n        },\n      }\n    : {}\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full shrink-0 items-center gap-0.5\",\n        scrubbable && \"cursor-ew-resize touch-none select-none\",\n        className\n      )}\n      {...scrubHandlers}\n    >\n      <DraftInput\n        aria-label=\"Opacity\"\n        className={cn(\n          \"border-0 bg-transparent p-0 text-right tabular-nums outline-none focus:border-0 focus:ring-0\",\n          inputClassName ?? \"w-8 text-base\"\n        )}\n        disabled={disabled}\n        inputMode=\"decimal\"\n        inputRef={inputRef}\n        value={String(rounded)}\n        onCommitText={(text) => {\n          const numeric = parseChannelText(text)\n          if (numeric !== null) onCommit(clamp(numeric, 0, 100))\n        }}\n        onStep={(direction, big) =>\n          onCommit(clamp(alphaPercent + direction * (big ? 10 : 1), 0, 100))\n        }\n      />\n      <span\n        className={cn(\"text-muted-foreground\", suffixClassName ?? \"text-base\")}\n      >\n        %\n      </span>\n    </div>\n  )\n}\n\nexport type ColorPickerProps = {\n  value?: string\n  color?: string\n  onChange?: (value: string) => void\n  label?: string\n  disabled?: boolean\n  showAlpha?: boolean\n  defaultFormat?: ColorFormat\n  variant?: \"input\" | \"swatch\" | \"panel\"\n  className?: string\n  icon?: React.ComponentType<RegistryIconProps>\n  rainbowTrigger?: boolean\n  onTriggerMouseDown?: React.MouseEventHandler<HTMLButtonElement>\n  onTriggerPointerDown?: React.PointerEventHandler<HTMLButtonElement>\n}\n\nexport function ColorPicker({\n  value: valueProp,\n  color,\n  onChange,\n  label = \"Choose color\",\n  disabled = false,\n  showAlpha = false,\n  defaultFormat = \"hex\",\n  variant,\n  className,\n  icon: IconComponent,\n  rainbowTrigger = false,\n  onTriggerMouseDown,\n  onTriggerPointerDown,\n}: ColorPickerProps) {\n  const value = valueProp ?? color ?? \"#111827\"\n  const resolvedVariant = variant ?? (color !== undefined ? \"swatch\" : \"input\")\n  const [open, setOpen] = React.useState(false)\n\n  // Keep drag coordinates independent from exact typed RGB values.\n  const [picker, setPicker] = React.useState<{\n    plane: HsvColor\n    rgb: RgbColor\n    a: number\n  }>(() => {\n    const parsed = parseColorInput(value, \"hex\")\n    if (!parsed)\n      return { plane: { h: 0, s: 0, v: 0 }, rgb: { r: 0, g: 0, b: 0 }, a: 1 }\n    return {\n      plane: rgbToOklchPlane(parsed.rgb),\n      rgb: parsed.rgb,\n      a: showAlpha ? (parsed.alpha ?? 1) : 1,\n    }\n  })\n  const [format, setFormat] = React.useState<ColorFormat>(defaultFormat)\n  const [controlledValue, setControlledValue] = React.useState({\n    value,\n    showAlpha,\n    pending: [] as string[],\n  })\n  const triggerGroupRef = React.useRef<HTMLDivElement>(null)\n\n  if (\n    controlledValue.value !== value ||\n    controlledValue.showAlpha !== showAlpha\n  ) {\n    const parsed = value ? parseColorInput(value, \"hex\") : null\n    const alpha = showAlpha ? (parsed?.alpha ?? picker.a) : 1\n    const incoming = parsed ? rgbaToHex(parsed.rgb, alpha) : null\n    const echoIndex =\n      incoming === null ? -1 : controlledValue.pending.lastIndexOf(incoming)\n    setControlledValue({\n      value,\n      showAlpha,\n      pending:\n        echoIndex < 0 ? [] : controlledValue.pending.slice(echoIndex + 1),\n    })\n    if (\n      parsed &&\n      (echoIndex < 0 ||\n        echoIndex === controlledValue.pending.length - 1 ||\n        controlledValue.showAlpha !== showAlpha)\n    ) {\n      if (rgbaToHex(picker.rgb, picker.a) !== incoming) {\n        const derived = rgbToOklchPlane(parsed.rgb, picker.plane.h)\n        setPicker({\n          plane: withHueContinuity(derived, picker.plane.h, derived.s / 100),\n          rgb: parsed.rgb,\n          a: alpha,\n        })\n      }\n    }\n  }\n\n  const applyState = (next: { plane: HsvColor; rgb: RgbColor; a: number }) => {\n    setPicker(next)\n    const hex = rgbaToHex(next.rgb, next.a)\n    if ((valueProp !== undefined || color !== undefined) && onChange) {\n      setControlledValue((current) => ({\n        ...current,\n        pending: [...current.pending, hex],\n      }))\n    }\n    onChange?.(hex)\n  }\n\n  const commitHsva = (next: HsvaColor) => {\n    const plane: HsvColor = {\n      h: clamp(next.h, 0, 360),\n      s: clamp(next.s, 0, 100),\n      v: clamp(next.v, 0, 100),\n    }\n    applyState({\n      plane,\n      rgb: oklchPlaneToRgb(plane),\n      a: showAlpha ? clamp(next.a, 0, 1) : 1,\n    })\n  }\n\n  const hsva: HsvaColor = { ...picker.plane, a: picker.a }\n  const rgb = picker.rgb\n  // Preserve hue for achromatic colors.\n  const [srgbHue, setSrgbHue] = React.useState(0)\n  const displayHsv = rgbToHsv(rgb, srgbHue)\n  if (srgbHue !== displayHsv.h) setSrgbHue(displayHsv.h)\n  const hsl = hsvToHsl(displayHsv)\n  const rawOklch = rgbToOklch(rgb, hsva.h)\n  const oklch = withHueContinuity(rawOklch, hsva.h, rawOklch.c / 0.33)\n  const parts: ColorParts = { rgb, hsl, oklch, alpha: hsva.a }\n  const opaqueColor = `rgb(${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)})`\n  const currentColor = `rgb(${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)} / ${hsva.a})`\n\n  const commitRgb = (\n    nextRgb: RgbColor,\n    alpha?: number | null,\n    intendedHue?: number\n  ) => {\n    const exact: RgbColor = {\n      r: clamp(nextRgb.r, 0, 255),\n      g: clamp(nextRgb.g, 0, 255),\n      b: clamp(nextRgb.b, 0, 255),\n    }\n    const targetHue = intendedHue ?? picker.plane.h\n    const derived = rgbToOklchPlane(exact, targetHue)\n    applyState({\n      plane: withHueContinuity(derived, targetHue, derived.s / 100),\n      rgb: exact,\n      a: showAlpha ? clamp(alpha ?? picker.a, 0, 1) : 1,\n    })\n  }\n  const commitHsl = (nextHsl: HslColor) => commitRgb(hslToRgb(nextHsl))\n  const commitOklch = (nextOklch: OklchColor) =>\n    commitRgb(oklchToRgb(nextOklch), undefined, normalizeHue(nextOklch.h))\n  const commitText = (text: string) => {\n    const parsed = parseColorInput(text, format)\n    if (!parsed) return\n    if (parsed.format && parsed.format !== format) setFormat(parsed.format)\n    commitRgb(parsed.rgb, parsed.alpha)\n  }\n\n  const cssColor = formatCssColor(format, parts, { includeAlpha: showAlpha })\n  const portalContainer =\n    typeof document === \"undefined\" ? undefined : document.body\n  const supportsEyeDropper =\n    typeof window !== \"undefined\" && window.EyeDropper !== undefined\n\n  const handleEyeDropper = () => {\n    const eyeDropperCtor = window.EyeDropper\n    if (!eyeDropperCtor) return\n    new eyeDropperCtor()\n      .open()\n      .then((result) => {\n        const parsed = parseColorInput(result.sRGBHex, \"hex\")\n        if (parsed) commitRgb(parsed.rgb)\n      })\n      .catch(() => {})\n  }\n\n  const oklchChannels: ChannelSpec[] = [\n    {\n      key: \"l\",\n      label: \"L\",\n      ariaLabel: \"OKLCH lightness\",\n      value: (oklch.l * 100).toFixed(1),\n      numericValue: oklch.l * 100,\n      step: 1,\n      bigStep: 10,\n      commit: (next) => commitOklch({ ...oklch, l: clamp(next, 0, 100) / 100 }),\n    },\n    {\n      key: \"c\",\n      label: \"C\",\n      ariaLabel: \"OKLCH chroma\",\n      value: oklch.c.toFixed(3),\n      numericValue: oklch.c,\n      step: 0.01,\n      bigStep: 0.05,\n      commit: (next) => commitOklch({ ...oklch, c: Math.max(0, next) }),\n    },\n    {\n      key: \"h\",\n      label: \"H\",\n      ariaLabel: \"OKLCH hue\",\n      value: oklch.h.toFixed(1),\n      numericValue: oklch.h,\n      step: 1,\n      bigStep: 10,\n      commit: (next) => commitOklch({ ...oklch, h: normalizeHue(next) }),\n    },\n  ]\n\n  const hslChannels: ChannelSpec[] = [\n    {\n      key: \"h\",\n      label: \"H\",\n      ariaLabel: \"HSL hue\",\n      value: hsl.h.toFixed(1),\n      numericValue: hsl.h,\n      step: 1,\n      bigStep: 10,\n      commit: (next) => commitHsl({ ...hsl, h: normalizeHue(next) }),\n    },\n    {\n      key: \"s\",\n      label: \"S\",\n      ariaLabel: \"HSL saturation\",\n      value: hsl.s.toFixed(1),\n      numericValue: hsl.s,\n      step: 1,\n      bigStep: 10,\n      commit: (next) => commitHsl({ ...hsl, s: clamp(next, 0, 100) }),\n    },\n    {\n      key: \"l\",\n      label: \"L\",\n      ariaLabel: \"HSL lightness\",\n      value: hsl.l.toFixed(1),\n      numericValue: hsl.l,\n      step: 1,\n      bigStep: 10,\n      commit: (next) => commitHsl({ ...hsl, l: clamp(next, 0, 100) }),\n    },\n  ]\n\n  const rgbChannels: ChannelSpec[] = ([\"r\", \"g\", \"b\"] as const).map(\n    (channel) => ({\n      key: channel,\n      label: channel.toUpperCase(),\n      ariaLabel: { r: \"Red\", g: \"Green\", b: \"Blue\" }[channel],\n      value: String(Math.round(rgb[channel])),\n      numericValue: rgb[channel],\n      step: 1,\n      bigStep: 10,\n      commit: (next) => commitRgb({ ...rgb, [channel]: clamp(next, 0, 255) }),\n    })\n  )\n\n  const panel = (\n    <div\n      className={cn(\n        \"flex items-stretch gap-3\",\n        resolvedVariant === \"panel\" && className\n      )}\n    >\n      <SaturationValueArea\n        hue={hsva.h}\n        s={hsva.s}\n        v={hsva.v}\n        color={opaqueColor}\n        onMove={(s, v) => commitHsva({ ...hsva, s, v })}\n      />\n\n      <div className=\"flex shrink-0 flex-col items-center gap-1.5 self-stretch\">\n        <div className=\"flex min-h-0 flex-1 items-stretch gap-2\">\n          {showAlpha && (\n            <VerticalSlider\n              ariaLabel=\"Opacity\"\n              ariaValueMax={100}\n              ariaValueNow={Math.round(hsva.a * 100)}\n              ariaValueText={`${Math.round(hsva.a * 100)}%`}\n              fraction={1 - hsva.a}\n              onFraction={(fraction) =>\n                commitHsva({ ...hsva, a: 1 - fraction })\n              }\n              onStep={(direction, big) =>\n                commitHsva({\n                  ...hsva,\n                  a: clamp(hsva.a + direction * (big ? 0.1 : 0.01), 0, 1),\n                })\n              }\n              track={\n                <>\n                  <Checkerboard size={6} />\n                  <span\n                    className=\"absolute inset-0\"\n                    style={{\n                      background: `linear-gradient(to bottom, ${opaqueColor}, transparent)`,\n                    }}\n                  />\n                </>\n              }\n            />\n          )}\n\n          <VerticalSlider\n            ariaLabel=\"Hue\"\n            ariaValueMax={360}\n            ariaValueNow={Math.round(hsva.h)}\n            fraction={clamp(hsva.h, 0, 360) / 360}\n            onFraction={(fraction) =>\n              commitHsva({ ...hsva, h: fraction * 360 })\n            }\n            onStep={(direction, big) =>\n              commitHsva({\n                ...hsva,\n                h: clamp(hsva.h - direction * (big ? 10 : 1), 0, 360),\n              })\n            }\n            track={\n              <span\n                className=\"absolute inset-0\"\n                style={{ background: OK_HUE_TRACK }}\n              />\n            }\n          />\n        </div>\n        {supportsEyeDropper ? (\n          <Tooltip>\n            <TooltipTrigger\n              render={\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  size=\"icon-sm\"\n                  aria-label=\"Pick color from screen\"\n                  className=\"size-8! shrink-0\"\n                  onClick={handleEyeDropper}\n                />\n              }\n            >\n              <IconPlaceholder\n                lucide=\"Pipette\"\n                tabler=\"IconColorPicker\"\n                hugeicons=\"ColorPickerIcon\"\n                phosphor=\"EyedropperIcon\"\n                remixicon=\"RiDropperLine\"\n                className=\"size-4\"\n              />\n            </TooltipTrigger>\n            <TooltipContent side=\"top\" className=\"whitespace-nowrap\">\n              Pick color from screen\n            </TooltipContent>\n          </Tooltip>\n        ) : null}\n      </div>\n\n      <div className=\"flex w-64 shrink-0 flex-col gap-2\">\n        <ChannelGroup\n          channels={oklchChannels}\n          copyValue={formatCssColor(\"oklch\", parts, {\n            includeAlpha: showAlpha,\n          })}\n          disabled={disabled}\n        />\n        <ChannelGroup\n          channels={hslChannels}\n          copyValue={formatCssColor(\"hsl\", parts, { includeAlpha: showAlpha })}\n          disabled={disabled}\n        />\n        <ChannelGroup\n          channels={rgbChannels}\n          copyValue={formatCssColor(\"rgb\", parts, { includeAlpha: showAlpha })}\n          disabled={disabled}\n        />\n\n        <div className=\"flex items-center gap-1.5\">\n          <div\n            className={cn(\n              fieldSurfaceClass,\n              \"flex h-8 min-w-0 flex-1 items-center\"\n            )}\n          >\n            <DraftInput\n              aria-label=\"Color value\"\n              className=\"h-full min-w-0 flex-1 rounded-[inherit] border-0 bg-transparent px-2 py-0 text-center text-sm leading-none tabular-nums outline-none focus:border-0 focus:ring-0\"\n              copyValue={() => cssColor}\n              value={formatColorValue(format, parts)}\n              onCommitText={commitText}\n            />\n            {showAlpha && (\n              <AlphaScrubField\n                alphaPercent={hsva.a * 100}\n                disabled={disabled}\n                scrubbable={false}\n                className=\"gap-0.5 pr-2 pl-0.5\"\n                inputClassName=\"w-8 text-sm\"\n                suffixClassName=\"text-sm\"\n                onCommit={(percent) =>\n                  commitHsva({ ...hsva, a: percent / 100 })\n                }\n              />\n            )}\n          </div>\n          <Select\n            value={format}\n            onValueChange={(next) => {\n              if (\n                COLOR_FORMAT_OPTIONS.some((option) => option.value === next)\n              ) {\n                setFormat(next as ColorFormat)\n              }\n            }}\n          >\n            <SelectTrigger\n              aria-label=\"Color format\"\n              className=\"size-8! min-h-0 min-w-0 shrink-0 justify-center p-0 [&>[data-slot=select-icon]]:hidden [&>svg]:hidden\"\n            >\n              <span className=\"flex items-center justify-center\">\n                <IconPlaceholder\n                  lucide=\"ChevronsUpDown\"\n                  tabler=\"IconSelector\"\n                  hugeicons=\"UnfoldMoreIcon\"\n                  phosphor=\"CaretUpDownIcon\"\n                  remixicon=\"RiExpandUpDownLine\"\n                  className=\"size-3.5\"\n                />\n              </span>\n            </SelectTrigger>\n            <SelectContent\n              alignItemWithTrigger={false}\n              align=\"end\"\n              className=\"min-w-28\"\n              portalProps={{ container: portalContainer }}\n            >\n              {COLOR_FORMAT_OPTIONS.map((option) => (\n                <SelectItem key={option.value} value={option.value}>\n                  {option.label}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n    </div>\n  )\n\n  if (resolvedVariant === \"panel\") return panel\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      {resolvedVariant === \"swatch\" ? (\n        <PopoverTrigger\n          render={\n            <Button\n              type=\"button\"\n              variant={IconComponent || rainbowTrigger ? \"ghost\" : \"outline\"}\n              size=\"icon-sm\"\n              aria-label={label}\n              disabled={disabled}\n              data-color-picker-trigger=\"\"\n              className={cn(\"relative shrink-0\", className)}\n              onMouseDown={onTriggerMouseDown}\n              onPointerDown={onTriggerPointerDown}\n            />\n          }\n        >\n          {rainbowTrigger ? (\n            <span\n              aria-hidden=\"true\"\n              className=\"size-5 shrink-0 rounded-full bg-[conic-gradient(from_90deg,#ff3b30,#ffcc00,#34c759,#00c7be,#007aff,#af52de,#ff2d55,#ff3b30)] shadow-[0_0_0_1px_rgb(0_0_0/10%)]\"\n            />\n          ) : IconComponent ? (\n            <>\n              <IconComponent className=\"size-4\" />\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute right-1 bottom-1 h-1 w-4 rounded-full border border-background\"\n                style={{ backgroundColor: currentColor }}\n              />\n            </>\n          ) : (\n            <span className=\"relative size-4 shrink-0 overflow-hidden rounded-[4px]\">\n              <Checkerboard size={4} />\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-0\"\n                style={{ backgroundColor: currentColor }}\n              />\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-0 rounded-[inherit] ring-1 ring-black/12 ring-inset dark:ring-white/16\"\n              />\n            </span>\n          )}\n        </PopoverTrigger>\n      ) : (\n        <div\n          ref={triggerGroupRef}\n          data-color-picker-trigger=\"\"\n          data-slot=\"color-picker\"\n          className={cn(triggerGroupClass, className)}\n        >\n          <PopoverTrigger\n            render={\n              <button\n                type=\"button\"\n                aria-label={label}\n                disabled={disabled}\n                className={cn(\n                  \"relative ml-2 size-6 shrink-0 overflow-hidden rounded-[calc(var(--radius-md)-2px)]\",\n                  cossInputKeyboardFocusRing\n                )}\n              />\n            }\n          >\n            <Checkerboard size={6} />\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0\"\n              style={{ backgroundColor: currentColor }}\n            />\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 rounded-[inherit] ring-1 ring-black/12 ring-inset dark:ring-white/16\"\n            />\n          </PopoverTrigger>\n          <DraftInput\n            aria-label={`${label} value`}\n            className=\"h-full w-full min-w-0 grow border-0 bg-transparent px-[calc(--spacing(2.5)-1px)] text-base tabular-nums outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus:border-0 focus:ring-0\"\n            copyValue={() => cssColor}\n            disabled={disabled}\n            value={formatColorValue(format, parts)}\n            onCommitText={commitText}\n          />\n          {showAlpha && (\n            <AlphaScrubField\n              alphaPercent={hsva.a * 100}\n              disabled={disabled}\n              className=\"pr-[calc(--spacing(3)-1px)] pl-1\"\n              onCommit={(percent) => commitHsva({ ...hsva, a: percent / 100 })}\n            />\n          )}\n        </div>\n      )}\n      <PopoverContent\n        anchor={resolvedVariant === \"swatch\" ? undefined : triggerGroupRef}\n        align={resolvedVariant === \"swatch\" ? \"end\" : \"start\"}\n        sideOffset={8}\n        portalProps={{ container: portalContainer }}\n        className=\"w-auto p-0 [&>[data-slot=popover-viewport]]:p-3.5!\"\n      >\n        {panel}\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport function ColorPickerPanel({\n  className,\n  color,\n  label,\n  onChange,\n  showAlpha = false,\n}: {\n  className?: string\n  color: string\n  label: string\n  onChange: (color: string) => void\n  showAlpha?: boolean\n}) {\n  return (\n    <ColorPicker\n      className={className}\n      value={color}\n      label={label}\n      onChange={onChange}\n      showAlpha={showAlpha}\n      variant=\"panel\"\n    />\n  )\n}\n",
      "type": "registry:ui",
      "target": "@ui/color-picker.tsx"
    },
    {
      "path": "lib/color-picker-utils.ts",
      "content": "export type ColorFormat = \"oklch\" | \"hsl\" | \"rgb\" | \"hex\"\n\n/** 0-255 floats. */\nexport interface RgbColor {\n  r: number\n  g: number\n  b: number\n}\n\n/** h in 0-360, s/l in 0-100. */\nexport interface HslColor {\n  h: number\n  s: number\n  l: number\n}\n\n/** h in 0-360, s/v in 0-100. */\nexport interface HsvColor {\n  h: number\n  s: number\n  v: number\n}\n\n/** L: 0-1; C: >= 0; H: 0-360. */\nexport interface OklchColor {\n  l: number\n  c: number\n  h: number\n}\n\nexport interface ParsedColor {\n  rgb: RgbColor\n  alpha: number | null\n  format: ColorFormat | null\n}\n\nexport interface ColorParts {\n  rgb: RgbColor\n  hsl: HslColor\n  oklch: OklchColor\n  alpha: number\n}\n\nexport function clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value))\n}\n\nexport function normalizeHue(value: number): number {\n  return ((value % 360) + 360) % 360\n}\n\nexport function hsvToRgb({ h, s, v }: HsvColor): RgbColor {\n  const hue = normalizeHue(h) / 60\n  const sat = clamp(s, 0, 100) / 100\n  const val = clamp(v, 0, 100) / 100\n  const sector = Math.floor(hue) % 6\n  const f = hue - Math.floor(hue)\n  const p = val * (1 - sat)\n  const q = val * (1 - f * sat)\n  const t = val * (1 - (1 - f) * sat)\n  const pick: [number, number, number] =\n    sector === 0\n      ? [val, t, p]\n      : sector === 1\n        ? [q, val, p]\n        : sector === 2\n          ? [p, val, t]\n          : sector === 3\n            ? [p, q, val]\n            : sector === 4\n              ? [t, p, val]\n              : [val, p, q]\n\n  return { r: pick[0] * 255, g: pick[1] * 255, b: pick[2] * 255 }\n}\n\nexport function rgbToHsv(rgb: RgbColor, fallbackHue = 0): HsvColor {\n  const r = clamp(rgb.r, 0, 255) / 255\n  const g = clamp(rgb.g, 0, 255) / 255\n  const b = clamp(rgb.b, 0, 255) / 255\n  const max = Math.max(r, g, b)\n  const min = Math.min(r, g, b)\n  const delta = max - min\n  const v = max * 100\n  const s = max === 0 ? 0 : (delta / max) * 100\n\n  if (delta === 0) return { h: normalizeHue(fallbackHue), s, v }\n\n  let h: number\n  if (max === r) h = (g - b) / delta + (g < b ? 6 : 0)\n  else if (max === g) h = (b - r) / delta + 2\n  else h = (r - g) / delta + 4\n\n  return { h: normalizeHue(h * 60), s, v }\n}\n\nexport function hsvToHsl({ h, s, v }: HsvColor): HslColor {\n  const sat = clamp(s, 0, 100) / 100\n  const val = clamp(v, 0, 100) / 100\n  const l = val * (1 - sat / 2)\n  const hslSat = l === 0 || l === 1 ? 0 : (val - l) / Math.min(l, 1 - l)\n\n  return {\n    h: normalizeHue(h),\n    s: clamp(hslSat * 100, 0, 100),\n    l: clamp(l * 100, 0, 100),\n  }\n}\n\nexport function hslToHsv({ h, s, l }: HslColor): HsvColor {\n  const sat = clamp(s, 0, 100) / 100\n  const light = clamp(l, 0, 100) / 100\n  const v = light + sat * Math.min(light, 1 - light)\n  const hsvSat = v === 0 ? 0 : 2 * (1 - light / v)\n\n  return {\n    h: normalizeHue(h),\n    s: clamp(hsvSat * 100, 0, 100),\n    v: clamp(v * 100, 0, 100),\n  }\n}\n\nexport function hslToRgb(hsl: HslColor): RgbColor {\n  return hsvToRgb(hslToHsv(hsl))\n}\n\nfunction srgbToLinear(channel: number): number {\n  const c = channel / 255\n  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)\n}\n\nfunction linearToSrgb(channel: number): number {\n  const c =\n    channel <= 0.0031308\n      ? channel * 12.92\n      : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055\n  return c * 255\n}\n\ninterface OklabColor {\n  l: number\n  a: number\n  b: number\n}\n\nfunction rgbToOklab(rgb: RgbColor): OklabColor {\n  const r = srgbToLinear(clamp(rgb.r, 0, 255))\n  const g = srgbToLinear(clamp(rgb.g, 0, 255))\n  const b = srgbToLinear(clamp(rgb.b, 0, 255))\n\n  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)\n  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)\n  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)\n\n  return {\n    l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n    a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n    b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n  }\n}\n\nfunction oklchToOklab({ l, c, h }: OklchColor): OklabColor {\n  const hueRad = (normalizeHue(h) * Math.PI) / 180\n  return { l, a: c * Math.cos(hueRad), b: c * Math.sin(hueRad) }\n}\n\nexport function rgbToOklch(rgb: RgbColor, fallbackHue = 0): OklchColor {\n  const lab = rgbToOklab(rgb)\n  const c = Math.sqrt(lab.a * lab.a + lab.b * lab.b)\n  if (c < 1e-6)\n    return { l: clamp(lab.l, 0, 1), c: 0, h: normalizeHue(fallbackHue) }\n\n  return {\n    l: clamp(lab.l, 0, 1),\n    c,\n    h: normalizeHue((Math.atan2(lab.b, lab.a) * 180) / Math.PI),\n  }\n}\n\nfunction oklabToLinearSrgb({ l, a, b }: OklabColor): {\n  r: number\n  g: number\n  b: number\n} {\n  const lp = l + 0.3963377774 * a + 0.2158037573 * b\n  const mp = l - 0.1055613458 * a - 0.0638541728 * b\n  const sp = l - 0.0894841775 * a - 1.291485548 * b\n\n  const lc = lp * lp * lp\n  const mc = mp * mp * mp\n  const sc = sp * sp * sp\n\n  return {\n    r: 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,\n    g: -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,\n    b: -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc,\n  }\n}\n\nfunction oklabToRgbRaw(lab: OklabColor): RgbColor {\n  const linear = oklabToLinearSrgb(lab)\n  return {\n    r: linearToSrgb(linear.r),\n    g: linearToSrgb(linear.g),\n    b: linearToSrgb(linear.b),\n  }\n}\n\nfunction oklchToRgbRaw(oklch: OklchColor): RgbColor {\n  return oklabToRgbRaw(oklchToOklab(oklch))\n}\n\nfunction isInSrgbGamut(rgb: RgbColor): boolean {\n  const eps = 0.001\n  return (\n    rgb.r >= -eps &&\n    rgb.r <= 255 + eps &&\n    rgb.g >= -eps &&\n    rgb.g <= 255 + eps &&\n    rgb.b >= -eps &&\n    rgb.b <= 255 + eps\n  )\n}\n\nfunction clampRgb(rgb: RgbColor): RgbColor {\n  return {\n    r: clamp(rgb.r, 0, 255),\n    g: clamp(rgb.g, 0, 255),\n    b: clamp(rgb.b, 0, 255),\n  }\n}\n\nfunction deltaEok(first: OklabColor, second: OklabColor): number {\n  const dl = first.l - second.l\n  const da = first.a - second.a\n  const db = first.b - second.b\n  return Math.sqrt(dl * dl + da * da + db * db)\n}\n\n/** CSS Color 4 local-MINDE gamut mapping. */\nexport function oklchToRgb(oklch: OklchColor): RgbColor {\n  const target: OklchColor = {\n    l: clamp(oklch.l, 0, 1),\n    c: Math.max(0, oklch.c),\n    h: oklch.h,\n  }\n  if (target.l >= 1) return { r: 255, g: 255, b: 255 }\n  if (target.l <= 0) return { r: 0, g: 0, b: 0 }\n\n  const raw = oklchToRgbRaw(target)\n  if (isInSrgbGamut(raw)) return clampRgb(raw)\n\n  const jnd = 0.02\n  const epsilon = 0.0001\n\n  let clipped = clampRgb(raw)\n  if (deltaEok(rgbToOklab(clipped), oklchToOklab(target)) < jnd) return clipped\n\n  let min = 0\n  let max = target.c\n  let minInGamut = true\n\n  while (max - min > epsilon) {\n    const chroma = (min + max) / 2\n    const candidate: OklchColor = { ...target, c: chroma }\n    const candidateRaw = oklchToRgbRaw(candidate)\n\n    if (minInGamut && isInSrgbGamut(candidateRaw)) {\n      min = chroma\n      continue\n    }\n\n    clipped = clampRgb(candidateRaw)\n    const distance = deltaEok(rgbToOklab(clipped), oklchToOklab(candidate))\n    if (distance < jnd) {\n      if (jnd - distance < epsilon) return clipped\n      minInGamut = false\n      min = chroma\n    } else {\n      max = chroma\n    }\n  }\n\n  return clipped\n}\n\n// OKHSV cusp math: https://bottosson.github.io/posts/colorpicker/\n\nfunction computeMaxSaturation(a: number, b: number): number {\n  let k0: number, k1: number, k2: number, k3: number, k4: number\n  let wl: number, wm: number, ws: number\n\n  if (-1.88170328 * a - 0.80936493 * b > 1) {\n    k0 = 1.19086277\n    k1 = 1.76576728\n    k2 = 0.59662641\n    k3 = 0.75515197\n    k4 = 0.56771245\n    wl = 4.0767416621\n    wm = -3.3077115913\n    ws = 0.2309699292\n  } else if (1.81444104 * a - 1.19445276 * b > 1) {\n    k0 = 0.73956515\n    k1 = -0.45954404\n    k2 = 0.08285427\n    k3 = 0.1254107\n    k4 = 0.14503204\n    wl = -1.2684380046\n    wm = 2.6097574011\n    ws = -0.3413193965\n  } else {\n    k0 = 1.35733652\n    k1 = -0.00915799\n    k2 = -1.1513021\n    k3 = -0.50559606\n    k4 = 0.00692167\n    wl = -0.0041960863\n    wm = -0.7034186147\n    ws = 1.707614701\n  }\n\n  let s = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b\n\n  // Refine the gamut boundary.\n  const kl = 0.3963377774 * a + 0.2158037573 * b\n  const km = -0.1055613458 * a - 0.0638541728 * b\n  const ks = -0.0894841775 * a - 1.291485548 * b\n  const lp = 1 + s * kl\n  const mp = 1 + s * km\n  const sp = 1 + s * ks\n  const l = lp * lp * lp\n  const m = mp * mp * mp\n  const s3 = sp * sp * sp\n  const lds = 3 * kl * lp * lp\n  const mds = 3 * km * mp * mp\n  const sds = 3 * ks * sp * sp\n  const lds2 = 6 * kl * kl * lp\n  const mds2 = 6 * km * km * mp\n  const sds2 = 6 * ks * ks * sp\n  const f = wl * l + wm * m + ws * s3\n  const f1 = wl * lds + wm * mds + ws * sds\n  const f2 = wl * lds2 + wm * mds2 + ws * sds2\n  s = s - (f * f1) / (f1 * f1 - 0.5 * f * f2)\n\n  return s\n}\n\nfunction findCusp(a: number, b: number): { l: number; c: number } {\n  const sCusp = computeMaxSaturation(a, b)\n  const rgbAtMax = oklabToLinearSrgb({ l: 1, a: sCusp * a, b: sCusp * b })\n  const lCusp = Math.cbrt(1 / Math.max(rgbAtMax.r, rgbAtMax.g, rgbAtMax.b))\n  return { l: lCusp, c: lCusp * sCusp }\n}\n\nexport function maxChromaForHue(hue: number): number {\n  const hueRad = (normalizeHue(hue) * Math.PI) / 180\n  return findCusp(Math.cos(hueRad), Math.sin(hueRad)).c\n}\n\nexport function cuspColorForHue(hue: number): RgbColor {\n  const hueRad = (normalizeHue(hue) * Math.PI) / 180\n  const aUnit = Math.cos(hueRad)\n  const bUnit = Math.sin(hueRad)\n  const cusp = findCusp(aUnit, bUnit)\n  return clampRgb(\n    oklabToRgbRaw({ l: cusp.l, a: cusp.c * aUnit, b: cusp.c * bUnit })\n  )\n}\n\n// Lift saturated dark colors above black.\nconst PLANE_MIN_L = 0.25\n\nfunction planeLightnessFloor(chromaFraction: number): number {\n  return PLANE_MIN_L * chromaFraction\n}\n\nexport function oklchPlaneToRgb({ h, s, v }: HsvColor): RgbColor {\n  const s01 = clamp(s, 0, 100) / 100\n  const floor = planeLightnessFloor(s01)\n  const lightness = floor + (clamp(v, 0, 100) / 100) * (1 - floor)\n  return clampRgb(\n    oklchToRgbRaw({ l: lightness, c: s01 * maxChromaForHue(h), h })\n  )\n}\n\nexport function rgbToOklchPlane(rgb: RgbColor, fallbackHue = 0): HsvColor {\n  const oklch = rgbToOklch(rgb, fallbackHue)\n  // Preserve hue below the reliable chroma threshold.\n  const hue = oklch.c < 0.004 ? normalizeHue(fallbackHue) : oklch.h\n  const cMax = maxChromaForHue(hue)\n  const s01 = cMax > 0 ? clamp(oklch.c / cMax, 0, 1) : 0\n  const floor = planeLightnessFloor(s01)\n  return {\n    h: hue,\n    s: s01 * 100,\n    v: clamp((oklch.l - floor) / (1 - floor), 0, 1) * 100,\n  }\n}\n\n/** RGBA pixels for the picker plane. */\nexport function getOklchPlanePixels(\n  width: number,\n  height: number,\n  hue: number\n): Uint8ClampedArray {\n  const data = new Uint8ClampedArray(width * height * 4)\n  const hueRad = (normalizeHue(hue) * Math.PI) / 180\n  const aUnit = Math.cos(hueRad)\n  const bUnit = Math.sin(hueRad)\n  const cMax = findCusp(aUnit, bUnit).c\n\n  for (let x = 0; x < width; x += 1) {\n    const s01 = width === 1 ? 0 : x / (width - 1)\n    const chroma = s01 * cMax\n    const aChroma = chroma * aUnit\n    const bChroma = chroma * bUnit\n    const floor = planeLightnessFloor(s01)\n    for (let y = 0; y < height; y += 1) {\n      const v01 = height === 1 ? 1 : 1 - y / (height - 1)\n      const linear = oklabToLinearSrgb({\n        l: floor + v01 * (1 - floor),\n        a: aChroma,\n        b: bChroma,\n      })\n      const i = (y * width + x) * 4\n      data[i] = linearToSrgb(linear.r)\n      data[i + 1] = linearToSrgb(linear.g)\n      data[i + 2] = linearToSrgb(linear.b)\n      data[i + 3] = 255\n    }\n  }\n\n  return data\n}\n\nexport function formatNumber(value: number, maxDecimals: number): string {\n  const fixed = value.toFixed(maxDecimals)\n  if (!fixed.includes(\".\")) return fixed\n  const trimmed = fixed.replace(/0+$/, \"\").replace(/\\.$/, \"\")\n  return trimmed === \"-0\" ? \"0\" : trimmed\n}\n\nfunction toHexPair(channel: number): string {\n  return clamp(Math.round(channel), 0, 255)\n    .toString(16)\n    .padStart(2, \"0\")\n    .toUpperCase()\n}\n\nexport function rgbaToHex(rgb: RgbColor, alpha = 1): string {\n  const base = `#${toHexPair(rgb.r)}${toHexPair(rgb.g)}${toHexPair(rgb.b)}`\n  return alpha >= 1 ? base : `${base}${toHexPair(alpha * 255)}`\n}\n\nexport function formatColorValue(\n  format: ColorFormat,\n  parts: ColorParts\n): string {\n  const { rgb, hsl, oklch } = parts\n  switch (format) {\n    case \"hex\":\n      return rgbaToHex(rgb).slice(1)\n    case \"rgb\":\n      return `${Math.round(clamp(rgb.r, 0, 255))} ${Math.round(clamp(rgb.g, 0, 255))} ${Math.round(\n        clamp(rgb.b, 0, 255)\n      )}`\n    case \"hsl\":\n      return `${formatNumber(hsl.h, 1)}° ${formatNumber(hsl.s, 1)}% ${formatNumber(hsl.l, 1)}%`\n    case \"oklch\":\n      return `${formatNumber(oklch.l * 100, 1)}% ${formatNumber(oklch.c, 3)} ${formatNumber(oklch.h, 1)}`\n  }\n}\n\nexport function formatCssColor(\n  format: ColorFormat,\n  parts: ColorParts,\n  options?: { includeAlpha?: boolean }\n): string {\n  const includeAlpha = (options?.includeAlpha ?? true) && parts.alpha < 1\n  const alphaNumber = formatNumber(clamp(parts.alpha, 0, 1), 3)\n\n  switch (format) {\n    case \"hex\":\n      return rgbaToHex(parts.rgb, includeAlpha ? parts.alpha : 1)\n    case \"rgb\": {\n      const channels = `${Math.round(clamp(parts.rgb.r, 0, 255))}, ${Math.round(\n        clamp(parts.rgb.g, 0, 255)\n      )}, ${Math.round(clamp(parts.rgb.b, 0, 255))}`\n      return includeAlpha\n        ? `rgba(${channels}, ${alphaNumber})`\n        : `rgb(${channels})`\n    }\n    case \"hsl\": {\n      const channels = `${formatNumber(parts.hsl.h, 2)}, ${formatNumber(parts.hsl.s, 2)}%, ${formatNumber(\n        parts.hsl.l,\n        2\n      )}%`\n      return includeAlpha\n        ? `hsla(${channels}, ${alphaNumber})`\n        : `hsl(${channels})`\n    }\n    case \"oklch\": {\n      const channels = `${formatNumber(parts.oklch.l * 100, 2)}% ${formatNumber(parts.oklch.c, 4)} ${formatNumber(\n        parts.oklch.h,\n        2\n      )}`\n      return includeAlpha\n        ? `oklch(${channels} / ${alphaNumber})`\n        : `oklch(${channels})`\n    }\n  }\n}\n\ninterface NumericToken {\n  value: number\n  percent: boolean\n}\n\nfunction parseNumericToken(token: string): NumericToken | null {\n  const match = /^([+-]?\\d*\\.?\\d+)(%|deg|°)?$/i.exec(token.trim())\n  if (!match) return null\n  const value = Number.parseFloat(match[1] ?? \"\")\n  if (!Number.isFinite(value)) return null\n  return { value, percent: match[2] === \"%\" }\n}\n\nfunction parseAlphaToken(token: string): number | null {\n  const parsed = parseNumericToken(token)\n  if (!parsed) return null\n  const value =\n    parsed.percent || parsed.value > 1 ? parsed.value / 100 : parsed.value\n  return clamp(value, 0, 1)\n}\n\nfunction rgbFromTokens(\n  tokens: NumericToken[],\n  allowUnitScale: boolean\n): RgbColor | null {\n  if (tokens.length !== 3) return null\n  const scaleUnit =\n    allowUnitScale &&\n    tokens.every(\n      (token) => !token.percent && token.value >= 0 && token.value <= 1\n    )\n  const channels = tokens.map((token) => {\n    if (token.percent) return (token.value / 100) * 255\n    return scaleUnit ? token.value * 255 : token.value\n  })\n\n  return clampRgb({\n    r: channels[0] ?? 0,\n    g: channels[1] ?? 0,\n    b: channels[2] ?? 0,\n  })\n}\n\nfunction hslFromTokens(tokens: NumericToken[]): RgbColor | null {\n  if (tokens.length !== 3) return null\n  return hslToRgb({\n    h: normalizeHue(tokens[0]?.value ?? 0),\n    s: clamp(tokens[1]?.value ?? 0, 0, 100),\n    l: clamp(tokens[2]?.value ?? 0, 0, 100),\n  })\n}\n\nfunction oklchFromTokens(tokens: NumericToken[]): RgbColor | null {\n  if (tokens.length !== 3) return null\n  const [lToken, cToken, hToken] = tokens\n  if (!lToken || !cToken || !hToken) return null\n  const l =\n    lToken.percent || lToken.value > 1 ? lToken.value / 100 : lToken.value\n  const c = cToken.percent ? (cToken.value / 100) * 0.4 : cToken.value\n\n  return oklchToRgb({\n    l: clamp(l, 0, 1),\n    c: Math.max(0, c),\n    h: normalizeHue(hToken.value),\n  })\n}\n\nfunction splitAlpha(\n  body: string\n): { main: string; alpha: number | null } | null {\n  const slashParts = body.split(\"/\")\n  if (slashParts.length > 2) return null\n  if (slashParts.length === 2) {\n    const alpha = parseAlphaToken(slashParts[1] ?? \"\")\n    if (alpha === null) return null\n    return { main: slashParts[0] ?? \"\", alpha }\n  }\n  return { main: body, alpha: null }\n}\n\nfunction tokenize(main: string): NumericToken[] | null {\n  const rawTokens = main\n    .split(/[\\s,]+/)\n    .map((token) => token.trim())\n    .filter(Boolean)\n  const tokens: NumericToken[] = []\n  for (const raw of rawTokens) {\n    const parsed = parseNumericToken(raw)\n    if (!parsed) return null\n    tokens.push(parsed)\n  }\n  return tokens\n}\n\nfunction parseHexBody(raw: string): ParsedColor | null {\n  const body = raw.startsWith(\"#\") ? raw.slice(1) : raw\n  if (!/^[0-9a-f]+$/i.test(body)) return null\n\n  let expanded: string\n  if (body.length === 3 || body.length === 4) {\n    expanded = body\n      .split(\"\")\n      .map((char) => char + char)\n      .join(\"\")\n  } else if (body.length === 6 || body.length === 8) {\n    expanded = body\n  } else {\n    return null\n  }\n\n  const channels: number[] = []\n  for (let i = 0; i < expanded.length; i += 2) {\n    channels.push(Number.parseInt(expanded.slice(i, i + 2), 16))\n  }\n\n  return {\n    rgb: { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 },\n    alpha: channels.length === 4 ? (channels[3] ?? 255) / 255 : null,\n    format: \"hex\",\n  }\n}\n\nfunction parseFunctional(name: string, body: string): ParsedColor | null {\n  const withAlpha = splitAlpha(body)\n  if (!withAlpha) return null\n  const tokens = tokenize(withAlpha.main)\n  if (!tokens) return null\n\n  let alpha = withAlpha.alpha\n  let componentTokens = tokens\n  if (tokens.length === 4 && alpha === null) {\n    componentTokens = tokens.slice(0, 3)\n    const last = tokens[3]\n    if (!last) return null\n    alpha = clamp(last.percent ? last.value / 100 : last.value, 0, 1)\n  }\n\n  let rgb: RgbColor | null = null\n  let format: ColorFormat | null = null\n  if (name === \"rgb\" || name === \"rgba\") {\n    rgb = rgbFromTokens(componentTokens, false)\n    format = \"rgb\"\n  } else if (name === \"hsl\" || name === \"hsla\") {\n    rgb = hslFromTokens(componentTokens)\n    format = \"hsl\"\n  } else if (name === \"oklch\") {\n    rgb = oklchFromTokens(componentTokens)\n    format = \"oklch\"\n  }\n\n  return rgb ? { rgb, alpha, format } : null\n}\n\nlet cssParserContext: CanvasRenderingContext2D | null | undefined\n\nfunction parseWithBrowser(raw: string): ParsedColor | null {\n  if (typeof document === \"undefined\") return null\n  if (cssParserContext === undefined) {\n    try {\n      cssParserContext = document.createElement(\"canvas\").getContext(\"2d\")\n    } catch {\n      cssParserContext = null\n    }\n  }\n  if (!cssParserContext) return null\n\n  const sentinels = [\"#010203\", \"#030201\"]\n  let normalized: string | null = null\n  for (const sentinel of sentinels) {\n    cssParserContext.fillStyle = sentinel\n    cssParserContext.fillStyle = raw\n    const result = cssParserContext.fillStyle\n    if (typeof result !== \"string\" || result === sentinel) return null\n    normalized = result\n  }\n  if (!normalized) return null\n\n  // Browser fallback should not change the selected format.\n  if (normalized.startsWith(\"#\")) {\n    const hex = parseHexBody(normalized)\n    return hex ? { ...hex, format: null } : null\n  }\n  const functional = /^(rgba?)\\(\\s*(.+?)\\s*\\)$/i.exec(normalized)\n  if (functional) {\n    const parsed = parseFunctional(\n      (functional[1] ?? \"\").toLowerCase(),\n      functional[2] ?? \"\"\n    )\n    return parsed ? { ...parsed, format: null } : null\n  }\n  return null\n}\n\n/** Parses supported color syntax, then falls back to the browser parser. */\nexport function parseColorInput(\n  input: string,\n  assumeFormat: ColorFormat = \"hex\"\n): ParsedColor | null {\n  const raw = input.trim()\n  if (!raw) return null\n\n  const functional = /^([a-z]+)\\(\\s*(.+?)\\s*\\)$/i.exec(raw)\n  if (functional) {\n    const name = (functional[1] ?? \"\").toLowerCase()\n    if ([\"rgb\", \"rgba\", \"hsl\", \"hsla\", \"oklch\"].includes(name)) {\n      return parseFunctional(name, functional[2] ?? \"\")\n    }\n    return parseWithBrowser(raw)\n  }\n\n  const withAlpha = splitAlpha(raw)\n  if (withAlpha) {\n    const main = withAlpha.main.trim()\n\n    if (/^#?[0-9a-f]{3,8}$/i.test(main)) {\n      const hex = parseHexBody(main)\n      if (hex)\n        return {\n          rgb: hex.rgb,\n          alpha: withAlpha.alpha ?? hex.alpha,\n          format: \"hex\",\n        }\n    }\n\n    const tokens = tokenize(main)\n    if (tokens && tokens.length === 3) {\n      let rgb: RgbColor | null = null\n      if (assumeFormat === \"hsl\") rgb = hslFromTokens(tokens)\n      else if (assumeFormat === \"oklch\") rgb = oklchFromTokens(tokens)\n      else rgb = rgbFromTokens(tokens, true)\n      if (rgb) return { rgb, alpha: withAlpha.alpha, format: null }\n    }\n  }\n\n  return parseWithBrowser(raw)\n}\n\nexport function rgbTupleToHex(tuple: [number, number, number]): string {\n  return rgbaToHex({ r: tuple[0], g: tuple[1], b: tuple[2] })\n}\n\nexport function hexToRgbTuple(value: string): [number, number, number] | null {\n  const parsed = parseColorInput(value, \"hex\")\n  if (!parsed) return null\n  return [\n    Math.round(parsed.rgb.r),\n    Math.round(parsed.rgb.g),\n    Math.round(parsed.rgb.b),\n  ]\n}\n",
      "type": "registry:lib",
      "target": "@lib/color-picker-utils.ts"
    }
  ],
  "type": "registry:ui"
}