{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "type": "registry:ui",
  "title": "Color Picker",
  "description": "Drag, type or sample a color; hue survives grays and recents remember.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/color-picker.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronDown, ChevronsUpDown } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ───────────────────────── color math ───────────────────────── */\n// Colors here are the data being edited, not the theme, so they are written as values.\n\nexport type Rgba = { r: number; g: number; b: number; a: number };\nexport type 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 round = (n: number, p = 0) => Math.round(n * 10 ** p) / 10 ** p;\n\nexport function hsvToRgb({ h, s, v, a }: Hsva): Rgba {\n  const f = (n: number) => {\n    const k = (n + h / 60) % 6;\n    return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));\n  };\n  return { r: Math.round(f(5) * 255), g: Math.round(f(3) * 255), b: Math.round(f(1) * 255), a };\n}\n\n/** Converts to HSV, keeping the previous hue (and saturation) where the color has none, so grays and black don't reset the hue slider. */\nexport function rgbToHsv({ r, g, b, a }: Rgba, prev?: Hsva): Hsva {\n  const R = r / 255, G = g / 255, B = b / 255;\n  const max = Math.max(R, G, B), min = Math.min(R, G, B), d = max - min;\n  let h = 0;\n  if (d) h = max === R ? ((G - B) / d) % 6 : max === G ? (B - R) / d + 2 : (R - G) / d + 4;\n  h = (h * 60 + 360) % 360;\n  const v = max;\n  const s = max ? d / max : 0;\n  return {\n    h: d === 0 && prev ? prev.h : h,\n    s: v === 0 && prev ? prev.s : s,\n    v,\n    a,\n  };\n}\n\nconst hex2 = (n: number) => Math.round(n).toString(16).padStart(2, \"0\").toUpperCase();\nexport function toHex({ r, g, b, a }: Rgba) {\n  return `#${hex2(r)}${hex2(g)}${hex2(b)}${a < 1 ? hex2(a * 255) : \"\"}`;\n}\n\nfunction rgbToHsl({ r, g, b }: Rgba) {\n  const R = r / 255, G = g / 255, B = b / 255;\n  const max = Math.max(R, G, B), min = Math.min(R, G, B), l = (max + min) / 2, d = max - min;\n  const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));\n  let h = 0;\n  if (d) h = max === R ? ((G - B) / d) % 6 : max === G ? (B - R) / d + 2 : (R - G) / d + 4;\n  return { h: Math.round((h * 60 + 360) % 360), s: Math.round(s * 100), l: Math.round(l * 100) };\n}\n\nfunction hslToRgb(h: number, s: number, l: number, a: number): Rgba {\n  s /= 100; l /= 100;\n  const k = (n: number) => (n + h / 30) % 12;\n  const c = s * Math.min(l, 1 - l);\n  const f = (n: number) => l - c * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));\n  return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a };\n}\n\n/** Reads #rgb, #rgba, #rrggbb, #rrggbbaa (with or without #), rgb()/rgba() and hsl()/hsla(). */\nexport function parseColor(input: string): Rgba | null {\n  const s = input.trim().toLowerCase();\n  const hex = s.replace(/^#/, \"\");\n  if (/^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(hex)) {\n    const full = hex.length <= 4 ? [...hex].map((c) => c + c).join(\"\") : hex;\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 ? round(n(6) / 255, 3) : 1 };\n  }\n  const fn = s.match(/^(rgba?|hsla?)\\(([^)]+)\\)$/);\n  if (!fn) return null;\n  const parts = fn[2].split(/[\\s,/]+/).filter(Boolean);\n  if (parts.length < 3) return null;\n  const num = (p: string, scale: number) => (p.endsWith(\"%\") ? (parseFloat(p) / 100) * scale : parseFloat(p));\n  const a = parts[3] !== undefined ? clamp(num(parts[3], 1), 0, 1) : 1;\n  if (fn[1].startsWith(\"rgb\")) {\n    const [r, g, b] = parts.slice(0, 3).map((p) => clamp(Math.round(num(p, 255)), 0, 255));\n    return [r, g, b].some(Number.isNaN) ? null : { r, g, b, a };\n  }\n  const [h, sat, l] = [parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])];\n  return [h, sat, l].some(Number.isNaN) ? null : hslToRgb(((h % 360) + 360) % 360, clamp(sat, 0, 100), clamp(l, 0, 100), a);\n}\n\nconst FALLBACK: Hsva = { h: 0, s: 0, v: 0, a: 1 };\nconst toHsva = (value: string, prev?: Hsva) => {\n  const rgb = parseColor(value);\n  return rgb ? rgbToHsv(rgb, prev) : prev ?? FALLBACK;\n};\nconst hsvaHex = (c: Hsva) => toHex(hsvToRgb(c));\nconst cssOf = (c: Hsva) => {\n  const { r, g, b, a } = hsvToRgb(c);\n  return `rgb(${r} ${g} ${b} / ${a})`;\n};\n\n/* ───────────────────────── small pieces ───────────────────────── */\n\n// A checkerboard drawn from theme lines, so transparency reads in both themes.\nconst checker =\n  \"[background-image:conic-gradient(var(--line-2)_25%,transparent_0_50%,var(--line-2)_0_75%,transparent_0)] [background-size:8px_8px] bg-raised\";\n\n// Double ring: inner in the surface color, outer in the text color, so a thumb reads on any hue in either theme.\nconst thumbRing = \"border-2 border-raised shadow-[0_0_0_1px_color-mix(in_oklab,var(--fg)_45%,transparent),0_2px_6px_-1px_rgb(0_0_0/0.35)]\";\n\nconst EYE_DROPPER = () => \"EyeDropper\" in window;\nconst useEyeDropperSupport = () => useSyncExternalStore(() => () => {}, EYE_DROPPER, () => false);\n\ntype EyeDropperCtor = new () => { open: (o?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }> };\n\nfunction PipetteIcon() {\n  return (\n    <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M9.75 3.9 11 2.65a1.6 1.6 0 0 1 2.35 2.35L12.1 6.25\" />\n      <path d=\"m8.6 5.05 2.35 2.35\" />\n      <path d=\"M9.8 6.2 4.4 11.6 3 13l-.25-.25L4.15 11.3 9.55 5.95\" />\n    </svg>\n  );\n}\n\n/* ───────────────────────── the panel ───────────────────────── */\n\ntype Format = \"hex\" | \"rgb\" | \"hsl\";\nconst FORMATS: Format[] = [\"hex\", \"rgb\", \"hsl\"];\n\nexport type ColorPickerPanelProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** A hex string (#RRGGBB, or #RRGGBBAA when translucent). Accepts any CSS rgb/hsl string as input. */\n  value?: string;\n  defaultValue?: string;\n  /** Fires continuously while dragging, with an uppercase hex string. */\n  onValueChange?: (hex: string) => void;\n  /** Fires once when a change settles: pointer up, Enter or blur in a field, a swatch, the eyedropper. */\n  onValueCommit?: (hex: string) => void;\n  /** Show the opacity slider and field. */\n  alpha?: boolean;\n  /** A fixed palette shown under the controls (brand colors, a theme). */\n  swatches?: string[];\n  /** Colors committed recently, newest first. Controlled with onRecentChange, or kept internally. */\n  recent?: string[];\n  defaultRecent?: string[];\n  onRecentChange?: (recent: string[]) => void;\n  recentLimit?: number;\n  disabled?: boolean;\n};\n\nexport function ColorPickerPanel({\n  value: valueProp,\n  defaultValue = \"#6E56CF\",\n  onValueChange,\n  onValueCommit,\n  alpha = true,\n  swatches,\n  recent: recentProp,\n  defaultRecent = [],\n  onRecentChange,\n  recentLimit = 8,\n  disabled = false,\n  className,\n  ...rest\n}: ColorPickerPanelProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [recent, setRecent] = useControllableState({ value: recentProp, defaultValue: defaultRecent, onChange: onRecentChange });\n\n  // HSV is the working model: it remembers hue through grays, which a hex string can't.\n  const [hsva, setHsva] = useState(() => toHsva(value));\n  const [seen, setSeen] = useState(value);\n  if (value !== seen) {\n    setSeen(value);\n    if (value.toUpperCase() !== hsvaHex(hsva)) setHsva(toHsva(value, hsva));\n  }\n\n  const [format, setFormat] = useState<Format>(\"hex\");\n  const [dragging, setDragging] = useState(false);\n  const [picking, setPicking] = useState(false);\n  const [pulse, setPulse] = useState(0);\n  const sessionHasRecent = useRef(false);\n  const eyeDropper = useEyeDropperSupport();\n\n  const hex = hsvaHex(hsva);\n  const rgb = hsvToRgb(hsva);\n  const opaque = cssOf({ ...hsva, a: 1 });\n\n  const apply = (next: Hsva) => {\n    setHsva(next);\n    const h = hsvaHex(next);\n    setSeen(h);\n    setValue(h);\n  };\n\n  // The first commit of an editing session adds a recent color; later ones update it in place.\n  const commit = (next: Hsva = hsva, { remember = true }: { remember?: boolean } = {}) => {\n    const h = hsvaHex(next);\n    onValueCommit?.(h);\n    if (!remember) return;\n    const rest = recent.filter((c, i) => c.toUpperCase() !== h && !(i === 0 && sessionHasRecent.current));\n    setRecent([h, ...rest].slice(0, recentLimit));\n    sessionHasRecent.current = true;\n  };\n\n  const choose = (c: string) => {\n    const next = toHsva(c, hsva);\n    apply(next);\n    commit(next, { remember: false });\n    setPulse((p) => p + 1);\n  };\n\n  /* Saturation / value area */\n  const fromPointer = (e: React.PointerEvent) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    return { ...hsva, s: clamp((e.clientX - r.left) / r.width, 0, 1), v: clamp(1 - (e.clientY - r.top) / r.height, 0, 1) };\n  };\n  const areaKey = (e: React.KeyboardEvent) => {\n    const step = e.shiftKey ? 0.1 : 0.01;\n    const d: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };\n    const move = d[e.key];\n    if (!move) return;\n    e.preventDefault();\n    const next = { ...hsva, s: clamp(hsva.s + move[0], 0, 1), v: clamp(hsva.v + move[1], 0, 1) };\n    apply(next);\n    commit(next);\n  };\n\n  const pick = async () => {\n    const Ctor = (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper;\n    if (!Ctor || picking) return;\n    setPicking(true);\n    try {\n      const { sRGBHex } = await new Ctor().open();\n      const rgbPicked = parseColor(sRGBHex);\n      if (rgbPicked) {\n        const next = rgbToHsv({ ...rgbPicked, a: hsva.a }, hsva);\n        apply(next);\n        commit(next);\n        setPulse((p) => p + 1);\n      }\n    } catch {\n      // Escape or a click outside the page cancels the pick; nothing to report.\n    } finally {\n      setPicking(false);\n    }\n  };\n\n  return (\n    <div data-disabled={disabled || undefined} className={cn(\"flex w-64 flex-col gap-3 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\", className)} {...rest}>\n      {/* Saturation (x) and brightness (y) for the current hue. */}\n      <div\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label=\"Saturation and brightness\"\n        aria-roledescription=\"2D slider\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(hsva.s * 100)}\n        aria-valuetext={`Saturation ${Math.round(hsva.s * 100)}%, brightness ${Math.round(hsva.v * 100)}%`}\n        data-dragging={dragging || undefined}\n        onPointerDown={(e) => {\n          if (e.button !== 0) return;\n          e.currentTarget.setPointerCapture(e.pointerId);\n          e.currentTarget.focus({ preventScroll: true });\n          setDragging(true);\n          apply(fromPointer(e));\n        }}\n        onPointerMove={(e) => dragging && apply(fromPointer(e))}\n        onPointerUp={(e) => {\n          if (!dragging) return;\n          setDragging(false);\n          e.currentTarget.releasePointerCapture(e.pointerId);\n          commit(fromPointer(e));\n        }}\n        onPointerCancel={() => setDragging(false)}\n        onKeyDown={areaKey}\n        className=\"group/area relative h-40 w-full cursor-crosshair touch-none select-none rounded-lg outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n        style={{ background: `linear-gradient(to top, hsl(0 0% 0%), transparent), linear-gradient(to right, hsl(0 0% 100%), hsl(${hsva.h} 100% 50%))` }}\n      >\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 rounded-lg shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_10%,transparent)]\" />\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute size-0\"\n          style={{ left: `${hsva.s * 100}%`, top: `${(1 - hsva.v) * 100}%` }}\n        >\n          <motion.div\n            className={cn(\"absolute -left-2 -top-2 size-4 rounded-full\", thumbRing)}\n            style={{ backgroundColor: opaque }}\n            animate={{ scale: dragging && !reduce ? 1.25 : 1 }}\n            transition={spring.snappy}\n          />\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-2.5\">\n        {eyeDropper && (\n          <button\n            type=\"button\"\n            onClick={pick}\n            aria-label=\"Pick a color from the screen\"\n            aria-busy={picking || undefined}\n            data-picking={picking || undefined}\n            className=\"grid size-8 shrink-0 place-items-center rounded-lg border border-line-2 bg-raised text-fg-2 outline-none transition-[background-color,border-color,color,scale] duration-150 hover:border-fg-4 hover:text-fg active:scale-[0.92] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 data-[picking]:border-fg-4 data-[picking]:bg-fg/5 data-[picking]:text-fg\"\n          >\n            <PipetteIcon />\n          </button>\n        )}\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2.5\">\n          <ChannelSlider\n            label=\"Hue\"\n            max={360}\n            value={Math.round(hsva.h)}\n            onChange={(h) => apply({ ...hsva, h })}\n            onCommit={(h) => commit({ ...hsva, h })}\n            valueText={(v) => `${v}°`}\n            trackStyle={{ background: \"linear-gradient(to right, hsl(0 100% 50%), hsl(60 100% 50%), hsl(120 100% 50%), hsl(180 100% 50%), hsl(240 100% 50%), hsl(300 100% 50%), hsl(360 100% 50%))\" }}\n            thumbColor={`hsl(${hsva.h} 100% 50%)`}\n            disabled={disabled}\n            reduce={!!reduce}\n          />\n          {alpha && (\n            <ChannelSlider\n              label=\"Opacity\"\n              max={100}\n              value={Math.round(hsva.a * 100)}\n              onChange={(a) => apply({ ...hsva, a: a / 100 })}\n              onCommit={(a) => commit({ ...hsva, a: a / 100 })}\n              valueText={(v) => `${v}%`}\n              checker\n              trackStyle={{ backgroundImage: `linear-gradient(to right, transparent, ${opaque})` }}\n              thumbColor={cssOf(hsva)}\n              disabled={disabled}\n              reduce={!!reduce}\n            />\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-1.5\">\n        <button\n          type=\"button\"\n          onClick={() => setFormat(FORMATS[(FORMATS.indexOf(format) + 1) % FORMATS.length])}\n          aria-label={`Color format: ${format.toUpperCase()}. Switch format`}\n          className=\"group/fmt flex h-7 w-[52px] shrink-0 items-center justify-between rounded-md pl-1.5 pr-1 font-mono text-[11px] text-fg-2 outline-none transition-[background-color,color,scale] duration-150 hover:bg-fg/5 hover:text-fg active:scale-[0.95] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\"\n        >\n          <span className=\"relative grid overflow-hidden\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={format}\n                className=\"col-start-1 row-start-1 uppercase\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n                transition={{ duration: 0.18, ease: ease.out }}\n              >\n                {format}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <ChevronsUpDown size={12} className=\"text-fg-4 transition-colors group-hover/fmt:text-fg-3\" />\n        </button>\n\n        <div className=\"flex min-w-0 flex-1 gap-1\">\n          {format === \"hex\" ? (\n            <HexField key=\"hex\" hex={hex} alpha={alpha} onApply={(c) => apply(toHsva(c, hsva))} onCommit={() => commit()} disabled={disabled} />\n          ) : format === \"rgb\" ? (\n            <>\n              {([\"r\", \"g\", \"b\"] as const).map((k) => (\n                <ChannelInput key={k} label={k.toUpperCase()} value={rgb[k]} max={255} onApply={(n) => apply(rgbToHsv({ ...rgb, [k]: n }, hsva))} onCommit={() => commit()} disabled={disabled} />\n              ))}\n            </>\n          ) : (\n            (() => {\n              const hsl = rgbToHsl(rgb);\n              return ([\"h\", \"s\", \"l\"] as const).map((k) => (\n                <ChannelInput\n                  key={k}\n                  label={k.toUpperCase()}\n                  value={k === \"h\" ? Math.round(hsva.h) : hsl[k]}\n                  max={k === \"h\" ? 360 : 100}\n                  onApply={(n) => {\n                    const next = { ...hsl, h: Math.round(hsva.h), [k]: n };\n                    apply(rgbToHsv(hslToRgb(next.h, next.s, next.l, hsva.a), { ...hsva, h: next.h }));\n                  }}\n                  onCommit={() => commit()}\n                  disabled={disabled}\n                />\n              ));\n            })()\n          )}\n          {alpha && (\n            <ChannelInput label=\"A\" suffix=\"%\" value={Math.round(hsva.a * 100)} max={100} onApply={(n) => apply({ ...hsva, a: n / 100 })} onCommit={() => commit()} disabled={disabled} className=\"w-[54px] flex-none\" />\n          )}\n        </div>\n      </div>\n\n      {(swatches?.length || recent.length > 0) && (\n        <div className=\"flex flex-col gap-2.5 border-t border-line pt-3\">\n          {swatches?.length ? <SwatchRow id={`${uid}-sw`} label=\"Swatches\" colors={swatches} current={hex} onChoose={choose} reduce={!!reduce} /> : null}\n          {recent.length > 0 && <SwatchRow id={`${uid}-rc`} label=\"Recent\" colors={recent} current={hex} onChoose={choose} reduce={!!reduce} animated />}\n        </div>\n      )}\n\n      {/* Pulse the live value for screen readers only when it settles, not on every drag frame. */}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\" key={pulse}>\n        {pulse ? `Color set to ${hex}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction ChannelSlider({\n  label,\n  max,\n  value,\n  onChange,\n  onCommit,\n  valueText,\n  trackStyle,\n  thumbColor,\n  checker: withChecker,\n  disabled,\n  reduce,\n}: {\n  label: string;\n  max: number;\n  value: number;\n  onChange: (n: number) => void;\n  onCommit: (n: number) => void;\n  valueText: (n: number) => string;\n  trackStyle: React.CSSProperties;\n  thumbColor: string;\n  checker?: boolean;\n  disabled?: boolean;\n  reduce: boolean;\n}) {\n  return (\n    <Slider.Root\n      value={value}\n      min={0}\n      max={max}\n      disabled={disabled}\n      thumbAlignment=\"edge\"\n      onValueChange={(v) => onChange(v as number)}\n      onValueCommitted={(v) => onCommit(v as number)}\n      className=\"group/slider\"\n    >\n      <Slider.Control className=\"flex h-4 w-full cursor-pointer touch-none select-none items-center\">\n        <Slider.Track className={cn(\"relative h-3 w-full rounded-full\", withChecker && checker)}>\n          <div aria-hidden className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_10%,transparent)]\" style={trackStyle} />\n          <Slider.Thumb\n            aria-label={label}\n            getAriaValueText={(_, v) => valueText(v)}\n            className={cn(\n              \"size-4 rounded-full outline-none\",\n              thumbRing,\n              withChecker && checker,\n              \"transition-[scale] duration-150 ease-out has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-3\",\n              !reduce && \"data-[dragging]:scale-125 active:scale-125\",\n            )}\n          >\n            <span aria-hidden className=\"absolute inset-0 rounded-full\" style={{ background: thumbColor }} />\n          </Slider.Thumb>\n        </Slider.Track>\n      </Slider.Control>\n    </Slider.Root>\n  );\n}\n\n// Typing applies as soon as the text is a color; blur puts back the canonical form.\nfunction HexField({ hex, alpha, onApply, onCommit, disabled }: { hex: string; alpha: boolean; onApply: (c: string) => void; onCommit: () => void; disabled?: boolean }) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const shown = alpha ? hex : hex.slice(0, 7);\n  const text = draft ?? shown;\n  const invalid = draft !== null && !parseColor(draft);\n  return (\n    <label className=\"relative flex h-7 min-w-0 flex-1\">\n      <span className=\"sr-only\">Hex color</span>\n      <input\n        value={text}\n        disabled={disabled}\n        spellCheck={false}\n        autoComplete=\"off\"\n        aria-invalid={invalid || undefined}\n        onFocus={(e) => e.currentTarget.select()}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          if (parseColor(e.target.value)) onApply(e.target.value);\n        }}\n        onBlur={() => {\n          if (draft !== null && parseColor(draft)) onCommit();\n          setDraft(null);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            if (draft !== null && parseColor(draft)) onCommit();\n            setDraft(null);\n            e.currentTarget.select();\n          }\n        }}\n        className=\"h-full w-full min-w-0 rounded-md border border-line-2 bg-raised px-2 font-mono text-base uppercase tabular text-fg outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-fg-4 hover:border-fg-4 focus:border-fg-4 focus:ring-3 focus:ring-fg/10 aria-[invalid]:border-danger aria-[invalid]:focus:ring-danger/15 sm:text-[12px]\"\n      />\n    </label>\n  );\n}\n\nfunction ChannelInput({\n  label,\n  value,\n  max,\n  suffix,\n  onApply,\n  onCommit,\n  disabled,\n  className,\n}: {\n  label: string;\n  value: number;\n  max: number;\n  suffix?: string;\n  onApply: (n: number) => void;\n  onCommit: () => void;\n  disabled?: boolean;\n  className?: string;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const names: Record<string, string> = { R: \"Red\", G: \"Green\", B: \"Blue\", H: \"Hue\", S: \"Saturation\", L: \"Lightness\", A: \"Opacity\" };\n  const set = (n: number) => onApply(clamp(Math.round(n), 0, max));\n  return (\n    <label title={names[label] ?? label} className={cn(\"group/ch relative flex h-7 min-w-0 flex-1 items-center rounded-md border border-line-2 bg-raised transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\", className)}>\n      {/* The format button already says RGB or HSL, so the channel letters live in the accessible name and the tooltip. */}\n      <span className=\"sr-only\">{names[label] ?? label}</span>\n      <input\n        inputMode=\"numeric\"\n        value={draft ?? String(value)}\n        disabled={disabled}\n        onFocus={(e) => e.currentTarget.select()}\n        onChange={(e) => {\n          const t = e.target.value.replace(/[^\\d]/g, \"\").slice(0, 3);\n          setDraft(t);\n          if (t !== \"\") set(Number(t));\n        }}\n        onBlur={() => {\n          if (draft !== null) onCommit();\n          setDraft(null);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n            e.preventDefault();\n            setDraft(null);\n            set(value + (e.key === \"ArrowUp\" ? 1 : -1) * (e.shiftKey ? 10 : 1));\n          } else if (e.key === \"Enter\") {\n            setDraft(null);\n            onCommit();\n          }\n        }}\n        className={cn(\"h-full w-full min-w-0 bg-transparent font-mono text-base tabular text-fg outline-none sm:text-[12px]\", suffix ? \"pl-1.5 text-right\" : \"px-1 text-center\")}\n      />\n      {suffix && <span aria-hidden className=\"pointer-events-none pl-px pr-1.5 font-mono text-[11px] text-fg-4\">{suffix}</span>}\n    </label>\n  );\n}\n\nfunction SwatchRow({ id, label, colors, current, onChoose, reduce, animated }: { id: string; label: string; colors: string[]; current: string; onChoose: (c: string) => void; reduce: boolean; animated?: boolean }) {\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <span id={id} className=\"font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-4\">{label}</span>\n      <div role=\"group\" aria-labelledby={id} className=\"grid grid-cols-8 gap-1.5\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {colors.map((c) => {\n            const up = c.toUpperCase();\n            const on = hsvaHex(toHsva(up)) === current;\n            return (\n              <motion.button\n                key={up}\n                type=\"button\"\n                layout={animated && !reduce ? \"position\" : false}\n                initial={animated && !reduce ? { opacity: 0, scale: 0.6 } : false}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}\n                transition={spring.snappy}\n                onClick={() => onChoose(up)}\n                aria-label={up}\n                aria-pressed={on}\n                className={cn(\"group/sw relative aspect-square w-full rounded-md outline-none transition-[scale] duration-100 active:scale-[0.9] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\", checker)}\n              >\n                <span\n                  aria-hidden\n                  className=\"absolute inset-0 rounded-md shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_12%,transparent)] transition-[scale] duration-150 ease-out group-hover/sw:scale-[1.06]\"\n                  style={{ background: up }}\n                />\n                {on && (\n                  <motion.span\n                    layoutId={reduce ? undefined : `${id}-ring`}\n                    aria-hidden\n                    className=\"absolute -inset-[3px] rounded-[8px] border-[1.5px] border-fg\"\n                    transition={spring.snappy}\n                  />\n                )}\n              </motion.button>\n            );\n          })}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\n/* ───────────────────────── the popover ───────────────────────── */\n\nexport type ColorPickerProps = Omit<ColorPickerPanelProps, \"className\"> & {\n  /** A square swatch, or a field-shaped trigger that also shows the hex. */\n  variant?: \"swatch\" | \"field\";\n  /** Names the trigger for assistive tech, followed by the current value: \"Brand color, #6E56CF\". */\n  label?: string;\n  size?: \"sm\" | \"md\";\n  className?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n};\n\nexport function ColorPicker({\n  variant = \"swatch\",\n  label = \"Color\",\n  size = \"md\",\n  className,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"start\",\n  value: valueProp,\n  defaultValue = \"#6E56CF\",\n  onValueChange,\n  recent: recentProp,\n  defaultRecent = [],\n  onRecentChange,\n  disabled,\n  ...panel\n}: ColorPickerProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  // Recents live here, above the panel, so they survive the popup unmounting.\n  const [recent, setRecent] = useControllableState({ value: recentProp, defaultValue: defaultRecent, onChange: onRecentChange });\n  const [session, setSession] = useState(0);\n  const rgba = parseColor(value);\n  const pct = rgba && rgba.a < 1 ? Math.round(rgba.a * 100) : null;\n  const shownHex = rgba ? toHex({ ...rgba, a: 1 }) : value;\n\n  return (\n    <Popover.Root\n      open={open}\n      onOpenChange={(next) => {\n        if (next) setSession((s) => s + 1);\n        setOpen(next);\n      }}\n    >\n      <Popover.Trigger\n        disabled={disabled}\n        aria-label={`${label}, ${shownHex}${pct !== null ? ` at ${pct}% opacity` : \"\"}`}\n        data-variant={variant}\n        data-size={size}\n        className={cn(\n          \"group/trigger relative inline-flex shrink-0 select-none items-center outline-none\",\n          \"transition-[background-color,border-color,box-shadow,scale] duration-150 ease-out active:duration-75\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          variant === \"swatch\"\n            ? cn(\"justify-center rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)] hover:border-fg-4 active:scale-[0.94] data-[popup-open]:border-fg-4 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\", size === \"sm\" ? \"size-7\" : \"size-8\")\n            : cn(\n                \"gap-2 rounded-lg border border-line-2 bg-raised pl-1 pr-2 shadow-[var(--shadow)] hover:border-fg-4 active:scale-[0.985] data-[popup-open]:border-fg-4 focus-visible:border-fg-4 focus-visible:ring-3 focus-visible:ring-fg/10\",\n                size === \"sm\" ? \"h-7 text-[12px]\" : \"h-8 text-[12.5px]\",\n              ),\n          className,\n        )}\n      >\n        <span className={cn(\"block shrink-0 overflow-hidden\", checker, variant === \"swatch\" ? \"absolute inset-[3px] rounded-[5px]\" : cn(\"relative\", size === \"sm\" ? \"size-5 rounded-[5px]\" : \"size-6 rounded-md\"))}>\n          <span aria-hidden className=\"absolute inset-0 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_12%,transparent)]\" style={{ background: value }} />\n        </span>\n        {variant === \"field\" && (\n          <>\n            <span className=\"font-mono tabular uppercase text-fg\">{shownHex}</span>\n            {pct !== null && <span className=\"font-mono tabular text-fg-3\">{pct}%</span>}\n            <ChevronDown size={14} className=\"ml-auto text-fg-3 transition-transform duration-200 ease-out group-data-[popup-open]/trigger:rotate-180 motion-reduce:transition-none\" />\n          </>\n        )}\n      </Popover.Trigger>\n      <Popover.Portal>\n        <Popover.Positioner side={side} align={align} sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            className={cn(\n              \"origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-3 shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-180 ease-out-expo data-starting-style:scale-[0.96] data-starting-style:opacity-0\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <Popover.Title className=\"sr-only\">{label}</Popover.Title>\n            <ColorPickerPanel\n              key={session}\n              {...panel}\n              value={value}\n              onValueChange={setValue}\n              recent={recent}\n              onRecentChange={setRecent}\n              disabled={disabled}\n            />\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/color-picker"
}
