{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "author": "@fma-ds/ds",
  "description": "A composable color picker: `ColorPicker` holds the color and shares it with parts laid out in any way.\n\nWithout children, `ColorPicker` is a swatch that opens the default panel in a popover.\n\n### Parts\n\n- **`ColorPickerTrigger`** + **`ColorPickerContent`**: a popover.\n- **`ColorPickerPanel`**: a column of controls, or a standalone inline picker.\n- **`ColorPickerArea`**: OKLCH chroma × lightness at the hue (the part sRGB can't show is left blank), or HSB saturation × brightness.\n- **`ColorPickerWheel`**: a hue ring whose children fill the square inside it.\n- **`ColorPickerSlider`**: any channel of OKLCH, HSL, HSB or RGB, or alpha; horizontal or vertical, painted with the colors it reaches.\n- **`ColorPickerFields`**: a format menu with hex or per-channel fields; `ColorPickerChannelInput` for a single channel.\n- **`ColorPickerInput`**: a text field taking any CSS color (Escape reverts), with a swatch that opens the popover against it.\n- **`ColorPickerSwatches`**: presets, Tailwind's palette by default; `ColorPickerSwatch` for one.\n- **Extras**: `ColorPickerValue`, `ColorPickerCopy`, `ColorPickerEyeDropper`, `ColorPickerPreview` and `ColorPickerFixContrast`.\n- **`useColorPicker`**: reads and sets the color from custom parts.\n\n### Value\n\n- **Events**: `onChange` fires while dragging and `onChangeEnd` once an interaction ends.\n- **`format`**: what they emit — `oklch` by default, `hex`, `rgb` or `hsl`; `alpha` adds opacity controls.\n- **Forms**: `name` submits the value with a form (a hidden input), and a form reset restores the starting color.\n\n### Contrast\n\nGiven a background (`contrastWith`), every part shows contrast: the area hatches what fails AA and draws the AA and AAA thresholds across it, tracks hatch and mark them, presets failing AA are hatched, the preview shows the WCAG ratio, and \"Fix to AA/AAA\" jumps to the nearest passing lightness keeping the hue.\n\n`labels` translates every text it shows or announces.",
  "dependencies": [
    "cn@^0.5.0",
    "culori@^4.0.2",
    "lucide-react@^1.61.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/button",
    "@fma-ds/copy-button",
    "@fma-ds/input",
    "@fma-ds/input-group",
    "@fma-ds/popover",
    "@fma-ds/select",
    "@fma-ds/color-contrast"
  ],
  "files": [
    {
      "path": "src/core/color-picker/color-picker.tsx",
      "content": "import { cn } from \"cn\";\nimport {\n  type Color,\n  converter,\n  displayable,\n  formatCss,\n  formatHex,\n  formatHex8,\n  formatHsl,\n  formatRgb,\n  parse,\n} from \"culori\";\nimport { ContrastIcon, PipetteIcon } from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useEffectEvent,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport {\n  Dialog,\n  Label,\n  ListBox,\n  ListBoxItem,\n  NumberField,\n  Button as RACButton,\n  type ButtonProps as RACButtonProps,\n  Slider,\n  SliderOutput,\n  SliderThumb,\n  SliderTrack,\n} from \"react-aria-components\";\nimport { Button, buttonVariants } from \"@/core/button/button\";\nimport { CopyButton } from \"@/core/copy-button/copy-button\";\nimport { Input } from \"@/core/input/input\";\nimport { InputGroup, InputGroupAddon, InputGroupInput } from \"@/core/input-group/input-group\";\nimport { Popover } from \"@/core/popover/popover\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/core/select/select\";\nimport {\n  CONTRAST_AA,\n  CONTRAST_AAA,\n  type ColorChannel,\n  type ColorSpace,\n  channelDomain,\n  contrastLevel,\n  contrastRatio,\n  formatOklchChannels,\n  inGamut,\n  nearestContrastColor,\n  type PassingColumn,\n  passingRanges,\n  passingRegion,\n} from \"@/lib/color-contrast\";\n\n/* -------------------------------------------------------------------------- */\n/*                                 Color state                                */\n/* -------------------------------------------------------------------------- */\n\n/** culori's name for each space (HSB is its `hsv`). */\nconst MODES = { oklch: \"oklch\", hsl: \"hsl\", hsb: \"hsv\", rgb: \"rgb\" } as const;\nconst SPACES = Object.keys(MODES) as ColorSpace[];\nconst SPACE_KEYS: Record<ColorSpace, readonly ColorChannel[]> = {\n  oklch: [\"l\", \"c\", \"h\"],\n  hsl: [\"h\", \"s\", \"l\"],\n  hsb: [\"h\", \"s\", \"v\"],\n  rgb: [\"r\", \"g\", \"b\"],\n};\n\ntype Channels = Partial<Record<ColorChannel, number>>;\n\n/**\n * The picked color, kept in the space it was last set or edited in, so a gray\n * keeps its hue and an OKLCH chroma beyond sRGB survives until it's emitted.\n * Channels use culori's units (see channelDomain).\n */\ninterface ColorState {\n  space: ColorSpace;\n  channels: Channels;\n  alpha: number;\n}\n\nconst BLACK: ColorState = { space: \"oklch\", channels: { l: 0, c: 0, h: 0 }, alpha: 1 };\n\nconst finite = (n: number | undefined) => (n === undefined || Number.isNaN(n) ? undefined : n);\n\nfunction toCulori({ space, channels, alpha }: ColorState): Color {\n  return { mode: MODES[space], ...channels, alpha } as Color;\n}\n\n/** The state's hue carried into `space`, read off a vivid color with that hue. */\nfunction hueIn(state: ColorState, space: ColorSpace): number {\n  const h = state.channels.h;\n  if (h === undefined) return 0;\n  const vivid: Color =\n    state.space === \"oklch\"\n      ? { mode: \"oklch\", l: 0.7, c: 0.15, h }\n      : { mode: \"hsv\", h, s: 1, v: 1 };\n  const converted: Channels = converter(MODES[space])(vivid);\n  return finite(converted.h) ?? 0;\n}\n\n/** The state's channels in `space`; a gray takes the hue it had before. */\nfunction channelsIn(state: ColorState, space: ColorSpace): Channels {\n  if (state.space === space) return state.channels;\n  const converted: Channels = converter(MODES[space])(toCulori(state));\n  const channels: Channels = {};\n  for (const key of SPACE_KEYS[space]) {\n    channels[key] = finite(converted[key]) ?? (key === \"h\" ? hueIn(state, space) : 0);\n  }\n  return channels;\n}\n\n/** A CSS color as state, in its own space when the picker has it (else OKLCH). */\nfunction parseState(value: string, previous?: ColorState): ColorState | undefined {\n  const color = parse(value.trim());\n  if (!color) return undefined;\n  const space = SPACES.find((s) => MODES[s] === color.mode) ?? \"oklch\";\n  const converted: Channels = converter(MODES[space])(color);\n  const channels: Channels = {};\n  for (const key of SPACE_KEYS[space]) {\n    const fallback = key === \"h\" && previous ? channelsIn(previous, space).h : undefined;\n    channels[key] = finite(converted[key]) ?? fallback ?? 0;\n  }\n  return { space, channels, alpha: color.alpha ?? 1 };\n}\n\n/** The formats a picker emits and its fields edit. */\ntype ColorPickerFormat = \"oklch\" | \"hex\" | \"rgb\" | \"hsl\";\n\n/** The state as CSS in `format`, clamped to sRGB (OKLCH by chroma), alpha kept. */\nfunction formatState(state: ColorState, format: ColorPickerFormat = \"oklch\"): string {\n  const { l = 0, c = 0, h = 0 } = channelsIn(state, \"oklch\");\n  const channels = { l, c, h, alpha: state.alpha };\n  if (format === \"oklch\") return formatOklchChannels(channels);\n  const color: Color = { mode: \"oklch\", ...inGamut(channels) };\n  if (format === \"hex\") return state.alpha < 1 ? formatHex8(color) : formatHex(color);\n  return format === \"rgb\" ? formatRgb(color) : formatHsl(color);\n}\n\n/** The state with one channel set in `space` (or alpha, without `key`). */\nfunction withChannel(\n  state: ColorState,\n  space: ColorSpace,\n  key: ColorChannel | undefined,\n  value: number,\n): ColorState {\n  if (!key) return { ...state, alpha: value };\n  return { space, channels: { ...channelsIn(state, space), [key]: value }, alpha: state.alpha };\n}\n\n/** Opaque sRGB hex of the state (OKLCH chroma clamped), for painting tracks and swatches. */\nfunction paint(state: ColorState): string {\n  if (state.space !== \"oklch\") return formatHex({ ...toCulori(state), alpha: 1 });\n  const { l = 0, c = 0, h = 0 } = state.channels;\n  return formatHex({ mode: \"oklch\", ...inGamut({ l, c, h, alpha: 1 }) });\n}\n\n/** The transparency grid behind translucent colors. */\nconst CHECKER =\n  \"repeating-conic-gradient(var(--muted) 0 25%, var(--background) 0 50%) 50% / 8px 8px\";\n\n/** A CSS background showing `color` over the checker when it is translucent. */\nconst swatchBackground = (color: string) => `linear-gradient(${color}, ${color}), ${CHECKER}`;\n\nconst HATCH =\n  \"bg-[repeating-linear-gradient(135deg,rgb(0_0_0/0.35)_0_2px,rgb(255_255_255/0.35)_2px_4px)]\";\n\n/** A preset color: a CSS color, or one with a name used as its accessible label. */\ntype Swatch = string | { color: string; name: string };\n\nconst TAILWIND_HUES = [\n  \"neutral\",\n  \"red\",\n  \"orange\",\n  \"amber\",\n  \"yellow\",\n  \"green\",\n  \"teal\",\n  \"cyan\",\n  \"blue\",\n  \"indigo\",\n  \"violet\",\n  \"pink\",\n];\nconst TAILWIND_SHADES: Array<[number, string[]]> = [\n  [\n    200,\n    [\n      \"0.922 0 0\",\n      \"0.885 0.062 18.334\",\n      \"0.901 0.076 70.697\",\n      \"0.924 0.12 95.746\",\n      \"0.945 0.129 101.54\",\n      \"0.925 0.084 155.995\",\n      \"0.91 0.096 180.426\",\n      \"0.917 0.08 205.041\",\n      \"0.882 0.059 254.128\",\n      \"0.87 0.065 274.039\",\n      \"0.894 0.057 293.283\",\n      \"0.899 0.061 343.231\",\n    ],\n  ],\n  [\n    400,\n    [\n      \"0.708 0 0\",\n      \"0.704 0.191 22.216\",\n      \"0.75 0.183 55.934\",\n      \"0.828 0.189 84.429\",\n      \"0.852 0.199 91.936\",\n      \"0.792 0.209 151.711\",\n      \"0.777 0.152 181.912\",\n      \"0.789 0.154 211.53\",\n      \"0.707 0.165 254.624\",\n      \"0.673 0.182 276.935\",\n      \"0.702 0.183 293.541\",\n      \"0.718 0.202 349.761\",\n    ],\n  ],\n  [\n    500,\n    [\n      \"0.556 0 0\",\n      \"0.637 0.237 25.331\",\n      \"0.705 0.213 47.604\",\n      \"0.769 0.188 70.08\",\n      \"0.795 0.184 86.047\",\n      \"0.723 0.219 149.579\",\n      \"0.704 0.14 182.503\",\n      \"0.715 0.143 215.221\",\n      \"0.623 0.214 259.815\",\n      \"0.585 0.233 277.117\",\n      \"0.606 0.25 292.717\",\n      \"0.656 0.241 354.308\",\n    ],\n  ],\n  [\n    600,\n    [\n      \"0.439 0 0\",\n      \"0.577 0.245 27.325\",\n      \"0.646 0.222 41.116\",\n      \"0.666 0.179 58.318\",\n      \"0.681 0.162 75.834\",\n      \"0.627 0.194 149.214\",\n      \"0.6 0.118 184.704\",\n      \"0.609 0.126 221.723\",\n      \"0.546 0.245 262.881\",\n      \"0.511 0.262 276.966\",\n      \"0.541 0.281 293.009\",\n      \"0.592 0.249 0.584\",\n    ],\n  ],\n  [\n    800,\n    [\n      \"0.269 0 0\",\n      \"0.444 0.177 26.899\",\n      \"0.47 0.157 37.304\",\n      \"0.473 0.137 46.201\",\n      \"0.476 0.114 61.907\",\n      \"0.448 0.119 151.328\",\n      \"0.437 0.078 188.216\",\n      \"0.45 0.085 224.283\",\n      \"0.424 0.199 265.638\",\n      \"0.398 0.195 277.366\",\n      \"0.432 0.232 292.759\",\n      \"0.459 0.187 3.815\",\n    ],\n  ],\n];\n\n/** Tailwind v4's palette (MIT): 12 hues × shades 200/400/500/600/800, a row per shade. */\nconst DEFAULT_SWATCHES: Swatch[] = TAILWIND_SHADES.flatMap(([shade, values]) =>\n  values.map((value, i) => ({ color: `oklch(${value})`, name: `${TAILWIND_HUES[i]}-${shade}` })),\n);\n\nconst swatchColor = (swatch: Swatch) => (typeof swatch === \"string\" ? swatch : swatch.color);\nconst swatchName = (swatch: Swatch) => (typeof swatch === \"string\" ? swatch : swatch.name);\n\n/* -------------------------------------------------------------------------- */\n/*                                   Context                                  */\n/* -------------------------------------------------------------------------- */\n\n/** Every text the picker shows or announces, so it can be translated. */\ninterface ColorPickerLabels {\n  lightness: string;\n  chroma: string;\n  hue: string;\n  saturation: string;\n  brightness: string;\n  red: string;\n  green: string;\n  blue: string;\n  alpha: string;\n  /** The OKLCH area (chroma × lightness). */\n  oklchArea: string;\n  /** The HSB area (saturation × brightness). */\n  hsbArea: string;\n  swatches: string;\n  format: string;\n  hex: string;\n  copy: string;\n  copied: string;\n  copyFailed: string;\n  eyeDropper: string;\n  /** The contrast fix buttons. */\n  fix: (level: \"AA\" | \"AAA\") => string;\n  /** The level for 3:1, enough for large text only. */\n  largeText: string;\n  fail: string;\n}\n\nconst DEFAULT_COLOR_PICKER_LABELS: ColorPickerLabels = {\n  lightness: \"Lightness\",\n  chroma: \"Chroma\",\n  hue: \"Hue\",\n  saturation: \"Saturation\",\n  brightness: \"Brightness\",\n  red: \"Red\",\n  green: \"Green\",\n  blue: \"Blue\",\n  alpha: \"Alpha\",\n  oklchArea: \"Chroma and lightness\",\n  hsbArea: \"Saturation and brightness\",\n  swatches: \"Preset colors\",\n  format: \"Color format\",\n  hex: \"Hex\",\n  copy: \"Copy color\",\n  copied: \"Copied\",\n  copyFailed: \"Couldn't copy\",\n  eyeDropper: \"Pick a color from the screen\",\n  fix: (level) => `Fix to ${level}`,\n  largeText: \"AA large\",\n  fail: \"Fail\",\n};\n\ninterface ColorPickerContextValue {\n  state: ColorState;\n  /** The current value as emitted. */\n  value: string;\n  /** Sets the color; `end` also fires onChangeEnd (a discrete change: a click, a key, a commit). */\n  update: (next: ColorState, end?: boolean) => void;\n  /** Fires onChangeEnd with the latest value (a drag was released). */\n  end: () => void;\n  format: ColorPickerFormat;\n  alpha: boolean;\n  contrastWith?: string;\n  swatches?: Swatch[];\n  swatchColumns?: number;\n  label: string;\n  labels: ColorPickerLabels;\n  isDisabled: boolean;\n  isInvalid: boolean;\n  isOpen: boolean;\n  setOpen: (open: boolean) => void;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  /** What the popover is placed against: the anchor (a ColorPickerInput) or else the trigger. */\n  anchorRef: RefObject<HTMLDivElement | null>;\n  popoverRef: RefObject<Element | null>;\n}\n\nconst ColorPickerContext = createContext<ColorPickerContextValue | null>(null);\n\nfunction usePicker(part: string): ColorPickerContextValue {\n  const picker = useContext(ColorPickerContext);\n  if (!picker) throw new Error(`${part} must be used inside a ColorPicker or ColorPickerPanel.`);\n  return picker;\n}\n\ntype ColorPickerProviderProps = {\n  /** Any CSS color (controlled). */\n  value?: string;\n  /** Initial color when uncontrolled. */\n  defaultValue?: string;\n  /** Called with the color in `format`, clamped to sRGB (OKLCH by chroma); alpha is kept. */\n  onChange?: (value: string) => void;\n  /**\n   * Called once an interaction ends: a drag released, a key pressed, a preset\n   * picked, a field committed. Use it for history or saving, not live preview.\n   */\n  onChangeEnd?: (value: string) => void;\n  /** The format of emitted values. Defaults to `oklch(…)`. */\n  format?: ColorPickerFormat;\n  /** Adds the alpha slider and field to the default panel (alpha is always kept). */\n  alpha?: boolean;\n  /**\n   * The background this color sits on. Every part shows contrast against it:\n   * tracks hatch what fails AA and mark the AA/AAA thresholds, presets failing\n   * AA are hatched, and ColorPickerPreview / ColorPickerFixContrast show the\n   * ratio and move to the nearest passing lightness.\n   */\n  contrastWith?: string;\n  /**\n   * Preset colors for ColorPickerSwatches (Tailwind's palette by default; `[]`\n   * hides them in the default panel). With `contrastWith`, the ones failing AA\n   * are hatched.\n   */\n  swatches?: Swatch[];\n  /** Columns of the swatch grid. */\n  swatchColumns?: number;\n  /** Accessible name of the trigger and the popover dialog. */\n  label?: string;\n  /** Overrides any of the picker's texts (see DEFAULT_COLOR_PICKER_LABELS), e.g. to translate them. */\n  labels?: Partial<ColorPickerLabels>;\n  isDisabled?: boolean;\n  /** Marks the picker invalid (the input and trigger show it). */\n  isInvalid?: boolean;\n  /** Submits the value with a form under this name (a hidden input); a form reset restores defaultValue. */\n  name?: string;\n  /** The id of the form to submit with, when the picker is outside it. */\n  form?: string;\n  /** Whether the popover is open (controlled). */\n  isOpen?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (isOpen: boolean) => void;\n};\n\nfunction ColorPickerProvider({\n  value,\n  defaultValue,\n  onChange,\n  onChangeEnd,\n  format = \"oklch\",\n  alpha = false,\n  contrastWith,\n  swatches,\n  swatchColumns,\n  label = \"Pick color\",\n  labels,\n  isDisabled = false,\n  isInvalid = false,\n  name,\n  form,\n  isOpen,\n  defaultOpen = false,\n  onOpenChange,\n  children,\n}: ColorPickerProviderProps & { children: ReactNode }) {\n  const [state, setState] = useState(() => parseState(value ?? defaultValue ?? \"\") ?? BLACK);\n  const emitted = useRef<string | undefined>(undefined);\n  // A new value from outside replaces the state, unless it is the same color\n  // (so a gray keeps its hue when the parent echoes the emitted value back).\n  useEffect(() => {\n    if (value === undefined || value === emitted.current) return;\n    setState((current) => {\n      const next = parseState(value, current);\n      return !next || formatState(next) === formatState(current) ? current : next;\n    });\n  }, [value]);\n\n  const update = useCallback(\n    (next: ColorState, end = false) => {\n      setState(next);\n      const css = formatState(next, format);\n      emitted.current = css;\n      onChange?.(css);\n      if (end) onChangeEnd?.(css);\n    },\n    [onChange, onChangeEnd, format],\n  );\n  const end = useCallback(() => {\n    if (emitted.current !== undefined) onChangeEnd?.(emitted.current);\n  }, [onChangeEnd]);\n\n  // A form reset goes back to the color the picker started with.\n  const hiddenRef = useRef<HTMLInputElement>(null);\n  const initial = useRef(defaultValue ?? value ?? \"\");\n  const reset = useEffectEvent(() => {\n    const next = parseState(initial.current);\n    if (next) update(next, true);\n  });\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `form` moves the hidden input to another form, whose reset to follow.\n  useEffect(() => {\n    const owner = hiddenRef.current?.form;\n    if (!owner || name === undefined) return;\n    const onReset = () => reset();\n    owner.addEventListener(\"reset\", onReset);\n    return () => owner.removeEventListener(\"reset\", onReset);\n  }, [name, form]);\n\n  const mergedLabels = useMemo(() => ({ ...DEFAULT_COLOR_PICKER_LABELS, ...labels }), [labels]);\n\n  const [openState, setOpenState] = useState(defaultOpen);\n  const setOpen = useCallback(\n    (open: boolean) => {\n      setOpenState(open);\n      onOpenChange?.(open);\n    },\n    [onOpenChange],\n  );\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const anchorRef = useRef<HTMLDivElement>(null);\n  // A ref the popover reads when it opens: the anchor if there is one.\n  const popoverRef = useMemo<RefObject<Element | null>>(\n    () => ({\n      get current() {\n        return anchorRef.current ?? triggerRef.current;\n      },\n    }),\n    [],\n  );\n\n  const context = useMemo<ColorPickerContextValue>(\n    () => ({\n      state,\n      value: formatState(state, format),\n      update,\n      end,\n      format,\n      alpha,\n      contrastWith,\n      swatches,\n      swatchColumns,\n      label,\n      labels: mergedLabels,\n      isDisabled,\n      isInvalid,\n      isOpen: isOpen ?? openState,\n      setOpen,\n      triggerRef,\n      anchorRef,\n      popoverRef,\n    }),\n    [\n      state,\n      format,\n      update,\n      end,\n      alpha,\n      popoverRef,\n      contrastWith,\n      swatches,\n      swatchColumns,\n      label,\n      mergedLabels,\n      isDisabled,\n      isInvalid,\n      isOpen,\n      openState,\n      setOpen,\n    ],\n  );\n  return (\n    <ColorPickerContext.Provider value={context}>\n      {children}\n      {name !== undefined && (\n        <input ref={hiddenRef} type=\"hidden\" name={name} form={form} value={context.value} />\n      )}\n    </ColorPickerContext.Provider>\n  );\n}\n\n/** State and actions of the surrounding ColorPicker, for custom parts. */\nfunction useColorPicker() {\n  const picker = usePicker(\"useColorPicker\");\n  return {\n    /** The current value, as emitted. */\n    value: picker.value,\n    /** Sets the color from any CSS color, keeping alpha unless it has its own. Returns false if unparseable. */\n    setValue: (css: string) => {\n      const next = parseState(css, picker.state);\n      if (next) picker.update(next, true);\n      return next !== undefined;\n    },\n    contrastWith: picker.contrastWith,\n    isDisabled: picker.isDisabled,\n    isOpen: picker.isOpen,\n    setOpen: picker.setOpen,\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Root parts                                 */\n/* -------------------------------------------------------------------------- */\n\ntype ColorPickerProps = ColorPickerProviderProps & {\n  /** Classes of the default trigger (when there are no children). */\n  className?: string;\n  /**\n   * The parts to render (ColorPickerTrigger, ColorPickerContent, sliders…), in\n   * any layout. Without children: a swatch button that opens the default panel.\n   */\n  children?: ReactNode;\n};\n\n/**\n * Holds the color and shares it with its parts; renders no element of its own.\n * Without children it is a swatch button opening ColorPickerPanel in a popover.\n */\nfunction ColorPicker({ className, children, ...props }: ColorPickerProps) {\n  return (\n    <ColorPickerProvider {...props}>\n      {children ?? (\n        <>\n          <ColorPickerTrigger className={className} />\n          <ColorPickerContent />\n        </>\n      )}\n    </ColorPickerProvider>\n  );\n}\n\ntype ColorPickerTriggerProps = Omit<RACButtonProps, \"children\" | \"className\"> & {\n  className?: string;\n  /** Custom content (an icon, the value…); styled as an outline button. Default: a swatch. */\n  children?: ReactNode;\n};\n\n/** The button that opens ColorPickerContent: a swatch of the color unless given children. */\nfunction ColorPickerTrigger({ className, children, ...props }: ColorPickerTriggerProps) {\n  const picker = usePicker(\"ColorPickerTrigger\");\n  return (\n    <RACButton\n      ref={picker.triggerRef}\n      data-slot=\"color-picker-trigger\"\n      aria-label={children ? undefined : picker.label}\n      aria-haspopup=\"dialog\"\n      aria-expanded={picker.isOpen}\n      data-invalid={picker.isInvalid || undefined}\n      isDisabled={picker.isDisabled}\n      onPress={() => picker.setOpen(!picker.isOpen)}\n      className={cn(\n        children\n          ? buttonVariants({ variant: \"outline\" })\n          : \"size-8 shrink-0 cursor-pointer rounded-md ring-1 ring-foreground/15 outline-none data-disabled:cursor-not-allowed data-disabled:opacity-50 data-focus-visible:ring-3 data-focus-visible:ring-ring/50 data-invalid:ring-2 data-invalid:ring-destructive\",\n        className,\n      )}\n      style={children ? undefined : { background: swatchBackground(picker.value) }}\n      {...props}\n    >\n      {children}\n    </RACButton>\n  );\n}\n\ntype ColorPickerContentProps = Omit<\n  ComponentProps<typeof Popover>,\n  \"children\" | \"isOpen\" | \"onOpenChange\" | \"triggerRef\"\n> & {\n  /** The popover's controls. Default: ColorPickerPanel. */\n  children?: ReactNode;\n};\n\n/** The popover opened by ColorPickerTrigger, holding a dialog with the controls. */\nfunction ColorPickerContent({\n  className,\n  children,\n  placement = \"bottom start\",\n  ...props\n}: ColorPickerContentProps) {\n  const picker = usePicker(\"ColorPickerContent\");\n  return (\n    <Popover\n      data-slot=\"color-picker-content\"\n      triggerRef={picker.popoverRef}\n      isOpen={picker.isOpen}\n      onOpenChange={picker.setOpen}\n      placement={placement}\n      className={cn(\"w-72 overflow-y-auto\", className)}\n      {...props}\n    >\n      <Dialog aria-label={picker.label} className=\"outline-none\">\n        {children ?? <ColorPickerPanel />}\n      </Dialog>\n    </Popover>\n  );\n}\n\ntype ColorPickerPanelProps = ColorPickerProviderProps & {\n  className?: string;\n  /**\n   * The controls, laid out in a column. Default: preview, OKLCH area, eye\n   * dropper, hue (and alpha) sliders, format fields, presets and contrast fixes.\n   */\n  children?: ReactNode;\n};\n\n/**\n * The controls laid out in a column. Inside a ColorPicker it is just the\n * layout; on its own it holds the color itself (value, onChange…) and renders\n * inline.\n */\nfunction ColorPickerPanel({ className, children, ...props }: ColorPickerPanelProps) {\n  const inPicker = useContext(ColorPickerContext) !== null;\n  const panel = (\n    <div data-slot=\"color-picker-panel\" className={cn(\"flex flex-col gap-3\", className)}>\n      {children ?? <DefaultControls />}\n    </div>\n  );\n  return inPicker ? panel : <ColorPickerProvider {...props}>{panel}</ColorPickerProvider>;\n}\n\nfunction DefaultControls() {\n  const picker = usePicker(\"ColorPickerPanel\");\n  return (\n    <>\n      <ColorPickerPreview />\n      <ColorPickerArea />\n      <div className=\"flex items-center gap-2\">\n        <ColorPickerEyeDropper />\n        <ColorPickerSlider channel=\"hue\" className=\"flex-1\" />\n      </div>\n      {picker.alpha && <ColorPickerSlider channel=\"alpha\" />}\n      <ColorPickerFields />\n      <ColorPickerSwatches />\n      {picker.contrastWith && <ColorPickerFixContrast />}\n    </>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                   Sliders                                  */\n/* -------------------------------------------------------------------------- */\n\ntype ColorPickerChannel =\n  | \"lightness\"\n  | \"chroma\"\n  | \"hue\"\n  | \"saturation\"\n  | \"brightness\"\n  | \"red\"\n  | \"green\"\n  | \"blue\"\n  | \"alpha\";\n\n/** Each channel's culori key and the spaces that have it (the first is its default). */\nconst CHANNELS: Record<ColorPickerChannel, { key?: ColorChannel; spaces: ColorSpace[] }> = {\n  lightness: { key: \"l\", spaces: [\"oklch\", \"hsl\"] },\n  chroma: { key: \"c\", spaces: [\"oklch\"] },\n  hue: { key: \"h\", spaces: [\"oklch\", \"hsl\", \"hsb\"] },\n  saturation: { key: \"s\", spaces: [\"hsb\", \"hsl\"] },\n  brightness: { key: \"v\", spaces: [\"hsb\"] },\n  red: { key: \"r\", spaces: [\"rgb\"] },\n  green: { key: \"g\", spaces: [\"rgb\"] },\n  blue: { key: \"b\", spaces: [\"rgb\"] },\n  alpha: { spaces: [] },\n};\n\n/** How a channel shows: its range and step on screen, `scale` × culori's unit. */\nfunction channelScale(key: ColorChannel | undefined, space: ColorSpace) {\n  const percent = { style: \"percent\" } satisfies Intl.NumberFormatOptions;\n  if (!key) return { min: 0, max: 1, step: 0.01, scale: 1, format: percent };\n  if (key === \"h\") {\n    const format = { style: \"unit\", unit: \"degree\", unitDisplay: \"narrow\" } as const;\n    return { min: 0, max: 360, step: 1, scale: 1, format };\n  }\n  if (space === \"rgb\") return { min: 0, max: 255, step: 1, scale: 255, format: {} };\n  if (space === \"oklch\") {\n    const [min, max] = channelDomain(key);\n    const format = { minimumFractionDigits: 3, maximumFractionDigits: 3 };\n    return { min, max, step: 0.001, scale: 1, format };\n  }\n  return { min: 0, max: 1, step: 0.01, scale: 1, format: percent };\n}\n\nconst GRADIENT_STOPS = 24;\n\n/** The colors a track runs through; a hue track is vivid even for a gray. */\nfunction trackBackground(\n  state: ColorState,\n  space: ColorSpace,\n  key: ColorChannel | undefined,\n  vertical: boolean,\n): string {\n  const direction = vertical ? \"to top\" : \"to right\";\n  if (!key) return `linear-gradient(${direction}, transparent, ${paint(state)}), ${CHECKER}`;\n  let base = channelsIn(state, space);\n  if (key === \"h\") {\n    if (space === \"oklch\") base = { ...base, c: Math.max(base.c ?? 0, 0.12) };\n    else if (space === \"hsl\") base = { ...base, s: 1, l: 0.5 };\n    else base = { ...base, s: 1, v: 1 };\n  }\n  const [min, max] = channelDomain(key);\n  const stops = Array.from({ length: GRADIENT_STOPS + 1 }, (_, i) => {\n    const value = min + ((max - min) * i) / GRADIENT_STOPS;\n    return paint({ space, channels: { ...base, [key]: value }, alpha: 1 });\n  });\n  return `linear-gradient(${direction}, ${stops.join(\", \")})`;\n}\n\n/** Values where the contrast crosses the ratio: the inner edges of its passing ranges. */\nfunction boundaries(ranges: Array<[number, number]>, [min, max]: [number, number]): number[] {\n  return ranges.flat().filter((value) => value > min && value < max);\n}\n\n/** The ranges that don't pass: the complement of `passing` in [min, max]. */\nfunction complement(\n  passing: Array<[number, number]>,\n  [min, max]: [number, number],\n): Array<[number, number]> {\n  const gaps: Array<[number, number]> = [];\n  let cursor = min;\n  for (const [from, to] of passing) {\n    if (from > cursor) gaps.push([cursor, from]);\n    cursor = to;\n  }\n  if (cursor < max) gaps.push([cursor, max]);\n  return gaps;\n}\n\n/** Where moving `key` fails AA (hatched) and crosses AA/AAA (marked), in culori units. */\nfunction useTrackContrast(\n  state: ColorState,\n  space: ColorSpace,\n  key: ColorChannel | undefined,\n  background: string | undefined,\n) {\n  // The scanned channel is zeroed so dragging it doesn't rescan.\n  const css =\n    key && background\n      ? formatCss(\n          toCulori({ space, channels: { ...channelsIn(state, space), [key]: 0 }, alpha: 1 }),\n        )\n      : undefined;\n  return useMemo(() => {\n    if (!css || !key || !background) return undefined;\n    const domain = channelDomain(key);\n    const aa = passingRanges(css, background, CONTRAST_AA, { space, channel: key });\n    const aaa = passingRanges(css, background, CONTRAST_AAA, { space, channel: key });\n    return {\n      failing: complement(aa, domain),\n      marks: [...boundaries(aa, domain), ...boundaries(aaa, domain)],\n    };\n  }, [css, key, space, background]);\n}\n\ntype ColorPickerSliderProps = {\n  channel: ColorPickerChannel;\n  /**\n   * The space the channel belongs to. Defaults to OKLCH for lightness, chroma\n   * and hue, HSB for saturation and brightness, RGB for red, green and blue.\n   */\n  colorSpace?: ColorSpace;\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Shows the channel's name and value above a horizontal track. */\n  showLabel?: boolean;\n  \"aria-label\"?: string;\n  className?: string;\n};\n\n/**\n * One channel's track, painted with the colors it reaches from the current\n * one. With `contrastWith`, the part that fails AA is hatched and the AA/AAA\n * thresholds are marked.\n */\nfunction ColorPickerSlider({\n  channel,\n  colorSpace = \"oklch\",\n  orientation = \"horizontal\",\n  showLabel = false,\n  \"aria-label\": ariaLabel,\n  className,\n}: ColorPickerSliderProps) {\n  const picker = usePicker(\"ColorPickerSlider\");\n  const { key, spaces } = CHANNELS[channel];\n  const label = picker.labels[channel];\n  const space = spaces.includes(colorSpace) ? colorSpace : (spaces[0] ?? \"oklch\");\n  const { min, max, step, scale, format } = channelScale(key, space);\n  const channels = channelsIn(picker.state, space);\n  const value = key ? (channels[key] ?? 0) * scale : picker.state.alpha;\n  const contrast = useTrackContrast(picker.state, space, key, picker.contrastWith);\n  const vertical = orientation === \"vertical\";\n  const percent = (v: number) => `${((v * scale - min) / (max - min)) * 100}%`;\n\n  const set = (next: number) => picker.update(withChannel(picker.state, space, key, next / scale));\n\n  return (\n    <Slider\n      data-slot=\"color-picker-slider\"\n      data-channel={channel}\n      orientation={orientation}\n      isDisabled={picker.isDisabled}\n      className={cn(\n        \"flex gap-1.5 data-disabled:opacity-50\",\n        vertical ? \"h-40 flex-col items-center\" : \"flex-col\",\n        className,\n      )}\n      minValue={min}\n      maxValue={max}\n      step={step}\n      formatOptions={format}\n      value={value}\n      onChange={set}\n      onChangeEnd={picker.end}\n      aria-label={showLabel ? undefined : (ariaLabel ?? label)}\n    >\n      {showLabel && !vertical && (\n        <div className=\"flex items-center justify-between text-xs\">\n          <Label className=\"text-muted-foreground\">{ariaLabel ?? label}</Label>\n          <SliderOutput className=\"font-mono tabular-nums\" />\n        </div>\n      )}\n      <SliderTrack\n        className={cn(\n          \"relative rounded-md ring-1 ring-foreground/10 ring-inset\",\n          vertical ? \"w-4 flex-1\" : \"h-4 w-full\",\n        )}\n        style={{ background: trackBackground(picker.state, space, key, vertical) }}\n      >\n        {contrast?.failing.map(([from, to]) => (\n          <span\n            key={`${from}-${to}`}\n            aria-hidden\n            data-slot=\"color-picker-failing\"\n            className={cn(\n              \"pointer-events-none absolute\",\n              vertical ? \"inset-x-0\" : \"inset-y-0\",\n              HATCH,\n            )}\n            style={\n              vertical\n                ? { bottom: percent(from), height: `calc(${percent(to)} - ${percent(from)})` }\n                : { left: percent(from), width: `calc(${percent(to)} - ${percent(from)})` }\n            }\n          />\n        ))}\n        {contrast?.marks.map((mark) => (\n          <span\n            key={mark}\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute bg-foreground\",\n              vertical ? \"-inset-x-0.5 h-px translate-y-1/2\" : \"-inset-y-0.5 w-px -translate-x-1/2\",\n            )}\n            style={vertical ? { bottom: percent(mark) } : { left: percent(mark) }}\n          />\n        ))}\n        <SliderThumb\n          className={cn(\n            \"size-4 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4),0_1px_2px_rgb(0_0_0/0.3)] outline-none data-focus-visible:ring-3 data-focus-visible:ring-ring/50\",\n            vertical ? \"left-1/2\" : \"top-1/2\",\n          )}\n          style={{ background: swatchBackground(picker.value) }}\n        />\n      </SliderTrack>\n    </Slider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                    Area                                    */\n/* -------------------------------------------------------------------------- */\n\ntype AreaSpace = \"oklch\" | \"hsb\";\n\n/**\n * Follows a drag over the whole window from a pointer press until its release:\n * `onMove` gets each position (the press's too), `onEnd` the release.\n */\nfunction useWindowDrag(onMove: (clientX: number, clientY: number) => void, onEnd: () => void) {\n  const [dragging, setDragging] = useState(false);\n  const move = useEffectEvent(onMove);\n  const release = useEffectEvent(() => {\n    setDragging(false);\n    onEnd();\n  });\n  useEffect(() => {\n    if (!dragging) return;\n    const onPointerMove = (event: PointerEvent) => move(event.clientX, event.clientY);\n    const onPointerUp = () => release();\n    window.addEventListener(\"pointermove\", onPointerMove);\n    window.addEventListener(\"pointerup\", onPointerUp);\n    window.addEventListener(\"pointercancel\", onPointerUp);\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n      window.removeEventListener(\"pointercancel\", onPointerUp);\n    };\n  }, [dragging]);\n  const start = (event: ReactPointerEvent<HTMLElement>) => {\n    if (event.button !== 0) return;\n    event.preventDefault();\n    event.currentTarget.focus({ preventScroll: true });\n    setDragging(true);\n    onMove(event.clientX, event.clientY);\n  };\n  return { dragging, start };\n}\n\nconst AREA = {\n  oklch: { x: \"c\", y: \"l\", label: \"oklchArea\", xName: \"chroma\", yName: \"lightness\" },\n  hsb: { x: \"s\", y: \"v\", label: \"hsbArea\", xName: \"saturation\", yName: \"brightness\" },\n} as const;\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\nconst percent = (n: number) => `${n * 100}%`;\nconst toRgb = converter(\"rgb\");\n\n/** The most chroma sRGB reaches at hue `h`, and the lightness where it does. */\nfunction cusp(h: number): { c: number; l: number } {\n  let best = { c: 0, l: 0.5 };\n  for (let i = 1; i < 100; i++) {\n    const l = i / 100;\n    const { c } = inGamut({ l, c: 0.4, h, alpha: 1 });\n    if (c > best.c) best = { c, l };\n  }\n  return best;\n}\n\n/** The lightness interval where chroma `c` at hue `h` fits sRGB (it contains the cusp's). */\nfunction gamutSpan(c: number, h: number, cuspL: number): [number, number] | undefined {\n  const fits = (l: number) => displayable({ mode: \"oklch\", l, c, h });\n  if (!fits(cuspL)) return undefined;\n  const edge = (inside: number, outside: number) => {\n    for (let i = 0; i < 14; i++) {\n      const mid = (inside + outside) / 2;\n      if (fits(mid)) inside = mid;\n      else outside = mid;\n    }\n    return inside;\n  };\n  return [fits(0) ? 0 : edge(cuspL, 0), fits(1) ? 1 : edge(cuspL, 1)];\n}\n\n/** Size of the canvas the OKLCH area is painted on (scaled up smoothly). */\nconst AREA_WIDTH = 128;\nconst AREA_HEIGHT = 96;\n\n/**\n * Paints chroma × lightness at hue `h`. In a row, sRGB covers chroma from 0 up\n * to a limit; past it the edge color repeats, and the area's clip-path cuts it.\n */\nfunction paintOklchArea(canvas: HTMLCanvasElement, h: number, maxChroma: number) {\n  const context = canvas.getContext(\"2d\");\n  if (!context) return;\n  const image = context.createImageData(AREA_WIDTH, AREA_HEIGHT);\n  for (let row = 0; row < AREA_HEIGHT; row++) {\n    const l = 1 - (row + 0.5) / AREA_HEIGHT;\n    let edge = [0, 0, 0];\n    for (let column = 0; column < AREA_WIDTH; column++) {\n      const c = ((column + 0.5) / AREA_WIDTH) * maxChroma;\n      const { r, g, b } = toRgb({ mode: \"oklch\", l, c, h });\n      if (![r, g, b].some((v) => v < -1e-4 || v > 1 + 1e-4)) {\n        edge = [r, g, b].map((v) => Math.round(clamp01(v) * 255));\n      }\n      image.data.set([...edge, 255], (row * AREA_WIDTH + column) * 4);\n    }\n  }\n  context.putImageData(image, 0, 0);\n}\n\n/** Columns across the area its sRGB outline is traced at. */\nconst GAMUT_COLUMNS = 48;\n\n/** A CSS clip-path tracing where chroma × lightness at hue `h` fits sRGB. */\nfunction gamutClipPath(h: number, maxChroma: number, cuspL: number): string {\n  const spans = Array.from({ length: GAMUT_COLUMNS + 1 }, (_, i) => {\n    const x = i / GAMUT_COLUMNS;\n    return { x, span: gamutSpan(x * maxChroma * 0.999, h, cuspL) };\n  }).flatMap(({ x, span }) => (span ? [{ x, span }] : []));\n  const top = spans.map(({ x, span }) => `${percent(x)} ${percent(1 - span[1])}`);\n  const bottom = spans.reverse().map(({ x, span }) => `${percent(x)} ${percent(1 - span[0])}`);\n  return `polygon(${[...top, ...bottom].join(\", \")})`;\n}\n\n/** A path (0–1 box, y down) of where the region fails: trapezoids between columns. */\nfunction failingPath(region: PassingColumn[]): string {\n  const failing = region.map(({ ranges }) => complement(ranges, [0, 1]));\n  const width = 1 / (region.length - 1);\n  const box = (x0: number, x1: number, [from, to]: [number, number]) =>\n    `M${x0} ${1 - to}H${x1}V${1 - from}H${x0}Z`;\n  let d = \"\";\n  for (let i = 0; i + 1 < failing.length; i++) {\n    const [x0, x1] = [i * width, (i + 1) * width];\n    const [left = [], right = []] = [failing[i], failing[i + 1]];\n    if (left.length === right.length) {\n      left.forEach(([f0, t0], k) => {\n        const [f1, t1] = right[k] ?? [f0, t0];\n        d += `M${x0} ${1 - t0}L${x1} ${1 - t1}L${x1} ${1 - f1}L${x0} ${1 - f0}Z`;\n      });\n    } else {\n      // The bands split or merge here: each column keeps its own half.\n      for (const range of left) d += box(x0, x0 + width / 2, range);\n      for (const range of right) d += box(x1 - width / 2, x1, range);\n    }\n  }\n  return d;\n}\n\n/** Hatches where the area fails AA and draws the AA (solid) and AAA (dashed) thresholds. */\nfunction AreaContrast({ aa, aaa }: { aa: PassingColumn[]; aaa: PassingColumn[] }) {\n  const id = `color-picker-${useId().replace(/[^\\w-]/g, \"\")}`;\n  const columns = aa.length - 1;\n  const width = 1 / columns;\n  const edges = (region: PassingColumn[], i: number) => boundaries(region[i]?.ranges ?? [], [0, 1]);\n  const failing = failingPath(aa);\n  // A threshold joins each column's edges to the next one's, where they pair up.\n  const contour = (region: PassingColumn[]) =>\n    Array.from({ length: columns }, (_, i) => {\n      const [from, to] = [edges(region, i), edges(region, i + 1)];\n      if (from.length !== to.length) return \"\";\n      const [x1, x2] = [i * width, (i + 1) * width];\n      return from.map((y1, k) => `M${x1} ${1 - y1}L${x2} ${1 - (to[k] ?? y1)}`).join(\"\");\n    }).join(\"\");\n\n  return (\n    <svg\n      aria-hidden\n      data-slot=\"color-picker-area-contrast\"\n      className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n    >\n      <defs>\n        <pattern\n          id={`${id}-hatch`}\n          width=\"6\"\n          height=\"6\"\n          patternUnits=\"userSpaceOnUse\"\n          patternTransform=\"rotate(45)\"\n        >\n          <rect width=\"3\" height=\"6\" fill=\"rgb(0 0 0 / 0.3)\" />\n          <rect x=\"3\" width=\"3\" height=\"6\" fill=\"rgb(255 255 255 / 0.3)\" />\n        </pattern>\n        <clipPath id={`${id}-failing`} clipPathUnits=\"objectBoundingBox\">\n          <path d={failing} />\n        </clipPath>\n      </defs>\n      {failing && (\n        <rect\n          data-slot=\"color-picker-failing\"\n          width=\"100%\"\n          height=\"100%\"\n          fill={`url(#${id}-hatch)`}\n          clipPath={`url(#${id}-failing)`}\n        />\n      )}\n      <svg\n        aria-hidden\n        viewBox=\"0 0 1 1\"\n        preserveAspectRatio=\"none\"\n        className=\"absolute inset-0 size-full overflow-visible\"\n        fill=\"none\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      >\n        {(\n          [\n            [\"aa\", aa, undefined],\n            [\"aaa\", aaa, \"3 3\"],\n          ] as const\n        ).map(([name, region, dash]) => {\n          const d = contour(region);\n          return (\n            <g key={name} data-slot={`color-picker-${name}-threshold`} strokeDasharray={dash}>\n              <path\n                d={d}\n                stroke=\"rgb(0 0 0 / 0.5)\"\n                strokeWidth={3}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <path d={d} stroke=\"white\" strokeWidth={1.5} vectorEffect=\"non-scaling-stroke\" />\n            </g>\n          );\n        })}\n      </svg>\n    </svg>\n  );\n}\n\ntype ColorPickerAreaProps = {\n  /**\n   * OKLCH (default): chroma across, lightness up, at the current hue. Only what\n   * sRGB can show is painted, a rough triangle: black and white have no chroma,\n   * and the most chroma peaks (the cusp) at a lightness that depends on the\n   * hue. The rest is left blank and chroma stops at its edge, so the axes stay\n   * the literal values written to a token.\n   * HSB: saturation across, brightness up; a full square (HSB is the RGB cube\n   * reshaped) but not perceptual.\n   */\n  colorSpace?: AreaSpace;\n  \"aria-label\"?: string;\n  className?: string;\n};\n\n/**\n * A 2D area at the current hue: drag or use the arrow keys (Shift for bigger\n * steps). With `contrastWith`, the part failing AA is hatched and the AA (solid)\n * and AAA (dashed) thresholds are drawn across it.\n */\nfunction ColorPickerArea({\n  colorSpace = \"oklch\",\n  \"aria-label\": ariaLabel,\n  className,\n}: ColorPickerAreaProps) {\n  const picker = usePicker(\"ColorPickerArea\");\n  const space = colorSpace;\n  const { x: xKey, y: yKey, label, xName, yName } = AREA[space];\n  const { labels } = picker;\n  const channels = channelsIn(picker.state, space);\n  const h = channels.h ?? 0;\n  const peak = useMemo(() => (space === \"oklch\" ? cusp(h) : { c: 1, l: 1 }), [space, h]);\n  const xMax = peak.c;\n  const x = xMax > 0 ? clamp01((channels[xKey] ?? 0) / xMax) : 0;\n  const y = clamp01(channels[yKey] ?? 0);\n\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  useEffect(() => {\n    if (space === \"oklch\" && canvasRef.current) paintOklchArea(canvasRef.current, h, xMax);\n  }, [space, h, xMax]);\n\n  const background = picker.contrastWith;\n  const contrast = useMemo(() => {\n    if (!background) return undefined;\n    // Only the hue matters: the area scans the other two channels.\n    const css = formatCss(toCulori({ space, channels: { [xKey]: 0, [yKey]: 0, h }, alpha: 1 }));\n    const options = { space, x: xKey, y: yKey, xDomain: [0, xMax] as [number, number] };\n    const aa = passingRegion(css, background, CONTRAST_AA, options);\n    const aaa = passingRegion(css, background, CONTRAST_AAA, options);\n    return { aa, aaa };\n  }, [background, space, xKey, yKey, h, xMax]);\n  const clipPath = useMemo(\n    () => (space === \"oklch\" ? gamutClipPath(h, xMax, peak.l) : undefined),\n    [space, h, xMax, peak.l],\n  );\n\n  const set = (nextX: number, nextY: number, end = false) => {\n    const xValue = clamp01(nextX) * xMax;\n    const l = clamp01(nextY);\n    const { alpha } = picker.state;\n    if (space === \"hsb\") {\n      picker.update({ space, channels: { h, s: xValue, v: l }, alpha }, end);\n      return;\n    }\n    // Chroma stops at the edge of sRGB at that lightness.\n    const { c } = inGamut({ l, c: xValue, h, alpha: 1 });\n    picker.update({ space, channels: { l, c, h }, alpha }, end);\n  };\n\n  const areaRef = useRef<HTMLDivElement>(null);\n  const drag = useWindowDrag((clientX, clientY) => {\n    const rect = areaRef.current?.getBoundingClientRect();\n    if (rect) set((clientX - rect.left) / rect.width, 1 - (clientY - rect.top) / rect.height);\n  }, picker.end);\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? 0.1 : 0.01;\n    const moves: Record<string, [number, number]> = {\n      ArrowLeft: [-step, 0],\n      ArrowRight: [step, 0],\n      ArrowUp: [0, step],\n      ArrowDown: [0, -step],\n      PageUp: [0, 0.1],\n      PageDown: [0, -0.1],\n      Home: [-1, 0],\n      End: [1, 0],\n    };\n    const move = moves[event.key];\n    if (!move || picker.isDisabled) return;\n    event.preventDefault();\n    set(x + move[0], y + move[1], true);\n  };\n\n  const format = (value: number) =>\n    space === \"oklch\" ? value.toFixed(3) : `${Math.round(value * 100)}%`;\n  const valueText = `${labels[yName]} ${format(y)}, ${labels[xName]} ${format(x * xMax)}`;\n\n  return (\n    <div\n      role=\"slider\"\n      tabIndex={picker.isDisabled ? undefined : 0}\n      aria-label={ariaLabel ?? labels[label]}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={Math.round(y * 100)}\n      aria-valuetext={valueText}\n      aria-disabled={picker.isDisabled || undefined}\n      data-slot=\"color-picker-area\"\n      data-color-space={space}\n      dir=\"ltr\"\n      className={cn(\n        \"relative h-40 w-full shrink-0 cursor-crosshair touch-none rounded-md bg-muted ring-1 ring-foreground/10 outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50\",\n        className,\n      )}\n      style={\n        space === \"hsb\"\n          ? {\n              background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${formatHex({ mode: \"hsv\", h, s: 1, v: 1 })})`,\n            }\n          : undefined\n      }\n      ref={areaRef}\n      data-dragging={drag.dragging || undefined}\n      onPointerDown={(event) => {\n        if (!picker.isDisabled) drag.start(event);\n      }}\n      onKeyDown={onKeyDown}\n    >\n      <div className=\"absolute inset-0 overflow-hidden rounded-[inherit]\">\n        <div className=\"absolute inset-0\" style={{ clipPath }}>\n          {space === \"oklch\" && (\n            <canvas\n              ref={canvasRef}\n              aria-hidden\n              width={AREA_WIDTH}\n              height={AREA_HEIGHT}\n              className=\"absolute inset-0 size-full\"\n            />\n          )}\n          {contrast && <AreaContrast {...contrast} />}\n        </div>\n      </div>\n      <div\n        data-slot=\"color-picker-area-thumb\"\n        className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4),0_1px_2px_rgb(0_0_0/0.3)]\"\n        style={{ left: percent(x), top: percent(1 - y), background: paint(picker.state) }}\n      />\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                    Wheel                                   */\n/* -------------------------------------------------------------------------- */\n\n/** The ring: its middle radius and its thickness, in % of the wheel's side. */\nconst RING_RADIUS = 45;\nconst RING_WIDTH = 10;\nconst RING_MASK = `radial-gradient(farthest-side, transparent calc(${100 - RING_WIDTH * 2}% - 0.5px), #000 ${100 - RING_WIDTH * 2}%)`;\n\n/** The ring and thumb show each hue vivid, whatever the color's own chroma. */\nconst VIVID: Record<AreaSpace, Channels> = { oklch: { l: 0.7, c: 0.15 }, hsb: { s: 1, v: 1 } };\n\n/** A point at `degrees` (0 on top, clockwise) and `radius` from the center of a 100 box. */\nfunction polar(degrees: number, radius: number): string {\n  const radians = (degrees * Math.PI) / 180;\n  return `${50 + radius * Math.sin(radians)} ${50 - radius * Math.cos(radians)}`;\n}\n\n/** An SVG arc along the ring from `from` to `to` degrees (a full turn as two halves). */\nfunction ringArc(from: number, to: number): string {\n  const r = RING_RADIUS;\n  if (to - from >= 359.99) {\n    return `M${polar(0, r)}A${r} ${r} 0 1 1 ${polar(180, r)}A${r} ${r} 0 1 1 ${polar(0, r)}`;\n  }\n  return `M${polar(from, r)}A${r} ${r} 0 ${to - from > 180 ? 1 : 0} 1 ${polar(to, r)}`;\n}\n\ntype ColorPickerWheelProps = {\n  /** The hue it turns: OKLCH's (default) or HSB's. */\n  colorSpace?: AreaSpace;\n  /** Content of the square inside the ring, e.g. a ColorPickerArea with `className=\"size-full\"`. */\n  children?: ReactNode;\n  \"aria-label\"?: string;\n  className?: string;\n};\n\n/**\n * A hue ring: drag around it, or use the arrow keys (Shift for 10°). With\n * `contrastWith`, the hues failing AA are hatched and the AA/AAA thresholds\n * marked. Its children fill the square inside the ring.\n */\nfunction ColorPickerWheel({\n  colorSpace = \"oklch\",\n  children,\n  \"aria-label\": ariaLabel,\n  className,\n}: ColorPickerWheelProps) {\n  const picker = usePicker(\"ColorPickerWheel\");\n  const space = colorSpace;\n  const h = channelsIn(picker.state, space).h ?? 0;\n  const contrast = useTrackContrast(picker.state, space, \"h\", picker.contrastWith);\n  const id = `color-picker-${useId().replace(/[^\\w-]/g, \"\")}`;\n  const vivid = VIVID[space];\n  const ring = useMemo(() => {\n    const stops = Array.from({ length: 25 }, (_, i) =>\n      paint({ space, channels: { ...vivid, h: i * 15 }, alpha: 1 }),\n    );\n    return `conic-gradient(${stops.join(\", \")})`;\n  }, [space, vivid]);\n\n  const set = (hue: number, end = false) =>\n    picker.update(withChannel(picker.state, space, \"h\", ((hue % 360) + 360) % 360), end);\n  const wheelRef = useRef<HTMLDivElement>(null);\n  const drag = useWindowDrag((clientX, clientY) => {\n    const rect = wheelRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const dx = clientX - (rect.left + rect.width / 2);\n    const dy = clientY - (rect.top + rect.height / 2);\n    set((Math.atan2(dx, -dy) * 180) / Math.PI);\n  }, picker.end);\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? 10 : 1;\n    const moves: Record<string, number> = {\n      ArrowRight: step,\n      ArrowUp: step,\n      ArrowLeft: -step,\n      ArrowDown: -step,\n      PageUp: 10,\n      PageDown: -10,\n    };\n    const move = moves[event.key];\n    if (move === undefined || picker.isDisabled) return;\n    event.preventDefault();\n    set(Math.round(h) + move, true);\n  };\n\n  return (\n    <div\n      ref={wheelRef}\n      data-slot=\"color-picker-wheel\"\n      dir=\"ltr\"\n      className={cn(\"relative aspect-square size-56 shrink-0\", className)}\n    >\n      <div\n        role=\"slider\"\n        tabIndex={picker.isDisabled ? undefined : 0}\n        aria-label={ariaLabel ?? picker.labels.hue}\n        aria-valuemin={0}\n        aria-valuemax={360}\n        aria-valuenow={Math.round(h)}\n        aria-valuetext={`${Math.round(h)}°`}\n        aria-disabled={picker.isDisabled || undefined}\n        data-dragging={drag.dragging || undefined}\n        className=\"absolute inset-0 cursor-pointer touch-none rounded-full outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50\"\n        onPointerDown={(event) => {\n          if (!picker.isDisabled) drag.start(event);\n        }}\n        onKeyDown={onKeyDown}\n      >\n        <div\n          aria-hidden\n          className=\"absolute inset-0 rounded-full\"\n          style={{ background: ring, mask: RING_MASK }}\n        />\n        {contrast && (\n          <svg\n            aria-hidden\n            viewBox=\"0 0 100 100\"\n            className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n            fill=\"none\"\n          >\n            <defs>\n              <pattern\n                id={`${id}-hatch`}\n                width=\"3\"\n                height=\"3\"\n                patternUnits=\"userSpaceOnUse\"\n                patternTransform=\"rotate(45)\"\n              >\n                <rect width=\"1.5\" height=\"3\" fill=\"rgb(0 0 0 / 0.35)\" />\n                <rect x=\"1.5\" width=\"1.5\" height=\"3\" fill=\"rgb(255 255 255 / 0.35)\" />\n              </pattern>\n            </defs>\n            {contrast.failing.map(([from, to]) => (\n              <path\n                key={`${from}-${to}`}\n                data-slot=\"color-picker-failing\"\n                d={ringArc(from, to)}\n                stroke={`url(#${id}-hatch)`}\n                strokeWidth={RING_WIDTH}\n              />\n            ))}\n            {contrast.marks.map((mark) => (\n              <path\n                key={mark}\n                d={`M${polar(mark, RING_RADIUS - RING_WIDTH / 2)}L${polar(mark, RING_RADIUS + RING_WIDTH / 2)}`}\n                className=\"stroke-foreground\"\n                strokeWidth={1}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            ))}\n          </svg>\n        )}\n        <div\n          data-slot=\"color-picker-wheel-thumb\"\n          className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4),0_1px_2px_rgb(0_0_0/0.3)]\"\n          style={{\n            left: `${50 + RING_RADIUS * Math.sin((h * Math.PI) / 180)}%`,\n            top: `${50 - RING_RADIUS * Math.cos((h * Math.PI) / 180)}%`,\n            background: paint({ space, channels: { ...vivid, h }, alpha: 1 }),\n          }}\n        />\n      </div>\n      {children && (\n        // The square inscribed in the ring's inner edge, with a little gap.\n        <div className=\"absolute\" style={{ inset: `${50 - (50 - RING_WIDTH - 2) / Math.SQRT2}%` }}>\n          {children}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                   Fields                                   */\n/* -------------------------------------------------------------------------- */\n\ntype ColorPickerChannelInputProps = {\n  channel: ColorPickerChannel;\n  /** The space the channel belongs to (defaults as in ColorPickerSlider). */\n  colorSpace?: ColorSpace;\n  \"aria-label\"?: string;\n  className?: string;\n};\n\n/** A number field for one channel; arrow keys step it, and it commits on Enter or blur. */\nfunction ColorPickerChannelInput({\n  channel,\n  colorSpace = \"oklch\",\n  \"aria-label\": ariaLabel,\n  className,\n}: ColorPickerChannelInputProps) {\n  const picker = usePicker(\"ColorPickerChannelInput\");\n  const { key, spaces } = CHANNELS[channel];\n  const label = picker.labels[channel];\n  const space = spaces.includes(colorSpace) ? colorSpace : (spaces[0] ?? \"oklch\");\n  const { min, max, step, scale, format } = channelScale(key, space);\n  const value = key ? (channelsIn(picker.state, space)[key] ?? 0) * scale : picker.state.alpha;\n  return (\n    <NumberField\n      data-slot=\"color-picker-channel-input\"\n      data-channel={channel}\n      aria-label={ariaLabel ?? label}\n      minValue={min}\n      maxValue={max}\n      step={step}\n      formatOptions={format}\n      value={value}\n      isDisabled={picker.isDisabled}\n      onChange={(next) => {\n        if (Number.isNaN(next)) return;\n        picker.update(withChannel(picker.state, space, key, next / scale), true);\n      }}\n      className={cn(\"min-w-0\", className)}\n    >\n      <Input className=\"h-7 px-1 text-center font-mono text-xs tabular-nums md:text-xs\" />\n    </NumberField>\n  );\n}\n\n/** The color as hex, typed with or without `#`; commits on Enter or blur. */\nfunction HexInput() {\n  const picker = usePicker(\"ColorPickerFields\");\n  const hex = formatState({ ...picker.state, alpha: 1 }, \"hex\");\n  const [draft, setDraft] = useState(hex);\n  useEffect(() => setDraft(hex), [hex]);\n  const commit = () => {\n    const next = parseState(draft.startsWith(\"#\") ? draft : `#${draft}`, picker.state);\n    if (next && formatState({ ...next, alpha: 1 }, \"hex\") !== hex) {\n      picker.update({ ...next, alpha: picker.state.alpha }, true);\n    } else setDraft(hex);\n  };\n  return (\n    <Input\n      data-slot=\"color-picker-hex-input\"\n      aria-label={picker.labels.hex}\n      value={draft}\n      spellCheck={false}\n      disabled={picker.isDisabled}\n      onChange={(event) => setDraft(event.target.value)}\n      onBlur={commit}\n      onKeyDown={(event) => {\n        if (event.key === \"Enter\") commit();\n        if (event.key === \"Escape\" && draft !== hex) {\n          // Reverts the draft instead of closing the popover.\n          event.stopPropagation();\n          setDraft(hex);\n        }\n      }}\n      className=\"h-7 min-w-0 px-1.5 font-mono text-xs md:text-xs\"\n    />\n  );\n}\n\nconst FORMAT_LABELS: Record<ColorPickerFormat, string> = {\n  oklch: \"OKLCH\",\n  hex: \"HEX\",\n  rgb: \"RGB\",\n  hsl: \"HSL\",\n};\n\nconst FORMAT_CHANNELS: Record<\n  Exclude<ColorPickerFormat, \"hex\">,\n  [ColorSpace, ColorPickerChannel[]]\n> = {\n  oklch: [\"oklch\", [\"lightness\", \"chroma\", \"hue\"]],\n  rgb: [\"rgb\", [\"red\", \"green\", \"blue\"]],\n  hsl: [\"hsl\", [\"hue\", \"saturation\", \"lightness\"]],\n};\n\ntype ColorPickerFieldsProps = {\n  /** The formats offered in the menu (the first shown, unless it has the picker's). */\n  formats?: ColorPickerFormat[];\n  className?: string;\n};\n\n/**\n * A format menu and the fields of that format: a hex input, or one number\n * field per channel (plus alpha when the picker has `alpha`).\n */\nfunction ColorPickerFields({\n  formats = [\"oklch\", \"hex\", \"rgb\", \"hsl\"],\n  className,\n}: ColorPickerFieldsProps) {\n  const picker = usePicker(\"ColorPickerFields\");\n  const [format, setFormat] = useState<ColorPickerFormat>(() =>\n    formats.includes(picker.format) ? picker.format : (formats[0] ?? \"hex\"),\n  );\n  return (\n    <div data-slot=\"color-picker-fields\" className={cn(\"flex items-center gap-1.5\", className)}>\n      {formats.length > 1 && (\n        <Select\n          aria-label={picker.labels.format}\n          value={format}\n          onChange={(key) => {\n            const next = formats.find((f) => f === key);\n            if (next) setFormat(next);\n          }}\n          isDisabled={picker.isDisabled}\n        >\n          <SelectTrigger size=\"sm\" className=\"w-21 shrink-0 text-xs\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {formats.map((f) => (\n              <SelectItem key={f} id={f}>\n                {FORMAT_LABELS[f]}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      )}\n      <div className=\"grid min-w-0 flex-1 auto-cols-fr grid-flow-col gap-1\">\n        {format === \"hex\" ? (\n          <HexInput />\n        ) : (\n          FORMAT_CHANNELS[format][1].map((channel) => (\n            <ColorPickerChannelInput\n              key={channel}\n              channel={channel}\n              colorSpace={FORMAT_CHANNELS[format][0]}\n            />\n          ))\n        )}\n        {picker.alpha && <ColorPickerChannelInput channel=\"alpha\" />}\n      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Swatches                                  */\n/* -------------------------------------------------------------------------- */\n\ntype ColorPickerSwatchesProps = {\n  /** Preset colors. Defaults to the picker's `swatches`, else Tailwind's palette. */\n  colors?: Swatch[];\n  /** Columns of the grid. Defaults to the picker's `swatchColumns`. */\n  columns?: number;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n};\n\n/**\n * A grid of preset colors that sets the color in one click (arrow keys move\n * through it). With `contrastWith`, presets failing AA are hatched.\n */\nfunction ColorPickerSwatches({\n  colors,\n  columns,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  className,\n}: ColorPickerSwatchesProps) {\n  const picker = usePicker(\"ColorPickerSwatches\");\n  const swatches = colors ?? picker.swatches ?? DEFAULT_SWATCHES;\n  // Swatches are compared as the picker emits them (oklch, clamped to sRGB, opaque).\n  const items = useMemo(() => {\n    const seen = new Set<string>();\n    return swatches.flatMap((swatch) => {\n      const state = parseState(swatchColor(swatch));\n      if (!state) return [];\n      const id = formatState({ ...state, alpha: 1 });\n      if (seen.has(id)) return [];\n      seen.add(id);\n      return [{ id, name: swatchName(swatch), paint: paint(state) }];\n    });\n  }, [swatches]);\n  if (items.length === 0) return null;\n  const selectedId = formatState({ ...picker.state, alpha: 1 });\n  const background = picker.contrastWith;\n\n  return (\n    <ListBox\n      data-slot=\"color-picker-swatches\"\n      aria-label={ariaLabel ?? (ariaLabelledby ? undefined : picker.labels.swatches)}\n      aria-labelledby={ariaLabelledby}\n      layout=\"grid\"\n      selectionMode=\"single\"\n      disallowEmptySelection\n      selectedKeys={[selectedId]}\n      onSelectionChange={(keys) => {\n        const [key] = keys === \"all\" ? [] : [...keys];\n        const next = typeof key === \"string\" ? parseState(key) : undefined;\n        if (next) picker.update({ ...next, alpha: picker.state.alpha }, true);\n      }}\n      className={cn(\"grid gap-1 outline-none\", className)}\n      style={{\n        gridTemplateColumns: `repeat(${columns ?? picker.swatchColumns ?? TAILWIND_HUES.length}, minmax(0, 1fr))`,\n      }}\n    >\n      {items.map((item) => {\n        const ratio = background ? contrastRatio(background, item.id) : undefined;\n        const fails = ratio !== undefined && ratio < CONTRAST_AA;\n        return (\n          <ListBoxItem\n            key={item.id}\n            id={item.id}\n            textValue={item.name}\n            aria-label={ratio === undefined ? item.name : `${item.name}, ${ratio.toFixed(2)}:1`}\n            isDisabled={picker.isDisabled}\n            data-failing={fails || undefined}\n            className=\"relative aspect-square cursor-pointer overflow-hidden rounded-sm ring-1 ring-foreground/10 outline-none ring-inset data-disabled:cursor-not-allowed data-disabled:opacity-50 data-focus-visible:ring-2 data-focus-visible:ring-ring data-selected:ring-2 data-selected:ring-foreground\"\n            style={{ background: item.paint }}\n          >\n            {fails && <span aria-hidden className={cn(\"absolute inset-0\", HATCH)} />}\n          </ListBoxItem>\n        );\n      })}\n    </ListBox>\n  );\n}\n\ntype ColorPickerSwatchProps = Omit<ComponentProps<\"span\">, \"color\"> & {\n  /** The color shown. Defaults to the surrounding picker's. */\n  color?: string;\n};\n\n/** A chip of a color (the picker's by default), over a checker when translucent. */\nfunction ColorPickerSwatch({ color, className, style, ...props }: ColorPickerSwatchProps) {\n  const picker = useContext(ColorPickerContext);\n  const value = color ?? picker?.value ?? \"transparent\";\n  return (\n    <span\n      data-slot=\"color-picker-swatch\"\n      className={cn(\n        \"inline-block size-5 shrink-0 rounded-sm ring-1 ring-foreground/10 ring-inset\",\n        className,\n      )}\n      style={{ background: swatchBackground(value), ...style }}\n      {...props}\n    />\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Input and actions                             */\n/* -------------------------------------------------------------------------- */\n\ntype ColorPickerInputProps = Omit<\n  ComponentProps<typeof InputGroupInput>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> & {\n  /**\n   * Reads what was typed as a CSS color, for syntaxes culori doesn't parse\n   * (e.g. bare \"222 47% 11%\" triplets). Return undefined to reject it.\n   */\n  parseValue?: (text: string) => string | undefined;\n};\n\n/**\n * A text field taking any CSS color (hex, rgb(), hsl(), oklch(), names…),\n * committed on Enter or blur; Escape reverts what was typed. A swatch at its\n * start opens ColorPickerContent, placed against the field.\n */\nfunction ColorPickerInput({\n  className,\n  parseValue,\n  onBlur,\n  onKeyDown,\n  ...props\n}: ColorPickerInputProps) {\n  const picker = usePicker(\"ColorPickerInput\");\n  const [draft, setDraft] = useState(picker.value);\n  useEffect(() => setDraft(picker.value), [picker.value]);\n  const read = (text: string) => {\n    const css = parseValue ? parseValue(text) : text;\n    return css === undefined ? undefined : parseState(css, picker.state);\n  };\n  const valid = read(draft) !== undefined;\n  const commit = () => {\n    const next = read(draft);\n    if (next && formatState(next, picker.format) !== picker.value) picker.update(next, true);\n    else setDraft(picker.value);\n  };\n\n  return (\n    <div ref={picker.anchorRef} data-slot=\"color-picker-input\" className={cn(\"w-full\", className)}>\n      <InputGroup isDisabled={picker.isDisabled}>\n        <InputGroupAddon align=\"inline-start\">\n          <ColorPickerTrigger className=\"relative size-5 rounded-sm after:absolute after:-inset-0.5\" />\n        </InputGroupAddon>\n        <InputGroupInput\n          value={draft}\n          spellCheck={false}\n          autoComplete=\"off\"\n          disabled={picker.isDisabled}\n          aria-invalid={!valid || picker.isInvalid || undefined}\n          onChange={(event) => setDraft(event.target.value)}\n          onBlur={(event) => {\n            onBlur?.(event);\n            commit();\n          }}\n          onKeyDown={(event) => {\n            onKeyDown?.(event);\n            if (event.key === \"Enter\") {\n              // A changed draft is committed first; the next Enter submits the form.\n              if (draft !== picker.value) event.preventDefault();\n              commit();\n            }\n            if (event.key === \"Escape\") setDraft(picker.value);\n          }}\n          className=\"font-mono\"\n          {...props}\n        />\n      </InputGroup>\n    </div>\n  );\n}\n\n/** The current value as text (in `format`, or the picker's). */\nfunction ColorPickerValue({\n  format,\n  className,\n  ...props\n}: Omit<ComponentProps<\"span\">, \"children\"> & { format?: ColorPickerFormat }) {\n  const picker = usePicker(\"ColorPickerValue\");\n  return (\n    <span data-slot=\"color-picker-value\" className={cn(\"tabular-nums\", className)} {...props}>\n      {format ? formatState(picker.state, format) : picker.value}\n    </span>\n  );\n}\n\ntype ColorPickerActionProps = Omit<ComponentProps<typeof Button>, \"children\"> & {\n  children?: ReactNode;\n};\n\ntype ColorPickerCopyProps = ColorPickerActionProps & {\n  /** The format copied. Defaults to the picker's. */\n  format?: ColorPickerFormat;\n};\n\n/** Copies the value to the clipboard; its icon turns into a check for a moment. */\nfunction ColorPickerCopy({\n  format,\n  variant = \"ghost\",\n  size = \"icon-sm\",\n  children,\n  ...props\n}: ColorPickerCopyProps) {\n  const picker = usePicker(\"ColorPickerCopy\");\n  return (\n    <CopyButton\n      data-slot=\"color-picker-copy\"\n      value={() => (format ? formatState(picker.state, format) : picker.value)}\n      timeout={1500}\n      labels={{\n        copy: picker.labels.copy,\n        copied: picker.labels.copied,\n        failed: picker.labels.copyFailed,\n      }}\n      variant={variant}\n      size={size}\n      {...props}\n    >\n      {children}\n    </CopyButton>\n  );\n}\n\ntype EyeDropperConstructor = new () => { open: () => Promise<{ sRGBHex: string }> };\n\nconst getEyeDropper = () => (window as Window & { EyeDropper?: EyeDropperConstructor }).EyeDropper;\nconst noSubscribe = () => () => {};\n\n/**\n * Samples a color from anywhere on screen, keeping the current alpha. Renders\n * nothing where the browser has no EyeDropper (Firefox, Safari).\n */\nfunction ColorPickerEyeDropper({\n  variant = \"ghost\",\n  size = \"icon-sm\",\n  children,\n  ...props\n}: ColorPickerActionProps) {\n  const picker = usePicker(\"ColorPickerEyeDropper\");\n  // False on the server and on hydration, so the markup matches.\n  const supported = useSyncExternalStore(\n    noSubscribe,\n    () => getEyeDropper() !== undefined,\n    () => false,\n  );\n  if (!supported) return null;\n  return (\n    <Button\n      data-slot=\"color-picker-eye-dropper\"\n      variant={variant}\n      size={size}\n      aria-label={picker.labels.eyeDropper}\n      isDisabled={picker.isDisabled}\n      onPress={async () => {\n        const EyeDropper = getEyeDropper();\n        if (!EyeDropper) return;\n        try {\n          const { sRGBHex } = await new EyeDropper().open();\n          const next = parseState(sRGBHex);\n          if (next) picker.update({ ...next, alpha: picker.state.alpha }, true);\n        } catch {\n          // Escape cancels the sampler with an AbortError.\n        }\n      }}\n      {...props}\n    >\n      {children ?? <PipetteIcon />}\n    </Button>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Contrast                                  */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A bar of the color. With `contrastWith`, it shows \"Aa\" in the color on that\n * background, with the WCAG ratio and level beside it.\n */\nfunction ColorPickerPreview({ className }: { className?: string }) {\n  const picker = usePicker(\"ColorPickerPreview\");\n  const background = picker.contrastWith;\n  const ratio = background ? contrastRatio(background, picker.value) : undefined;\n  const level = ratio === undefined ? undefined : contrastLevel(ratio);\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <div\n        data-slot=\"color-picker-preview\"\n        className=\"flex h-10 flex-1 items-center justify-center rounded-md text-sm font-medium ring-1 ring-foreground/10\"\n        style={\n          background\n            ? { background, color: picker.value }\n            : { background: swatchBackground(picker.value) }\n        }\n      >\n        {background && \"Aa\"}\n      </div>\n      {ratio !== undefined && (\n        <div\n          data-slot=\"color-picker-ratio\"\n          data-level={level}\n          className={cn(\n            \"flex w-20 flex-col items-end text-xs\",\n            level === \"Fail\" && \"text-destructive\",\n          )}\n        >\n          <span className=\"font-mono text-sm tabular-nums\">{ratio.toFixed(2)}:1</span>\n          <span className=\"text-muted-foreground\">\n            {level === \"AA18\"\n              ? picker.labels.largeText\n              : level === \"Fail\"\n                ? picker.labels.fail\n                : level}\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/**\n * \"Fix to AA\" / \"Fix to AAA\" buttons: move to the nearest OKLCH lightness that\n * reaches the ratio against `contrastWith`, keeping hue, chroma and alpha.\n */\nfunction ColorPickerFixContrast({ className }: { className?: string }) {\n  const picker = usePicker(\"ColorPickerFixContrast\");\n  const background = picker.contrastWith;\n  const { l = 0, c = 0, h = 0 } = channelsIn(picker.state, \"oklch\");\n  const targets = useMemo(() => {\n    if (!background) return undefined;\n    const color = formatOklchChannels({ l, c, h, alpha: 1 });\n    return {\n      AA: nearestContrastColor(color, background, CONTRAST_AA),\n      AAA: nearestContrastColor(color, background, CONTRAST_AAA),\n    };\n  }, [l, c, h, background]);\n  if (!background || !targets) return null;\n  const ratio = contrastRatio(background, picker.value);\n\n  return (\n    <div className={cn(\"flex gap-2\", className)}>\n      {(\n        [\n          [\"AA\", targets.AA, CONTRAST_AA],\n          [\"AAA\", targets.AAA, CONTRAST_AAA],\n        ] as const\n      ).map(([name, target, min]) => (\n        <Button\n          key={name}\n          size=\"sm\"\n          variant=\"outline\"\n          className=\"flex-1\"\n          isDisabled={picker.isDisabled || !target || ratio >= min}\n          onPress={() => {\n            const next = target ? parseState(target) : undefined;\n            if (!next) return;\n            const channels = { ...next.channels, h };\n            picker.update({ space: \"oklch\", channels, alpha: picker.state.alpha }, true);\n          }}\n        >\n          <ContrastIcon />\n          {picker.labels.fix(name)}\n        </Button>\n      ))}\n    </div>\n  );\n}\n\nexport type {\n  ColorPickerActionProps,\n  ColorPickerAreaProps,\n  ColorPickerChannel,\n  ColorPickerChannelInputProps,\n  ColorPickerContentProps,\n  ColorPickerCopyProps,\n  ColorPickerFieldsProps,\n  ColorPickerFormat,\n  ColorPickerInputProps,\n  ColorPickerLabels,\n  ColorPickerPanelProps,\n  ColorPickerProps,\n  ColorPickerSliderProps,\n  ColorPickerSwatchesProps,\n  ColorPickerSwatchProps,\n  ColorPickerTriggerProps,\n  ColorPickerWheelProps,\n  Swatch,\n};\nexport {\n  ColorPicker,\n  ColorPickerArea,\n  ColorPickerChannelInput,\n  ColorPickerContent,\n  ColorPickerCopy,\n  ColorPickerEyeDropper,\n  ColorPickerFields,\n  ColorPickerFixContrast,\n  ColorPickerInput,\n  ColorPickerPanel,\n  ColorPickerPreview,\n  ColorPickerSlider,\n  ColorPickerSwatch,\n  ColorPickerSwatches,\n  ColorPickerTrigger,\n  ColorPickerValue,\n  ColorPickerWheel,\n  DEFAULT_COLOR_PICKER_LABELS,\n  DEFAULT_SWATCHES,\n  useColorPicker,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ds/color-picker.tsx"
    }
  ],
  "meta": {
    "status": "stable",
    "source": "original",
    "license": "MIT",
    "tags": [
      "color",
      "composable",
      "contrast",
      "forms",
      "oklch",
      "react-aria"
    ],
    "contentHash": "538c1f4f4734"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}