{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "colour-field",
  "type": "registry:component",
  "title": "Colour Field",
  "description": "An OKLCH colour picker that shows you the real shape of sRGB for each hue, tells you how the colour reads on white and black, and copies it as OKLCH or hex.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/colour-field.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, CopyIcon, TriangleAlertIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Oklch {\n  l: number;\n  c: number;\n  h: number;\n}\n\nconst MAX_C = 0.37;\n\nfunction oklchToLinear({ l, c, h }: Oklch): [number, number, number] {\n  const a = c * Math.cos((h * Math.PI) / 180);\n  const b = c * Math.sin((h * Math.PI) / 180);\n  const lc = l + 0.3963377774 * a + 0.2158037573 * b;\n  const mc = l - 0.1055613458 * a - 0.0638541728 * b;\n  const sc = l - 0.0894841775 * a - 1.291485548 * b;\n  // NaN, not a clamp: the matrix would fold negative cone responses into a fake in-gamut patch near black.\n  if (lc < 0 || mc < 0 || sc < 0) return [Number.NaN, Number.NaN, Number.NaN];\n  const l_ = lc ** 3;\n  const m_ = mc ** 3;\n  const s_ = sc ** 3;\n  return [\n    4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,\n    -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,\n    -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,\n  ];\n}\n\nconst encode = (x: number) => (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055);\nconst decode = (x: number) => (x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4);\nconst inGamut = (rgb: number[]) => rgb.every((v) => v >= -0.00002 && v <= 1.00002);\n\nfunction toGamut(colour: Oklch): Oklch {\n  if (inGamut(oklchToLinear(colour))) return colour;\n  let lo = 0;\n  let hi = colour.c;\n  for (let i = 0; i < 18; i++) {\n    const mid = (lo + hi) / 2;\n    if (inGamut(oklchToLinear({ ...colour, c: mid }))) lo = mid;\n    else hi = mid;\n  }\n  return { ...colour, c: lo };\n}\n\nfunction toHex(colour: Oklch) {\n  const rgb = oklchToLinear(toGamut(colour)).map((v) => Math.round(Math.min(Math.max(encode(v), 0), 1) * 255));\n  return `#${rgb.map((v) => v.toString(16).padStart(2, \"0\")).join(\"\")}`.toUpperCase();\n}\n\nfunction fromHex(hex: string): Oklch | null {\n  const m = hex.trim().replace(/^#/, \"\");\n  const full = m.length === 3 ? [...m].map((ch) => ch + ch).join(\"\") : m;\n  if (!/^[0-9a-f]{6}$/i.test(full)) return null;\n  const [r, g, b] = [0, 2, 4].map((i) => decode(Number.parseInt(full.slice(i, i + 2), 16) / 255));\n  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);\n  const mm = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);\n  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);\n  const L = 0.2104542553 * l + 0.793617785 * mm - 0.0040720468 * s;\n  const A = 1.9779984951 * l - 2.428592205 * mm + 0.4505937099 * s;\n  const B = 0.0259040371 * l + 0.7827717662 * mm - 0.808675766 * s;\n  const c = Math.sqrt(A * A + B * B);\n  const h = c < 0.0001 ? 0 : ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;\n  return { l: L, c, h };\n}\n\nfunction parse(text: string): Oklch | null {\n  const m = text.trim().match(/^oklch\\(\\s*([\\d.]+)(%?)\\s+([\\d.]+)\\s+([\\d.]+)(?:deg)?\\s*(?:\\/\\s*[\\d.%]+\\s*)?\\)$/i);\n  if (m) {\n    const l = Number(m[1]) / (m[2] ? 100 : 1);\n    return { l: clamp(l, 0, 1), c: clamp(Number(m[3]), 0, MAX_C), h: Number(m[4]) % 360 };\n  }\n  return fromHex(text);\n}\n\nfunction luminance(colour: Oklch) {\n  const [r, g, b] = oklchToLinear(toGamut(colour)).map((v) => clamp(v, 0, 1));\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);\nconst css = ({ l, c, h }: Oklch) => `oklch(${(l * 100).toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(1)})`;\nconst grade = (ratio: number) => (ratio >= 7 ? \"AAA\" : ratio >= 4.5 ? \"AA\" : ratio >= 3 ? \"AA Large\" : \"Fail\");\n\nexport interface ColourFieldProps {\n  value?: Oklch;\n  defaultValue?: Oklch;\n  onValueChange?: (colour: Oklch) => void;\n  label?: string;\n  className?: string;\n}\n\nexport function ColourField({\n  value: valueProp,\n  defaultValue = { l: 0.62, c: 0.19, h: 259 },\n  onValueChange,\n  label = \"Colour\",\n  className,\n}: ColourFieldProps) {\n  const [inner, setInner] = useState(defaultValue);\n  const colour = valueProp ?? inner;\n  const [draft, setDraft] = useState<string | null>(null);\n  const [copied, setCopied] = useState<\"oklch\" | \"hex\" | null>(null);\n  const planeRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [size, setSize] = useState({ w: 0, h: 0 });\n  const reduce = useReducedMotion();\n  const id = useId();\n\n  const set = useCallback(\n    (next: Oklch) => {\n      if (valueProp === undefined) setInner(next);\n      onValueChange?.(next);\n    },\n    [valueProp, onValueChange],\n  );\n\n  useLayoutEffect(() => {\n    const el = planeRef.current;\n    if (!el) return;\n    const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || !size.w) return;\n    const frame = requestAnimationFrame(() => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const w = Math.round(size.w * dpr);\n      const h = Math.round(size.h * dpr);\n      canvas.width = w;\n      canvas.height = h;\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n      const image = ctx.createImageData(w, h);\n      const data = image.data;\n      for (let y = 0; y < h; y++) {\n        const l = 1 - y / (h - 1);\n        for (let x = 0; x < w; x++) {\n          const rgb = oklchToLinear({ l, c: (x / (w - 1)) * MAX_C, h: colour.h });\n          if (!inGamut(rgb)) continue;\n          const i = (y * w + x) * 4;\n          data[i] = encode(clamp(rgb[0], 0, 1)) * 255;\n          data[i + 1] = encode(clamp(rgb[1], 0, 1)) * 255;\n          data[i + 2] = encode(clamp(rgb[2], 0, 1)) * 255;\n          data[i + 3] = 255;\n        }\n      }\n      ctx.putImageData(image, 0, 0);\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [colour.h, size.w, size.h]);\n\n  useEffect(() => {\n    if (!copied) return;\n    const t = window.setTimeout(() => setCopied(null), 1400);\n    return () => window.clearTimeout(t);\n  }, [copied]);\n\n  const pick = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const x = clamp((event.clientX - rect.left) / rect.width, 0, 1);\n    const y = clamp((event.clientY - rect.top) / rect.height, 0, 1);\n    set({ ...colour, l: 1 - y, c: x * MAX_C });\n  };\n\n  const onPlaneKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const big = event.shiftKey ? 5 : 1;\n    const moves: Record<string, [number, number]> = {\n      ArrowLeft: [-0.005, 0],\n      ArrowRight: [0.005, 0],\n      ArrowUp: [0, 0.01],\n      ArrowDown: [0, -0.01],\n    };\n    const move = moves[event.key];\n    if (!move) return;\n    event.preventDefault();\n    set({ ...colour, c: clamp(colour.c + move[0] * big, 0, MAX_C), l: clamp(colour.l + move[1] * big, 0, 1) });\n  };\n\n  const copy = async (kind: \"oklch\" | \"hex\") => {\n    try {\n      await navigator.clipboard.writeText(kind === \"hex\" ? toHex(colour) : css(colour));\n    } catch {\n    }\n    setCopied(kind);\n  };\n\n  const shown = toGamut(colour);\n  const invalid = draft !== null && draft.trim() !== \"\" && !parse(draft);\n  const outside = shown.c < colour.c - 0.0005;\n  const lum = luminance(colour);\n  const onWhite = 1.05 / (lum + 0.05);\n  const onBlack = (lum + 0.05) / 0.05;\n  const hueStops = Array.from({ length: 13 }, (_, i) => `oklch(0.72 0.14 ${i * 30})`).join(\", \");\n\n  return (\n    <div className={cn(\"grid w-full gap-3\", className)}>\n      <div className=\"flex items-baseline justify-between\">\n        <span className=\"font-medium text-sm\" id={`${id}-label`}>\n          {label}\n        </span>\n        <span className=\"font-mono text-muted-foreground text-xs tabular-nums\">{toHex(colour)}</span>\n      </div>\n\n      <div\n        aria-label=\"Lightness and chroma\"\n        aria-valuetext={`Lightness ${Math.round(colour.l * 100)}%, chroma ${colour.c.toFixed(3)}`}\n        className=\"relative h-44 cursor-crosshair touch-none select-none rounded-xl bg-[repeating-linear-gradient(135deg,var(--color-muted)_0_6px,transparent_6px_12px)] outline-none ring-1 ring-border ring-inset focus-visible:ring-2 focus-visible:ring-ring\"\n        onKeyDown={onPlaneKey}\n        onPointerDown={(event) => {\n          event.currentTarget.setPointerCapture(event.pointerId);\n          pick(event);\n        }}\n        onPointerMove={(event) => {\n          if (event.currentTarget.hasPointerCapture(event.pointerId)) pick(event);\n        }}\n        ref={planeRef}\n        role=\"slider\"\n        tabIndex={0}\n      >\n        <canvas aria-hidden className=\"absolute inset-0 size-full rounded-xl\" ref={canvasRef} />\n        <span className=\"pointer-events-none absolute top-2 right-2.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide\">\n          Outside sRGB\n        </span>\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0_0_0_2px_white,0_0_0_3px_rgb(0_0_0/0.2),0_2px_6px_rgb(0_0_0/0.25)]\"\n          style={{ left: `${(colour.c / MAX_C) * 100}%`, top: `${(1 - colour.l) * 100}%`, background: css(shown) }}\n        />\n      </div>\n\n      <div className=\"relative -my-2 h-8\">\n        <div className=\"pointer-events-none absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 rounded-full ring-1 ring-border ring-inset\" style={{ background: `linear-gradient(to right, ${hueStops})` }} />\n        <input\n          aria-label=\"Hue\"\n          aria-valuetext={`${Math.round(colour.h)} degrees`}\n          className=\"peer absolute inset-0 z-10 w-full cursor-pointer appearance-none opacity-0 [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-4 [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none\"\n          max={360}\n          min={0}\n          onChange={(event) => set({ ...colour, h: Number(event.target.value) })}\n          step={1}\n          type=\"range\"\n          value={Math.round(colour.h)}\n        />\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0_0_0_2px_white,0_0_0_3px_rgb(0_0_0/0.2),0_2px_6px_rgb(0_0_0/0.25)] peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-4 peer-focus-visible:ring-offset-background\"\n          style={{ left: `calc(8px + ${colour.h / 360} * (100% - 16px))`, background: `oklch(0.72 0.14 ${colour.h})` }}\n        />\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <span className=\"size-9 shrink-0 rounded-lg ring-1 ring-border ring-inset\" style={{ background: css(shown) }} />\n        <label className=\"sr-only\" htmlFor={`${id}-text`}>\n          Colour value\n        </label>\n        <input\n          aria-describedby={`${id}-hint`}\n          aria-invalid={invalid || undefined}\n          className=\"h-9 min-w-0 flex-1 rounded-lg border border-input bg-background px-2.5 font-mono text-xs tabular-nums shadow-xs/5 outline-none ring-ring/24 transition-shadow focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/40 dark:bg-input/32\"\n          id={`${id}-text`}\n          onBlur={() => {\n            const parsed = draft !== null ? parse(draft) : null;\n            if (parsed) set(parsed);\n            setDraft(null);\n          }}\n          onChange={(event) => setDraft(event.target.value)}\n          onKeyDown={(event) => {\n            if (event.key === \"Enter\") event.currentTarget.blur();\n            if (event.key === \"Escape\") {\n              setDraft(null);\n              event.currentTarget.blur();\n            }\n          }}\n          spellCheck={false}\n          value={draft ?? css(colour)}\n        />\n      </div>\n\n      <div className=\"grid grid-cols-2 gap-2\">\n        <Contrast background=\"white\" colour={css(shown)} ratio={onWhite} />\n        <Contrast background=\"black\" colour={css(shown)} ratio={onBlack} />\n      </div>\n\n      <div aria-live=\"polite\" className=\"flex h-4 min-w-0 items-center gap-1.5 text-muted-foreground text-xs\" id={`${id}-hint`}>\n        {invalid ? (\n          <span className=\"truncate text-destructive-foreground\">Try a hex like #3B82F6 or oklch(62% 0.19 259).</span>\n        ) : outside ? (\n          <>\n            <TriangleAlertIcon className=\"size-3.5 shrink-0 text-warning-foreground\" />\n            <span className=\"truncate\">Outside sRGB, so hex uses the nearest match.</span>\n          </>\n        ) : (\n          <span className=\"truncate\">Arrow keys nudge it. Paste a hex to start from one.</span>\n        )}\n      </div>\n\n      <div className=\"grid grid-cols-2 gap-2\">\n        {([\"oklch\", \"hex\"] as const).map((kind) => (\n          <Button key={kind} onClick={() => copy(kind)} size=\"sm\" variant=\"outline\">\n            <span className=\"relative size-4\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                  className=\"absolute inset-0 flex items-center justify-center\"\n                  exit={{ opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n                  key={copied === kind ? \"done\" : \"copy\"}\n                  transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}\n                >\n                  {copied === kind ? <CheckIcon className=\"text-success-foreground\" /> : <CopyIcon />}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            {copied === kind ? \"Copied\" : `Copy ${kind === \"hex\" ? \"hex\" : \"OKLCH\"}`}\n          </Button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction Contrast({ background, colour, ratio }: { background: \"white\" | \"black\"; colour: string; ratio: number }) {\n  const g = grade(ratio);\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-between gap-2 rounded-lg px-2.5 py-2 ring-1 ring-inset\",\n        background === \"white\" ? \"bg-white ring-black/10\" : \"bg-black ring-white/10\",\n      )}\n    >\n      <span className=\"flex\">\n        <span aria-hidden className=\"font-semibold text-lg leading-none before:content-['Aa']\" style={{ color: colour }} />\n        <span className=\"sr-only\">Contrast on {background}</span>\n      </span>\n      <span className=\"grid justify-items-end gap-0.5\">\n        <span className={cn(\"font-mono text-xs tabular-nums\", background === \"white\" ? \"text-neutral-900\" : \"text-neutral-100\")}>\n          {ratio.toFixed(2)}:1\n        </span>\n        <span\n          className={cn(\n            \"font-medium text-[10px] uppercase tracking-wide\",\n            g === \"Fail\" ? (background === \"white\" ? \"text-red-600\" : \"text-red-400\") : background === \"white\" ? \"text-neutral-500\" : \"text-neutral-400\",\n          )}\n        >\n          {g === \"Fail\" ? \"Fails\" : g}\n        </span>\n      </span>\n    </div>\n  );\n}\n"
    }
  ]
}