{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"color-picker","type":"registry:ui","title":"Color Picker","description":"A swatch button that opens a color card – saturation and brightness, hue and optional opacity by drag or arrows, a hex field that also reads rgb(), named presets, a hex string as the value","dependencies":[],"files":[{"path":"registry/color-picker/color-picker.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithRef,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n/*\n * Color Picker – a single self-contained file.\n * A swatch button that opens a small card: a saturation / brightness area, a hue slider, an optional opacity slider,\n * a hex field (it understands #rgb, #rrggbb, #rrggbbaa and rgb()) and preset swatches. Every area and slider works\n * by pointer (dragged with capture, so it keeps following outside the card) and by keyboard (arrows, Shift for\n * bigger steps). The value is a hex string – \"#0190fe\", or \"#0190fecc\" with opacity below 100%.\n * ColorPickerPanel is the same card without the button, for a form or a settings page.\n * The styles are injected below, so no extra files are needed.\n */\n\n/** A preset: a color, or a color with a name (\"Blue\") that is read out and shown as its tooltip */\nexport type ColorPreset = string | { value: string; label: string };\n\ntype ValueProps = {\n  /** The color as hex – \"#0190fe\", or \"#0190fecc\" with opacity. Also takes #rgb and rgb() from outside */\n  value?: string;\n  /** The starting color when value isn't controlled */\n  defaultValue?: string;\n  /** Called on every change – while dragging too – with a lowercase hex string */\n  onValueChange?: (value: string) => void;\n  /** Shows the opacity slider; below 100% the value gets two more digits (#rrggbbaa) */\n  alpha?: boolean;\n  /** Swatches under the sliders – one press picks one */\n  presets?: ColorPreset[];\n};\n\nexport type ColorPickerPanelProps = Omit<ComponentPropsWithRef<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & ValueProps;\n\nexport type ColorPickerProps = Omit<ComponentPropsWithRef<\"button\">, \"value\" | \"defaultValue\" | \"onChange\" | \"children\" | \"name\"> &\n  ValueProps & {\n    /** Whether the card is open – leave it out to let the picker handle it */\n    open?: boolean;\n    /** Open from the start, without taking the focus */\n    defaultOpen?: boolean;\n    onOpenChange?: (open: boolean) => void;\n    /** What the color is for – names the button and the card for screen readers */\n    label?: string;\n    /** Shows the hex next to the swatch; off: only the swatch */\n    showValue?: boolean;\n    /** sm 24 · md 28 high */\n    size?: \"sm\" | \"md\";\n    /** Where the card opens; it flips when there is no room */\n    side?: \"bottom\" | \"top\";\n    /** Which edges line up with the button */\n    align?: \"start\" | \"end\";\n    /** Sends the hex with a form */\n    name?: string;\n  };\n\nconst FALLBACK = \"#0190fe\";\n\n/* ---------- The button and its card ---------- */\n\nexport function ColorPicker({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  alpha,\n  presets,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  label = \"Color\",\n  showValue = true,\n  size = \"md\",\n  side = \"bottom\",\n  align = \"start\",\n  name,\n  className,\n  disabled,\n  ref,\n  onClick,\n  ...rest\n}: ColorPickerProps) {\n  const [value, setValue] = useControllable(valueProp, defaultValue ?? FALLBACK, onValueChange);\n  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);\n  const uid = useId();\n  const wrap = useRef<HTMLSpanElement>(null);\n  const button = useRef<HTMLButtonElement | null>(null);\n  const card = useRef<HTMLDivElement>(null);\n  const setOpenRef = useRef(setOpen);\n  setOpenRef.current = setOpen;\n  // Open from the start (a demo, a restored view) leaves the focus where it is – only an opening moves it\n  const wasClosed = useRef(!open);\n  if (!open) wasClosed.current = true;\n\n  const rgba = parse(value);\n  const shown = rgba ? format(rgba, false).toUpperCase() : value;\n  const opacity = rgba && alpha && rgba.a < 1 ? `${Math.round(rgba.a * 100)}%` : null;\n  const inside = (n: Node | null) => !!n && (!!card.current?.contains(n) || !!wrap.current?.contains(n));\n\n  // Into the top layer, under (or over) the button, before paint; it follows on scroll and resize\n  useLayoutEffect(() => {\n    const el = card.current;\n    if (!open || !el) return;\n    if (\"showPopover\" in el && !el.matches(\":popover-open\")) el.showPopover();\n    const update = () => {\n      if (button.current) place(el, button.current.getBoundingClientRect(), side, align);\n    };\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(update);\n    ro?.observe(el);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      ro?.disconnect();\n      if (\"hidePopover\" in el && el.isConnected && el.matches(\":popover-open\")) el.hidePopover();\n    };\n  }, [open, side, align]);\n\n  // Opened by a press or a key: the focus moves onto the area, so the arrows work right away\n  useEffect(() => {\n    const el = card.current;\n    if (!open || !el) return;\n    if (wasClosed.current && !el.contains(document.activeElement)) el.querySelector<HTMLElement>(\"[role=slider]\")?.focus({ preventScroll: true });\n    wasClosed.current = false;\n    const close = () => setOpenRef.current(false);\n    let within = el.contains(document.activeElement);\n    const into = () => (within = true);\n    const away = (e: FocusEvent) => (within = !!e.relatedTarget && el.contains(e.relatedTarget as Node));\n    el.addEventListener(\"focusin\", into);\n    el.addEventListener(\"focusout\", away);\n    const down = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close();\n    };\n    // Caught first, so a page's own Escape (closing a dialog around it) doesn't fire as well\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      e.preventDefault();\n      e.stopPropagation();\n      const back = inside(document.activeElement);\n      close();\n      if (back) button.current?.focus({ preventScroll: true });\n    };\n    // Tabbing out of the button and the card closes it\n    const out = (e: FocusEvent) => {\n      const to = e.relatedTarget as Node | null;\n      if (inside(e.target as Node) && to && !inside(to)) close();\n    };\n    document.addEventListener(\"pointerdown\", down, true);\n    window.addEventListener(\"keydown\", key, true);\n    document.addEventListener(\"focusout\", out);\n    return () => {\n      document.removeEventListener(\"pointerdown\", down, true);\n      window.removeEventListener(\"keydown\", key, true);\n      document.removeEventListener(\"focusout\", out);\n      el.removeEventListener(\"focusin\", into);\n      el.removeEventListener(\"focusout\", away);\n      // Closed while the focus was inside: back to the button – unless something moved it on purpose\n      const b = button.current;\n      if (within)\n        window.setTimeout(() => {\n          const at = document.activeElement;\n          if (b?.isConnected && (!at || at === document.body)) b.focus({ preventScroll: true });\n        });\n    };\n  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  return (\n    <span ref={wrap} className={[\"ui-clp\", className].filter(Boolean).join(\" \")} data-size={size === \"sm\" ? \"sm\" : \"md\"}>\n      {/* React 19 hoists this into <head> once, no matter how many pickers render */}\n      <style href=\"ui-color-picker\" precedence=\"default\">\n        {css}\n      </style>\n      <button\n        {...rest}\n        ref={(el) => {\n          button.current = el;\n          if (typeof ref === \"function\") ref(el);\n          else if (ref) ref.current = el;\n        }}\n        type=\"button\"\n        className=\"ui-clp__trigger\"\n        data-bare={!showValue || undefined}\n        disabled={disabled}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-controls={open ? `${uid}-card` : undefined}\n        onClick={(e) => {\n          onClick?.(e);\n          if (!e.defaultPrevented) setOpen(!open);\n        }}\n      >\n        <span className=\"ui-clp__swatch\" style={{ \"--ui-clp-c\": rgba ? paint(rgba) : value } as CSSProperties} aria-hidden=\"true\" />\n        <span className={showValue ? \"ui-clp__value\" : \"ui-clp__sr\"}>\n          <span className=\"ui-clp__sr\">{label}: </span>\n          {shown}\n          {opacity && <span className=\"ui-clp__alpha\"> {opacity}</span>}\n        </span>\n      </button>\n      {name && <input type=\"hidden\" name={name} value={rgba ? format(rgba, !!alpha) : value} />}\n      {open && (\n        <div ref={card} id={`${uid}-card`} className=\"ui-clp__pop\" role=\"dialog\" aria-label={label} popover=\"manual\" data-side={side} data-align={align}>\n          <Panel value={value} onChange={setValue} alpha={alpha} presets={presets} />\n        </div>\n      )}\n    </span>\n  );\n}\n\n/* ---------- The card on its own ---------- */\n\nexport function ColorPickerPanel({ value: valueProp, defaultValue, onValueChange, alpha, presets, className, ...rest }: ColorPickerPanelProps) {\n  const [value, setValue] = useControllable(valueProp, defaultValue ?? FALLBACK, onValueChange);\n  return (\n    <div {...rest} className={[\"ui-clp\", \"ui-clp--inline\", className].filter(Boolean).join(\" \")}>\n      <style href=\"ui-color-picker\" precedence=\"default\">\n        {css}\n      </style>\n      <Panel value={value} onChange={setValue} alpha={alpha} presets={presets} />\n    </div>\n  );\n}\n\n/* ---------- The area, the sliders, the hex field and the presets ---------- */\n\nfunction Panel({ value, onChange, alpha, presets }: { value: string; onChange: (v: string) => void; alpha?: boolean; presets?: ColorPreset[] }) {\n  const [c, setC] = useState<Hsva>(() => toHsva(parse(value) ?? parse(FALLBACK)!, 0, alpha));\n  const [seen, setSeen] = useState(value);\n  const [draft, setDraft] = useState<string | null>(null);\n  const area = useRef<HTMLDivElement>(null);\n  const hue = useRef<HTMLDivElement>(null);\n  const opa = useRef<HTMLDivElement>(null);\n\n  // A new value from outside: follow it, but keep the hue when the color is a gray (it has none of its own)\n  if (value !== seen) {\n    setSeen(value);\n    const p = parse(value);\n    if (p && format(p, !!alpha) !== format(toRgba(c), !!alpha)) setC(toHsva(p, c.h, alpha, c.s));\n  }\n\n  const rgb = toRgba(c);\n  const hex = format(rgb, !!alpha);\n  const set = (next: Hsva) => {\n    setC(next);\n    const out = format(toRgba(next), !!alpha);\n    if (out !== value) onChange(out);\n  };\n\n  const S = Math.round(c.s * 100);\n  const V = Math.round(c.v * 100);\n  const H = Math.round(c.h);\n  const A = Math.round(c.a * 100);\n\n  const list = (presets ?? []).map((p) => (typeof p === \"string\" ? { value: p, label: p.toUpperCase() } : p));\n  const picked = list.findIndex((p) => {\n    const q = parse(p.value);\n    return !!q && format(q, !!alpha) === hex;\n  });\n\n  const [bad, setBad] = useState(false);\n  const commit = (t: string) => {\n    const p = parse(t);\n    if (p) set(toHsva(alpha ? p : { ...p, a: 1 }, c.h, alpha, c.s));\n    return !!p;\n  };\n\n  return (\n    <div className=\"ui-clp__panel\" style={{ \"--ui-clp-c\": paint(rgb), \"--ui-clp-solid\": paint({ ...rgb, a: 1 }), \"--ui-clp-hue\": `hsl(${H} 100% 50%)` } as CSSProperties}>\n      <div\n        ref={area}\n        className=\"ui-clp__area\"\n        role=\"slider\"\n        tabIndex={0}\n        dir=\"ltr\"\n        aria-label=\"Saturation and brightness\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={S}\n        aria-valuetext={`Saturation ${S}%, brightness ${V}%`}\n        {...drag(area, (x, y) => set({ ...c, s: x, v: 1 - y }), true)}\n        onKeyDown={(e) => {\n          const step = e.shiftKey ? 10 : 1;\n          const moves: Record<string, [number, number]> = {\n            ArrowRight: [step, 0],\n            ArrowLeft: [-step, 0],\n            ArrowUp: [0, step],\n            ArrowDown: [0, -step],\n            PageUp: [0, 10],\n            PageDown: [0, -10],\n            Home: [-100, 0],\n            End: [100, 0],\n          };\n          const m = moves[e.key];\n          if (!m) return;\n          e.preventDefault();\n          set({ ...c, s: clamp(S + m[0], 0, 100) / 100, v: clamp(V + m[1], 0, 100) / 100 });\n        }}\n      >\n        <span className=\"ui-clp__thumb\" style={{ left: `${c.s * 100}%`, top: `${(1 - c.v) * 100}%` }} />\n      </div>\n\n      <div\n        ref={hue}\n        className=\"ui-clp__slider ui-clp__slider--hue\"\n        role=\"slider\"\n        tabIndex={0}\n        dir=\"ltr\"\n        aria-label=\"Hue\"\n        aria-orientation=\"horizontal\"\n        aria-valuemin={0}\n        aria-valuemax={360}\n        aria-valuenow={H}\n        aria-valuetext={`${H}°, ${hueName(H)}`}\n        {...drag(hue, (x) => set({ ...c, h: x * 360 }))}\n        onKeyDown={(e) => {\n          const v = keyStep(e, H, 360);\n          if (v != null) set({ ...c, h: v });\n        }}\n      >\n        <span className=\"ui-clp__knob\" style={{ \"--p\": c.h / 360, background: `hsl(${H} 100% 50%)` } as CSSProperties} />\n      </div>\n\n      {alpha && (\n        <div\n          ref={opa}\n          className=\"ui-clp__slider ui-clp__slider--alpha\"\n          role=\"slider\"\n          tabIndex={0}\n          dir=\"ltr\"\n          aria-label=\"Opacity\"\n          aria-orientation=\"horizontal\"\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={A}\n          aria-valuetext={`${A}%`}\n          {...drag(opa, (x) => set({ ...c, a: Math.round(x * 100) / 100 }))}\n          onKeyDown={(e) => {\n            const v = keyStep(e, A, 100);\n            if (v != null) set({ ...c, a: v / 100 });\n          }}\n        >\n          <span className=\"ui-clp__knob\" style={{ \"--p\": c.a } as CSSProperties} />\n        </div>\n      )}\n\n      <div className=\"ui-clp__fields\">\n        <span className=\"ui-clp__preview\" aria-hidden=\"true\" />\n        <label className=\"ui-clp__hex\" data-invalid={bad || undefined}>\n          <span className=\"ui-clp__hash\" aria-hidden=\"true\">\n            #\n          </span>\n          <input\n            className=\"ui-clp__input\"\n            value={draft ?? hex.slice(1).toUpperCase()}\n            aria-label=\"Hex color\"\n            aria-invalid={bad || undefined}\n            spellCheck={false}\n            autoComplete=\"off\"\n            autoCapitalize=\"off\"\n            onChange={(e) => {\n              const t = e.target.value;\n              setDraft(t);\n              setBad(false);\n              // Six or eight digits, or a closed rgb(…), apply as you type; #rgb waits for Enter, so it doesn't flash\n              if (COMPLETE.test(t.trim())) commit(t);\n            }}\n            onBlur={() => {\n              if (draft != null) commit(draft);\n              setDraft(null);\n              setBad(false);\n            }}\n            onKeyDown={(e) => {\n              if (e.key !== \"Enter\" || draft == null) return;\n              e.preventDefault();\n              if (commit(draft)) setDraft(null);\n              else setBad(true);\n            }}\n          />\n        </label>\n        {alpha && <span className=\"ui-clp__pct\">{A}%</span>}\n      </div>\n\n      {list.length > 0 && (\n        <div className=\"ui-clp__presets\" role=\"radiogroup\" aria-label=\"Presets\">\n          {list.map((p, i) => (\n            <button\n              key={`${p.value}-${i}`}\n              type=\"button\"\n              role=\"radio\"\n              className=\"ui-clp__preset\"\n              aria-checked={i === picked}\n              aria-label={p.label}\n              title={p.label}\n              tabIndex={i === (picked >= 0 ? picked : 0) ? 0 : -1}\n              style={{ \"--ui-clp-p\": p.value } as CSSProperties}\n              onClick={() => {\n                const q = parse(p.value);\n                if (q) set(toHsva(alpha ? q : { ...q, a: 1 }, c.h, alpha, c.s));\n              }}\n              onKeyDown={(e) => {\n                const back = getComputedStyle(e.currentTarget).direction === \"rtl\";\n                const d = { ArrowRight: back ? -1 : 1, ArrowDown: 1, ArrowLeft: back ? 1 : -1, ArrowUp: -1 }[e.key];\n                const to = e.key === \"Home\" ? 0 : e.key === \"End\" ? list.length - 1 : d != null ? (i + d + list.length) % list.length : null;\n                if (to == null) return;\n                e.preventDefault();\n                const next = e.currentTarget.parentElement?.children[to] as HTMLButtonElement | undefined;\n                next?.focus();\n                next?.click();\n              }}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/* ---------- Pointer and keys ---------- */\n\n/** Press and drag on an area or a slider: the pointer is captured, so dragging past the card keeps working */\nfunction drag(ref: { current: HTMLElement | null }, to: (x: number, y: number) => void, area = false) {\n  const at = (e: ReactPointerEvent) => {\n    const el = ref.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    // A slider's knob stops half its track's height from each end; the area's thumb reaches the corners\n    const inset = area ? 0 : r.height / 2;\n    const x = clamp((e.clientX - r.left - inset) / Math.max(1, r.width - inset * 2), 0, 1);\n    const y = clamp((e.clientY - r.top) / Math.max(1, r.height), 0, 1);\n    to(x, y);\n  };\n  return {\n    onPointerDown: (e: ReactPointerEvent) => {\n      if (e.button !== 0) return;\n      ref.current?.setPointerCapture(e.pointerId);\n      ref.current?.focus({ preventScroll: true });\n      e.preventDefault();\n      at(e);\n    },\n    onPointerMove: (e: ReactPointerEvent) => {\n      if (ref.current?.hasPointerCapture(e.pointerId)) at(e);\n    },\n    onPointerUp: (e: ReactPointerEvent) => {\n      if (ref.current?.hasPointerCapture(e.pointerId)) ref.current.releasePointerCapture(e.pointerId);\n    },\n  };\n}\n\n/** Arrows ±1, with Shift ±10, Page Up / Down ±10, Home and End – the new value, or null for any other key */\nfunction keyStep(e: ReactKeyboardEvent, now: number, max: number) {\n  const step = e.shiftKey ? 10 : 1;\n  const next: Record<string, number> = {\n    ArrowRight: now + step,\n    ArrowUp: now + step,\n    ArrowLeft: now - step,\n    ArrowDown: now - step,\n    PageUp: now + 10,\n    PageDown: now - 10,\n    Home: 0,\n    End: max,\n  };\n  if (!(e.key in next)) return null;\n  e.preventDefault();\n  return clamp(next[e.key], 0, max);\n}\n\nfunction useControllable<T>(value: T | undefined, initial: T, onChange?: (v: T) => void) {\n  const [own, setOwn] = useState(initial);\n  const controlled = value !== undefined;\n  const set = (v: T) => {\n    if (!controlled) setOwn(v);\n    onChange?.(v);\n  };\n  return [controlled ? value : own, set] as const;\n}\n\n/* ---------- Placing the card ---------- */\n\n/** Under the button, or over it when there is more room there; the edge it lines up with switches when it would stick out */\nfunction place(el: HTMLElement, r: DOMRect, side: \"top\" | \"bottom\", align: \"start\" | \"end\") {\n  const pad = 8;\n  const gap = 6;\n  const vw = document.documentElement.clientWidth || window.innerWidth;\n  const vh = window.innerHeight;\n  el.style.maxHeight = \"\";\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const room = { bottom: vh - r.bottom - gap - pad, top: r.top - gap - pad };\n  let s = side;\n  if (room[s] < h && room[s === \"top\" ? \"bottom\" : \"top\"] > room[s]) s = s === \"top\" ? \"bottom\" : \"top\";\n  const fit = Math.min(h, Math.max(160, room[s]));\n  const top = s === \"bottom\" ? r.bottom + gap : r.top - gap - fit;\n  let a = align;\n  let left = a === \"start\" ? r.left : r.right - w;\n  if (a === \"start\" && left + w > vw - pad && r.right - w >= pad) (a = \"end\"), (left = r.right - w);\n  else if (a === \"end\" && left < pad && r.left + w <= vw - pad) (a = \"start\"), (left = r.left);\n  left = Math.min(Math.max(pad, left), vw - pad - w);\n  if (h > fit) el.style.maxHeight = `${fit}px`;\n  el.style.top = `${Math.round(top)}px`;\n  el.style.left = `${Math.round(left)}px`;\n  el.dataset.side = s;\n  el.dataset.align = a;\n}\n\n/* ---------- Color math ---------- */\n\ntype Rgba = { r: number; g: number; b: number; a: number };\ntype Hsva = { h: number; s: number; v: number; a: number };\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\nconst hex2 = (n: number) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, \"0\");\nconst COMPLETE = /^(#?([0-9a-f]{6}|[0-9a-f]{8})|rgba?\\(.*\\))$/i;\nconst NUM = /^-?(\\d+\\.?\\d*|\\.\\d+)%?$/;\n\n/** \"#0190fe\", \"0190FE\", \"#09f\", \"#0190fecc\", \"rgb(1, 144, 254)\", \"rgb(1 144 254 / 80%)\", \"rgba(1,144,254,.8)\" – or null */\nfunction parse(input: string): Rgba | null {\n  const s = String(input ?? \"\").trim().toLowerCase();\n  const h = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s)?.[1];\n  if (h) {\n    const full = h.length <= 4 ? [...h].map((ch) => ch + ch).join(\"\") : h;\n    const n = (i: number) => parseInt(full.slice(i, i + 2), 16);\n    return { r: n(0), g: n(2), b: n(4), a: full.length === 8 ? n(6) / 255 : 1 };\n  }\n  const body = /^rgba?\\(([^)]*)\\)$/.exec(s)?.[1];\n  if (body == null) return null;\n  const parts = body.trim().split(/\\s*[,/]\\s*|\\s+/).filter(Boolean);\n  if ((parts.length !== 3 && parts.length !== 4) || !parts.every((p) => NUM.test(p))) return null;\n  const ch = (p: string) => clamp(Math.round(p.endsWith(\"%\") ? parseFloat(p) * 2.55 : parseFloat(p)), 0, 255);\n  const a = parts[3] == null ? 1 : clamp(parts[3].endsWith(\"%\") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]), 0, 1);\n  return { r: ch(parts[0]), g: ch(parts[1]), b: ch(parts[2]), a };\n}\n\n/** Lowercase #rrggbb – with alpha on and below 100%, #rrggbbaa */\nconst format = (c: Rgba, withAlpha: boolean) => `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${withAlpha && c.a < 1 ? hex2(c.a * 255) : \"\"}`;\nconst paint = (c: Rgba) => `rgb(${Math.round(c.r)} ${Math.round(c.g)} ${Math.round(c.b)} / ${c.a})`;\n\nfunction toRgba({ h, s, v, a }: Hsva): Rgba {\n  const f = (n: number) => {\n    const k = (n + h / 60) % 6;\n    return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255);\n  };\n  return { r: f(5), g: f(3), b: f(1), a };\n}\n\n/** Grays have no hue (and black no saturation) – those keep what the picker had, so the thumbs don't jump */\nfunction toHsva({ r, g, b, a }: Rgba, keepH: number, alpha?: boolean, keepS = 0): Hsva {\n  const R = r / 255;\n  const G = g / 255;\n  const B = b / 255;\n  const max = Math.max(R, G, B);\n  const d = max - Math.min(R, G, B);\n  let h = keepH;\n  if (d > 0) {\n    h = max === R ? ((G - B) / d) % 6 : max === G ? (B - R) / d + 2 : (R - G) / d + 4;\n    h = (h * 60 + 360) % 360;\n  }\n  return { h, s: max === 0 ? keepS : d / max, v: max, a: alpha ? a : 1 };\n}\n\nconst hueName = (h: number) =>\n  h < 15 ? \"red\" : h < 45 ? \"orange\" : h < 70 ? \"yellow\" : h < 165 ? \"green\" : h < 195 ? \"cyan\" : h < 255 ? \"blue\" : h < 290 ? \"purple\" : h < 335 ? \"pink\" : \"red\";\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-clp (see Theming in the docs) */\n.ui-clp {\n  --ui-clp-ink: var(--aui-ink, #171717);\n  --ui-clp-strong: var(--aui-ink, #171717);\n  --ui-clp-muted: var(--aui-muted, #8f8f8f);\n  --ui-clp-surface: var(--aui-surface, #ffffff);\n  --ui-clp-overlay: var(--aui-overlay, var(--aui-surface, #ffffff));\n  --ui-clp-hover: rgba(0, 0, 0, 0.04);\n  --ui-clp-hairline: rgba(0, 0, 0, 0.08);\n  --ui-clp-check: #e6e6e6;\n  --ui-clp-shadow: 0 0 0 1px var(--aui-border, #e6e6e8), 0 1px 2px rgba(0, 0, 0, 0.04);\n  --ui-clp-shadow-hover: 0 0 0 1px var(--aui-border-strong, #d4d4d8), 0 1px 2px rgba(0, 0, 0, 0.05);\n  --ui-clp-edge-focus: 0 0 0 1px var(--aui-border-strong, #b4b4bb), 0 1px 2px rgba(0, 0, 0, 0.05);\n  --ui-clp-danger-edge: #f04438;\n  --ui-clp-pop-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(0, 0, 0, 0.06), 0 18px 40px -12px rgba(0, 0, 0, 0.18), 0 4px 12px -4px rgba(0, 0, 0, 0.06));\n  --ui-clp-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-clp-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  --ui-clp-mono: ui-monospace, \"SF Mono\", SFMono-Regular, Menlo, Consolas, monospace;\n\n  --h: 32px;\n  --radius: var(--aui-radius, 9px);\n\n  position: relative;\n  display: inline-flex;\n  min-width: 0;\n  vertical-align: middle;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  line-height: 20px;\n  color: var(--ui-clp-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-clp[data-size=\"sm\"] { --h: 28px; --radius: calc(var(--aui-radius, 9px) - 1px); }\n.ui-clp--inline { display: block; }\n.ui-clp *, .ui-clp *::before, .ui-clp *::after { box-sizing: border-box; }\n.ui-clp__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n\n/* The button: white with a 1px edge, the swatch and the hex in a fixed-width face, so it never jumps */\n.ui-clp__trigger {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  min-width: 0;\n  height: var(--h);\n  margin: 0;\n  padding: 0 12px 0 8px;\n  border: 0;\n  border-radius: var(--radius);\n  background: var(--ui-clp-surface);\n  box-shadow: var(--ui-clp-shadow);\n  font: inherit;\n  color: var(--ui-clp-strong);\n  cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n  transition: box-shadow 140ms ease;\n}\n.ui-clp[data-size=\"sm\"] .ui-clp__trigger { padding: 0 10px 0 6px; }\n.ui-clp__trigger[data-bare] { justify-content: center; width: var(--h); padding: 0; }\n.ui-clp__trigger:hover:not(:disabled), .ui-clp__trigger[aria-expanded=\"true\"] { box-shadow: var(--ui-clp-shadow-hover); }\n.ui-clp__trigger:disabled { opacity: 0.5; cursor: not-allowed; }\n.ui-clp__trigger:focus-visible { outline: 2px solid var(--ui-clp-focus); outline-offset: 2px; }\n\n/* A swatch over a checkerboard, so a see-through color reads as see-through */\n.ui-clp__swatch, .ui-clp__preview {\n  flex: none;\n  background: linear-gradient(var(--ui-clp-c), var(--ui-clp-c)), repeating-conic-gradient(var(--ui-clp-check) 0 25%, #fff 0 50%) 0 0 / 8px 8px;\n  box-shadow: inset 0 0 0 0.5px var(--ui-clp-hairline);\n}\n.ui-clp__swatch { width: 18px; height: 18px; border-radius: 6px; }\n.ui-clp[data-size=\"sm\"] .ui-clp__swatch { width: 16px; height: 16px; border-radius: 5px; }\n.ui-clp__value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ui-clp-mono); font-size: 12px; font-weight: 500; letter-spacing: 0; }\n.ui-clp__alpha { color: var(--ui-clp-muted); }\n\n/* The card in the top layer: a 1px edge and a deeper drop */\n.ui-clp__pop {\n  position: fixed;\n  inset: auto;\n  z-index: 1000;\n  box-sizing: border-box;\n  max-width: calc(100vw - 16px);\n  max-height: calc(100vh - 16px);\n  margin: 0;\n  padding: 14px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-lg, 10px) + 6px);\n  background: var(--ui-clp-overlay);\n  box-shadow: var(--ui-clp-pop-shadow);\n  color: var(--ui-clp-ink);\n  font: inherit;\n  overflow: auto;\n  overscroll-behavior: contain;\n  transform-origin: top left;\n  animation: ui-clp-in 220ms var(--ui-clp-ease) both;\n}\n.ui-clp__pop::backdrop { display: none; }\n.ui-clp__pop[data-align=\"end\"] { transform-origin: top right; }\n.ui-clp__pop[data-side=\"top\"] { transform-origin: bottom left; animation-name: ui-clp-in-up; }\n.ui-clp__pop[data-side=\"top\"][data-align=\"end\"] { transform-origin: bottom right; }\n\n.ui-clp__panel { display: grid; gap: 14px; width: 236px; max-width: 100%; }\n.ui-clp--inline .ui-clp__panel { width: 100%; }\n\n/* Saturation left to right, brightness bottom to top, over the hue */\n.ui-clp__area {\n  position: relative;\n  height: 152px;\n  border-radius: calc(var(--radius) + 1px);\n  background: linear-gradient(to top, #000, rgba(0, 0, 0, 0)), linear-gradient(to right, #fff, rgba(255, 255, 255, 0)), var(--ui-clp-hue);\n  box-shadow: inset 0 0 0 1px var(--ui-clp-hairline);\n  cursor: crosshair;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n}\n.ui-clp__thumb, .ui-clp__knob {\n  position: absolute;\n  border-radius: 50%;\n  pointer-events: none;\n  box-shadow: 0 0 0 2px #fff, 0 0 0 2.5px rgba(0, 0, 0, 0.18), 0 1px 4px 1px rgba(0, 0, 0, 0.24);\n}\n.ui-clp__thumb { width: 12px; height: 12px; margin: -6px 0 0 -6px; background: var(--ui-clp-solid); }\n\n/* The sliders: a 12px rail, the knob stops half its height from each end */\n.ui-clp__slider { position: relative; height: 12px; border-radius: 999px; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; box-shadow: inset 0 0 0 1px var(--ui-clp-hairline); }\n.ui-clp__slider--hue { background: linear-gradient(to right, #f00 0%, #ff0 16.67%, #0f0 33.33%, #0ff 50%, #00f 66.67%, #f0f 83.33%, #f00 100%); }\n.ui-clp__slider--alpha { background: linear-gradient(to right, transparent, var(--ui-clp-solid)), repeating-conic-gradient(var(--ui-clp-check) 0 25%, #fff 0 50%) 0 0 / 8px 8px; }\n.ui-clp__knob { top: 50%; left: calc(6px + (100% - 12px) * var(--p)); width: 16px; height: 16px; margin: -8px 0 0 -8px; background: transparent; }\n.ui-clp__slider--alpha .ui-clp__knob { background: linear-gradient(var(--ui-clp-c), var(--ui-clp-c)), repeating-conic-gradient(var(--ui-clp-check) 0 25%, #fff 0 50%) 0 0 / 6px 6px; }\n.ui-clp__area:focus-visible, .ui-clp__slider:focus-visible { outline: 2px solid var(--ui-clp-focus); outline-offset: 2px; }\n\n/* The hex field: no ring, only its edge firms up */\n.ui-clp__fields { display: flex; align-items: center; gap: 8px; min-width: 0; }\n.ui-clp__preview { width: 32px; height: 32px; border-radius: var(--radius); }\n.ui-clp__hex {\n  display: flex;\n  flex: 1 1 auto;\n  align-items: center;\n  gap: 2px;\n  min-width: 0;\n  height: 32px;\n  padding: 0 10px;\n  border-radius: var(--radius);\n  background: var(--ui-clp-surface);\n  box-shadow: var(--ui-clp-shadow);\n  cursor: text;\n  transition: box-shadow 140ms ease;\n}\n.ui-clp__hex:hover { box-shadow: var(--ui-clp-shadow-hover); }\n.ui-clp__hex:focus-within { box-shadow: var(--ui-clp-edge-focus); }\n.ui-clp__hex[data-invalid] { box-shadow: 0 0 0 1px var(--ui-clp-danger-edge); }\n.ui-clp__hash { flex: none; font-family: var(--ui-clp-mono); font-size: 12px; color: var(--ui-clp-muted); }\n.ui-clp__input {\n  flex: 1 1 auto;\n  width: 0;\n  min-width: 0;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  outline: 0;\n  background: transparent;\n  font-family: var(--ui-clp-mono);\n  font-size: 12px;\n  font-weight: 500;\n  color: var(--ui-clp-strong);\n}\n.ui-clp__pct { flex: none; width: 36px; font-size: 12px; line-height: 16px; text-align: end; font-variant-numeric: tabular-nums; color: var(--ui-clp-muted); }\n\n/* Presets: round swatches; the chosen one gets a ring in its own color */\n.ui-clp__presets { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px; }\n.ui-clp__preset {\n  flex: none;\n  width: 22px;\n  height: 22px;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 50%;\n  background: linear-gradient(var(--ui-clp-p), var(--ui-clp-p)), repeating-conic-gradient(var(--ui-clp-check) 0 25%, #fff 0 50%) 0 0 / 8px 8px;\n  box-shadow: inset 0 0 0 0.5px var(--ui-clp-hairline);\n  cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n  transition: transform 140ms var(--ui-clp-ease), box-shadow 140ms ease;\n}\n.ui-clp__preset:hover { transform: scale(1.1); }\n.ui-clp__preset[aria-checked=\"true\"] { box-shadow: inset 0 0 0 0.5px var(--ui-clp-hairline), 0 0 0 2px var(--ui-clp-overlay), 0 0 0 3.5px var(--ui-clp-p); }\n.ui-clp__preset:focus-visible { outline: 2px solid var(--ui-clp-focus); outline-offset: 2px; }\n\n@keyframes ui-clp-in { from { opacity: 0; transform: scale(0.96) translateY(-4px); } }\n@keyframes ui-clp-in-up { from { opacity: 0; transform: scale(0.96) translateY(4px); } }\n\n/* Touch screens: a bigger button, thumbs and swatches, and 16px in the field so iOS doesn't zoom in */\n@media (pointer: coarse) {\n  .ui-clp { --h: 40px; }\n  .ui-clp[data-size=\"sm\"] { --h: 36px; }\n  .ui-clp__area { height: 176px; }\n  .ui-clp__slider { height: 14px; }\n  .ui-clp__knob { left: calc(7px + (100% - 14px) * var(--p)); width: 20px; height: 20px; margin: -10px 0 0 -10px; }\n  .ui-clp__thumb { width: 16px; height: 16px; margin: -8px 0 0 -8px; }\n  .ui-clp__panel { gap: 14px; }\n  .ui-clp__hex, .ui-clp__preview { height: 40px; }\n  .ui-clp__preview { width: 40px; }\n  .ui-clp__input { font-size: 16px; }\n  .ui-clp__presets { gap: 10px; }\n  .ui-clp__preset { width: 28px; height: 28px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-clp__pop { animation-duration: 1ms; }\n  .ui-clp__preset { transition: none; }\n  .ui-clp__preset:hover { transform: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-clp {\n  --ui-clp-ink: var(--aui-ink, #ededed);\n  --ui-clp-strong: var(--aui-ink, #ededed);\n  --ui-clp-muted: var(--aui-muted, #8b8b92);\n  --ui-clp-surface: var(--aui-surface, #1c1c1e);\n  --ui-clp-overlay: var(--aui-overlay, var(--aui-surface, #19191b));\n  --ui-clp-hover: rgba(255, 255, 255, 0.06);\n  --ui-clp-hairline: rgba(255, 255, 255, 0.1);\n  --ui-clp-shadow: 0 0 0 1px var(--aui-border, #2a2a2e);\n  --ui-clp-shadow-hover: 0 0 0 1px var(--aui-border-strong, #3a3a40);\n  --ui-clp-edge-focus: 0 0 0 1px var(--aui-border-strong, #4a4a52);\n  --ui-clp-danger-edge: rgba(255, 99, 105, 0.8);\n  --ui-clp-pop-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(255, 255, 255, 0.06), 0 18px 40px -12px rgba(0, 0, 0, 0.6));\n  --ui-clp-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  color-scheme: dark;\n}\n`;\n"}]}