{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "type": "registry:ui",
  "title": "Color picker",
  "description": "One color as a hex string: a saturation and brightness area, a hue rail, the hex in mono, an eyedropper where the browser has one, and optional swatches as radios. A field in the Select trigger's look opens it in a popover, a bottom sheet on a phone. The thumbs follow the pointer and the keys at once, and every input says the color in words.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/input.json",
    "https://littmd.vercel.app/r/popover.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/color-picker.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/color-picker 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Color picker: one color, picked by eye or typed as hex (components.md, \"Color picker\"). ColorPanel is the picker on\n * its own; ColorPicker is a field that opens it in a Popover.\n *\n * - Values are six-digit hex strings, lowercase, with the hash. The panel keeps hue, saturation and brightness of its\n *   own, so dragging to black or grey never loses the hue, and it takes a new value from outside only when that value\n *   is a different color.\n * - The color is the user's data, never chrome. It only ever fills the area, the rails, the thumbs and the chips. No\n *   color is written in this file: the gradient stops are computed from the hue, so the lint's rule on raw colors\n *   holds.\n * - Area: saturation left to right, brightness bottom to top, at the control radius, cut into the panel (R3): an\n *   fg-14% edge, so a white corner still has one on any surface, and the cut's top-wall shade (--litt-cut-shade). The thumb\n *   is a cap seen from above (material.md 6): a 20px circle of the color inside a 3px ring of --litt-cap, with the cap's\n *   lift (--litt-cap-lift), so it stands over any color in both themes. Its centre reaches the corners, because the\n *   corners are real colors (white, black, the pure hue). Keyboard focus adds a 2px --litt-focus ring around the cap,\n *   which stays inside the popover's 12px padding.\n * - Hue: a 12px rail at the control radius with the six hue stops, cut like the area, and the same thumb filled with\n *   the pure hue.\n * - Flat (proposals/flat-pill.md): the trigger and the hex field are grey pills with no outline (ink ring on focus,\n *   danger ring on an error); the area loses its cut edge and rounds concentric with the panel. The hairline over\n *   the user's own color stays: it keeps white and black visible, it is not a frame.\n * - Keyboard (the React Aria ColorArea pattern, as the Pad): two visually hidden range inputs for the area, Saturation\n *   in the tab order and Brightness at tabindex -1, still reachable by a screen reader. ← → change saturation, ↑ ↓\n *   brightness, Shift makes it 10 steps, Page Up and Page Down move brightness by 10, Home and End take saturation to\n *   its ends. The hue is a native range input with the same Shift and Page steps. Each input's aria-valuetext says the\n *   color in words (\"Dark blue, #1f3a8a\").\n * - Pointer: pressing anywhere moves the thumb there and starts a drag; the area captures the pointer, so a drag may\n *   leave it. Both surfaces are data-no-drag and touch-action none.\n * - Hex: the Input at sm. It commits on Enter or when it loses focus, takes three or six digits with or\n *   without the hash, and puts the last good value back when what was typed is not a color. While it is not a color it\n *   is aria-invalid.\n * - Eyedropper: an icon-only ghost Button beside the hex, only where the browser has the EyeDropper API (Chromium on a\n *   desktop). It is read after hydration, so the server and the first render never show it.\n * - Swatches: optional, as native radios in a fieldset with a hidden legend, so the arrow keys move between them. Each\n *   chip is 24px (32 on a coarse pointer) at the control radius with an fg-12% hairline. The picked chip carries a\n *   16px check in black or white, whichever reads on that chip, because no token is sure to read on every color.\n * - Motion: none. The thumbs follow the pointer and the keys at once, and colors change at once: an eased color would\n *   lag the hand. The ColorPicker's popover carries the only motion, on the responsive tier.\n *\n * ColorPicker:\n * - Field: the Select's raised trigger, its look and sizes, as the Date picker's: a block you press (R3), held pressed\n *   while the panel is open, with a 16px chip of the color, the hex\n *   and a 16px chevron at the end, or a placeholder in --litt-fg-subtle. A button with aria-haspopup=\"dialog\", named by its\n *   label and the color in words. Label above, hint or error below.\n * - It opens the ColorPanel in a PopoverContent (md, 320px, so eight swatches fit a row), which is a bottom sheet on compact screens. Focus lands on\n *   the area (the first control in it). The value changes live while the panel is open; Esc or a click outside closes it.\n * - A form gets the value through a hidden field named `name`.\n */\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { Button } from \"./button\";\nimport { Input } from \"./input\";\nimport { CheckIcon, ChevronDownIcon } from \"./icons\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"./popover\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\n/* ----------------------------------------------------------------- color math */\n\n/** Hue in degrees (0 to 360), saturation and brightness from 0 to 1. */\nexport type Hsv = { h: number; s: number; v: number };\n\n/** One chip in the swatches row. */\nexport type ColorSwatch = {\n  /** The color, as hex. */\n  value: string;\n  /** Its name, such as \"Atelier blue\". It names the chip for screen readers and shows as its tooltip. */\n  label: string;\n};\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\nconst HEX_DIGITS = /^[0-9a-f]{6}$/;\nconst SHORT_DIGITS = /^[0-9a-f]{3}$/;\n\n/** Reads \"#2f6fe4\", \"2F6FE4\", \"#26e\" or \"26e\" as a six-digit lowercase hex with the hash. Anything else is null. */\nexport function parseHex(input: string): string | null {\n  const raw = input.trim().replace(/^#/, \"\").toLowerCase();\n  if (HEX_DIGITS.test(raw)) return \"#\" + raw;\n  if (SHORT_DIGITS.test(raw)) return \"#\" + raw.split(\"\").map((c) => c + c).join(\"\");\n  return null;\n}\n\nconst toRgb = (hex: string): [number, number, number] => {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n};\nconst byte = (n: number) => Math.round(clamp(n, 0, 1) * 255).toString(16).padStart(2, \"0\");\n\n/** Hue, saturation and brightness to hex. */\nexport function hsvToHex({ h, s, v }: Hsv): string {\n  const f = (k: number) => {\n    const t = (k + h / 60) % 6;\n    return v - v * s * Math.max(0, Math.min(t, 4 - t, 1));\n  };\n  return \"#\" + byte(f(5)) + byte(f(3)) + byte(f(1));\n}\n\n/** Hex to hue, saturation and brightness. A grey has hue 0; the panel keeps its own hue, so it never jumps to red. */\nexport function hexToHsv(hex: string): Hsv {\n  const [r, g, b] = toRgb(hex).map((c) => c / 255) as [number, number, number];\n  const max = Math.max(r, g, b);\n  const d = max - Math.min(r, g, b);\n  let h = 0;\n  if (d) {\n    if (max === r) h = ((g - b) / d) % 6;\n    else if (max === g) h = (b - r) / d + 2;\n    else h = (r - g) / d + 4;\n    h = (h * 60 + 360) % 360;\n  }\n  return { h, s: max ? d / max : 0, v: max };\n}\n\nconst WHITE = hsvToHex({ h: 0, s: 0, v: 1 });\nconst BLACK = hsvToHex({ h: 0, s: 0, v: 0 });\n\n/** APCA's screen luminance (0.0.98G): sRGB to the 2.4 power, without the WCAG 2 linear toe. */\nfunction screenY(hex: string) {\n  const [r, g, b] = toRgb(hex).map((c) => (c / 255) ** 2.4) as [number, number, number];\n  return 0.2126729 * r + 0.7151522 * g + 0.072175 * b;\n}\n\n/** APCA lightness contrast (Lc) of text on a background, as a magnitude: 75 suits body text, 60 content, 45 large text. */\nfunction apca(text: string, bg: string) {\n  const soft = (y: number) => (y > 0.022 ? y : y + (0.022 - y) ** 1.414);\n  const t = soft(screenY(text));\n  const b = soft(screenY(bg));\n  const s = b > t ? (b ** 0.56 - t ** 0.57) * 1.14 : (t ** 0.62 - b ** 0.65) * 1.14;\n  return s < 0.1 ? 0 : (s - 0.027) * 100;\n}\n\n/**\n * Black or white as hex, whichever reads better on this color: for text or a mark drawn on the user's color, where no\n * token is sure to read. Chosen by APCA, not the WCAG 2 ratio: on mid tones such as a rust (#b3613c) the WCAG 2 ratio\n * calls black and white even (4.7 and 4.5) and picks black, where white reads twice as well (Lc 76 against 33).\n */\nexport const getContrastInk = (hex: string) => (apca(WHITE, hex) >= apca(BLACK, hex) ? WHITE : BLACK);\n\nconst HUES: [number, string][] = [\n  [15, \"red\"],\n  [40, \"orange\"],\n  [65, \"yellow\"],\n  [160, \"green\"],\n  [195, \"teal\"],\n  [255, \"blue\"],\n  [290, \"violet\"],\n  [340, \"pink\"],\n  [360, \"red\"],\n];\n\n/** A color in words, such as \"Dark blue\" or \"Light grey\", for screen readers and for the field's name. */\nexport function describeColor(hex: string): string {\n  const { h, s, v } = hexToHsv(hex);\n  const l = v * (1 - s / 2); // the lightness of the same color in HSL\n  const chroma = v * s;\n  if (chroma < 0.08) {\n    if (l > 0.95) return \"White\";\n    if (l < 0.06) return \"Black\";\n    return l > 0.7 ? \"Light grey\" : l < 0.3 ? \"Dark grey\" : \"Grey\";\n  }\n  const name = HUES.find(([end]) => h < end)?.[1] ?? \"red\";\n  const tone = l < 0.3 ? \"Dark \" : l > 0.7 ? \"Light \" : \"\";\n  return tone ? tone + name : name[0]!.toUpperCase() + name.slice(1);\n}\n\nconst say = (hex: string) => `${describeColor(hex)}, ${hex}`;\n\n/* ----------------------------------------------------------------- styles */\n\n/* The thumb on the area and the hue, a cap seen from above: the color inside a ring of --litt-cap, lit on top by the\n   cap's lift, which comes first so its highlight sits over the ring. Focus adds a 2px --litt-focus ring around it. */\nconst thumb =\n  \"pointer-events-none absolute block size-5 -translate-x-1/2 -translate-y-1/2 rounded-litt-pill \" +\n  \"forced-colors:border \";\nconst thumbEdge = \"shadow-[var(--litt-cap-lift),inset_0_0_0_3px_var(--litt-cap)]\";\nconst thumbFocus = \"shadow-[0_0_0_2px_var(--litt-focus),var(--litt-cap-lift),inset_0_0_0_3px_var(--litt-cap)]\";\n\n/* A hairline over the user's color, so white still has an edge on a light surface and black on a dark one. */\nconst hairline = \"pointer-events-none absolute inset-0 rounded-litt-control shadow-[inset_0_0_0_1px_var(--litt-line)]\";\n\n/* The area and the hue are cut into the panel: a translucent edge, which reads over any color, and the cut's shade. */\nconst cutEdge =\n  \"pointer-events-none absolute inset-0 rounded-litt-control shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-fg)_14%,transparent),var(--litt-cut-shade)] in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] in-[[data-material=flat]]:shadow-none\";\n\n/* The Select trigger's look and sizes (select.tsx), as the Date picker copies them. */\nconst field =\n  \"group/trigger flex w-full min-w-0 cursor-pointer items-center gap-litt-control rounded-litt-control bg-litt-block text-left font-normal text-litt-fg \" +\n  \"data-placeholder:text-litt-fg-subtle \" +\n  \"pointer-coarse:text-(length:--litt-control-text-lg) \" +\n  \"[-webkit-tap-highlight-color:transparent] \" +\n  \"shadow-(--litt-block-lift) enabled:active:shadow-(--litt-block-press) data-[state=open]:shadow-(--litt-block-press) \" +\n  \"hover:bg-(--litt-block-hover) enabled:pointer-coarse:active:bg-(--litt-block-hover) \" +\n  \"transition-[background-color,color,translate] duration-(--litt-dur-instant) ease-litt \" +\n  \"active:translate-y-(--litt-press-depth) disabled:active:translate-y-0 data-[state=open]:translate-y-(--litt-press-depth) \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"aria-invalid:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-block-lift)] \" +\n  \"aria-invalid:enabled:active:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-block-press)] \" +\n  \"aria-invalid:data-[state=open]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-block-press)] \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:text-litt-fg-disabled disabled:data-placeholder:text-litt-fg-disabled \" +\n  \"disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  /* Flat (rule 4): a grey pill with no outline, the ink ring for focus, the danger ring for an error, disabled flat. */\n  \"in-[[data-material=flat]]:focus-visible:outline-none in-[[data-material=flat]]:focus-visible:shadow-(--litt-focus-ring) \" +\n  \"in-[[data-material=flat]]:aria-invalid:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:aria-invalid:enabled:active:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:aria-invalid:data-[state=open]:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:disabled:shadow-none \" +\n  \"forced-colors:border\";\n\nexport type ColorPickerSize = \"sm\" | \"md\" | \"lg\";\n\nconst sizes: Record<ColorPickerSize, string> = {\n  sm: \"h-litt-control-sm px-litt-control-pad-sm text-litt-small pointer-coarse:h-litt-control-md\",\n  md: \"h-litt-control-md px-litt-control-pad-md text-litt-body\",\n  lg: \"h-litt-control-lg px-litt-control-pad-lg text-(length:--litt-control-text-lg)\",\n};\n\n/* ----------------------------------------------------------------- the eyedropper */\n\ntype EyeDropperCtor = new () => { open: () => Promise<{ sRGBHex: string }> };\n\n/** The browser's EyeDropper, read after hydration so the server and the first render agree. Null where there is none. */\nfunction useEyeDropper(): EyeDropperCtor | null {\n  const [ctor, setCtor] = useState<EyeDropperCtor | null>(null);\n  useEffect(() => {\n    const found = (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper;\n    if (found) setCtor(() => found);\n  }, []);\n  return ctor;\n}\n\nconst DropperIcon = () => (\n  <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">\n    <path d=\"M8.75 3.75l1.4-1.4a1.77 1.77 0 0 1 2.5 2.5l-1.4 1.4z\" fill=\"currentColor\" strokeWidth={1} />\n    <path d=\"m8.25 3.25 4.5 4.5M10.5 5.5 2.75 13.25\" />\n  </svg>\n);\n\n/* ----------------------------------------------------------------- ColorPanel */\n\nexport type ColorPanelProps = {\n  /** Names the panel for screen readers, such as \"Brand color\". It is not shown: put a visible label beside the panel, or use ColorPicker. */\n  label: string;\n  /** The color as hex, when you control it. Pair it with onValueChange. */\n  value?: string;\n  /** The color it starts at when you do not control it. Defaults to a mid blue. */\n  defaultValue?: string;\n  /** Called with the new hex on every move, by pointer, key, hex, swatch or eyedropper. */\n  onValueChange?: (value: string) => void;\n  /** Called with the final hex when a drag is released, a key moves a thumb, the hex is committed or a swatch is picked. Use it to save. */\n  onValueCommit?: (value: string) => void;\n  /** Colors to pick in one click, such as a brand's palette. Shown as a row of chips under the hex. */\n  swatches?: ColorSwatch[];\n  /** The legend of the swatches for screen readers. */\n  swatchesLabel?: string;\n  /** Height of the area in px. The width is the container's. */\n  height?: number;\n  /** Extra classes for the wrapper, such as a width. */\n  className?: string;\n};\n\nconst START = hsvToHex({ h: 220, s: 0.8, v: 0.85 });\n\n/** The picker on its own: a saturation and brightness area, a hue rail, the hex, an eyedropper where the browser has one, and optional swatches. */\nexport function ColorPanel({\n  label,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  swatches,\n  swatchesLabel = \"Swatches\",\n  height = 160,\n  className = \"\",\n}: ColorPanelProps) {\n  const id = useId().replace(/[^\\w-]/g, \"\");\n  const dropper = useEyeDropper();\n\n  const [inner, setInner] = useState(() => parseHex(defaultValue ?? \"\") ?? START);\n  const value = parseHex(valueProp ?? \"\") ?? inner;\n  /* Hue, saturation and brightness are kept here, so black and grey keep their hue. A new value from outside replaces them only when it is another color. */\n  const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(value));\n  const shown = hsvToHex(hsv) === value ? hsv : hexToHsv(value);\n  if (shown !== hsv) setHsv(shown);\n\n  const [draft, setDraft] = useState<string | null>(null); // the hex while it is being typed\n  const sInput = useRef<HTMLInputElement>(null);\n  const dragging = useRef<\"area\" | \"hue\" | null>(null);\n  const [ring, setRing] = useState<\"area\" | \"hue\" | null>(null); // which thumb has keyboard focus, so its ring shows\n  const last = useRef(value);\n\n  const set = (next: Hsv, commit = false) => {\n    const n = { h: clamp(next.h, 0, 360), s: clamp(next.s, 0, 1), v: clamp(next.v, 0, 1) };\n    setHsv(n);\n    const hex = hsvToHex(n);\n    last.current = hex;\n    if (hex !== value) {\n      if (valueProp === undefined) setInner(hex);\n      onValueChange?.(hex);\n    }\n    if (commit) onValueCommit?.(hex);\n  };\n\n  const setHex = (hex: string) => {\n    const n = hexToHsv(hex);\n    /* A grey has no hue of its own, so it keeps the one the panel shows. */\n    set(n.s === 0 || n.v === 0 ? { ...n, h: hsv.h } : n, true);\n  };\n\n  /* ---- pointer */\n\n  const fromArea = (e: PointerEvent<HTMLDivElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    set({ h: hsv.h, s: (e.clientX - box.left) / Math.max(1, box.width), v: (box.bottom - e.clientY) / Math.max(1, box.height) });\n  };\n  const fromHue = (e: PointerEvent<HTMLDivElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    set({ ...hsv, h: ((e.clientX - box.left) / Math.max(1, box.width)) * 360 });\n  };\n  const press = (kind: \"area\" | \"hue\", e: PointerEvent<HTMLDivElement>, focus: () => void) => {\n    if (e.pointerType === \"mouse\" && e.button !== 0) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    dragging.current = kind;\n    setRing(null);\n    (kind === \"area\" ? fromArea : fromHue)(e);\n    focus();\n  };\n  const release = () => {\n    if (!dragging.current) return;\n    dragging.current = null;\n    onValueCommit?.(last.current);\n  };\n\n  /* ---- keyboard on the area */\n\n  const onAreaKey = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.altKey || e.ctrlKey || e.metaKey) return;\n    const n = (e.shiftKey ? 10 : 1) / 100;\n    const moves: Record<string, Partial<Hsv>> = {\n      ArrowLeft: { s: hsv.s - n },\n      ArrowRight: { s: hsv.s + n },\n      ArrowDown: { v: hsv.v - n },\n      ArrowUp: { v: hsv.v + n },\n      PageDown: { v: hsv.v - 0.1 },\n      PageUp: { v: hsv.v + 0.1 },\n      Home: { s: 0 },\n      End: { s: 1 },\n    };\n    const move = moves[e.key];\n    if (!move) return;\n    e.preventDefault();\n    setRing(\"area\");\n    set({ ...hsv, ...move }, true);\n  };\n\n  /* The hue input is native, so its own keys work; Shift adds ten-degree steps, as on the area. */\n  const onHueKey = (e: KeyboardEvent<HTMLInputElement>) => {\n    setRing(\"hue\");\n    if (!e.shiftKey || (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\" && e.key !== \"ArrowUp\" && e.key !== \"ArrowDown\")) return;\n    e.preventDefault();\n    const dir = e.key === \"ArrowLeft\" || e.key === \"ArrowDown\" ? -1 : 1;\n    set({ ...hsv, h: hsv.h + dir * 10 }, true);\n  };\n\n  /* A press focuses the input for the keys that follow, and that is not keyboard focus, so only Tab shows the ring. */\n  const focusProps = (kind: \"area\" | \"hue\") => ({\n    onFocus: (e: { currentTarget: HTMLInputElement }) => {\n      if (!dragging.current && e.currentTarget.matches(\":focus-visible\")) setRing(kind);\n    },\n    onBlur: () => setRing(null),\n  });\n\n  /* ---- hex */\n\n  const typed = draft === null ? null : parseHex(draft);\n  const commitDraft = () => {\n    if (draft === null) return;\n    if (typed && typed !== value) setHex(typed);\n    setDraft(null);\n  };\n\n  const pickWithDropper = async () => {\n    if (!dropper) return;\n    try {\n      const picked = parseHex((await new dropper().open()).sRGBHex);\n      if (picked) setHex(picked);\n    } catch {\n      /* Esc in the eyedropper rejects; the color stays as it was. */\n    }\n  };\n\n  /* ---- what is drawn */\n\n  const words = say(value);\n  const pure = hsvToHex({ h: hsv.h, s: 1, v: 1 });\n  const hueStops = [0, 60, 120, 180, 240, 300, 360].map((h) => hsvToHex({ h, s: 1, v: 1 })).join(\", \");\n  const areaInput = (axis: \"s\" | \"v\") => ({\n    type: \"range\" as const,\n    min: 0,\n    max: 100,\n    step: 1,\n    value: Math.round(hsv[axis] * 100),\n    \"aria-label\": axis === \"s\" ? \"Saturation\" : \"Brightness\",\n    \"aria-valuetext\": `${axis === \"s\" ? \"Saturation\" : \"Brightness\"} ${Math.round(hsv[axis] * 100)}%, ${words}`,\n    \"aria-describedby\": `${id}-area`,\n    onKeyDown: onAreaKey,\n    ...focusProps(\"area\"),\n    onChange: (e: ChangeEvent<HTMLInputElement>) => set({ ...hsv, [axis]: Number(e.target.value) / 100 }, true),\n  });\n\n  return (\n    <div role=\"group\" aria-label={label} className={\"grid gap-3 \" + className}>\n      <span id={`${id}-area`} className=\"sr-only\">\n        {label}. Saturation left to right, brightness bottom to top.\n      </span>\n\n      {/* Saturation and brightness. */}\n      <div\n        data-no-drag=\"\"\n        className=\"relative w-full cursor-crosshair touch-none select-none rounded-litt-control in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)]\"\n        style={{\n          height,\n          backgroundColor: pure,\n          backgroundImage: `linear-gradient(to top, ${BLACK}, transparent), linear-gradient(to right, ${WHITE}, transparent)`,\n        }}\n        onMouseDown={(e) => e.preventDefault() /* the press would blur the input it is about to focus */}\n        onPointerDown={(e) => press(\"area\", e, () => sInput.current?.focus({ preventScroll: true }))}\n        onPointerMove={(e) => dragging.current === \"area\" && fromArea(e)}\n        onPointerUp={release}\n        onPointerCancel={release}\n        onLostPointerCapture={release}\n      >\n        <span aria-hidden=\"true\" className={cutEdge} />\n        <input ref={sInput} className=\"sr-only\" {...areaInput(\"s\")} />\n        <input className=\"sr-only\" tabIndex={-1} aria-orientation=\"vertical\" {...areaInput(\"v\")} />\n        <span\n          aria-hidden=\"true\"\n          className={thumb + (ring === \"area\" ? thumbFocus : thumbEdge)}\n          style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, backgroundColor: value }}\n        />\n      </div>\n\n      {/* Hue. */}\n      <div\n        data-no-drag=\"\"\n        className=\"relative h-3 w-full cursor-pointer touch-none select-none rounded-litt-control pointer-coarse:my-2\"\n        style={{ backgroundImage: `linear-gradient(to right, ${hueStops})` }}\n        onMouseDown={(e) => e.preventDefault()}\n        onPointerDown={(e) => press(\"hue\", e, () => e.currentTarget.querySelector(\"input\")?.focus({ preventScroll: true }))}\n        onPointerMove={(e) => dragging.current === \"hue\" && fromHue(e)}\n        onPointerUp={release}\n        onPointerCancel={release}\n        onLostPointerCapture={release}\n      >\n        <span aria-hidden=\"true\" className={cutEdge} />\n        {/* The 12px rail is a 24px target, 6px above and below it, inside the 12px gaps; a finger's reaches 16px. */}\n        <span aria-hidden=\"true\" className=\"absolute inset-x-0 -inset-y-1.5 pointer-coarse:-inset-y-4\" />\n        <input\n          type=\"range\"\n          className=\"sr-only\"\n          min={0}\n          max={360}\n          step={1}\n          value={Math.round(hsv.h)}\n          aria-label=\"Hue\"\n          aria-valuetext={`Hue ${Math.round(hsv.h)} degrees, ${words}`}\n          onKeyDown={onHueKey}\n          {...focusProps(\"hue\")}\n          onChange={(e) => set({ ...hsv, h: Number(e.target.value) }, true)}\n        />\n        <span aria-hidden=\"true\" className={thumb + (ring === \"hue\" ? thumbFocus : thumbEdge) + \" top-1/2\"} style={{ left: `${(hsv.h / 360) * 100}%`, backgroundColor: pure }} />\n      </div>\n\n      {/* The hex, and the eyedropper where there is one. */}\n      <div className=\"flex items-center gap-2\">\n        <span aria-hidden=\"true\" className=\"relative size-8 shrink-0 rounded-litt-control pointer-coarse:size-11\" style={{ backgroundColor: value }}>\n          <span className={hairline} />\n        </span>\n        <Input\n          size=\"sm\"\n          className=\"min-w-0 flex-1\"\n          aria-label=\"Hex\"\n          invalid={draft !== null && !typed}\n          spellCheck={false}\n          autoComplete=\"off\"\n          autoCapitalize=\"off\"\n          maxLength={7}\n          value={draft ?? value}\n          onChange={(e) => setDraft(e.target.value)}\n          onBlur={commitDraft}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.preventDefault();\n              commitDraft();\n            }\n          }}\n        />\n        {dropper && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            iconOnly\n            aria-label=\"Pick a color from the screen\"\n            title=\"Pick from the screen\"\n            className=\"pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n            onClick={pickWithDropper}\n          >\n            <DropperIcon />\n          </Button>\n        )}\n      </div>\n\n      {swatches && swatches.length > 0 && (\n        <fieldset className=\"m-0 min-w-0 border-0 p-0\">\n          <legend className=\"sr-only\">{swatchesLabel}</legend>\n          <div className=\"flex flex-wrap gap-2 pointer-coarse:gap-3\">\n            {swatches.map((sw) => {\n              const hex = parseHex(sw.value);\n              if (!hex) return null;\n              const on = hex === value;\n              return (\n                <label key={sw.value + sw.label} title={sw.label} className=\"litt-target relative block cursor-pointer [-webkit-tap-highlight-color:transparent]\">\n                  <input\n                    type=\"radio\"\n                    name={`${id}-swatch`}\n                    className=\"peer sr-only\"\n                    checked={on}\n                    aria-label={`${sw.label}, ${hex}`}\n                    onChange={() => setHex(hex)}\n                  />\n                  <span\n                    aria-hidden=\"true\"\n                    className={\n                      \"relative grid size-6 place-items-center rounded-litt-control pointer-coarse:size-8 \" +\n                      \"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-litt-focus\"\n                    }\n                    style={{ backgroundColor: hex, color: getContrastInk(hex) }}\n                  >\n                    <span className=\"pointer-events-none absolute inset-0 rounded-litt-control shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\" />\n                    {on && <CheckIcon className=\"relative\" />}\n                  </span>\n                </label>\n              );\n            })}\n          </div>\n        </fieldset>\n      )}\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- ColorPicker */\n\nexport type ColorPickerProps = {\n  /** The label above the field. It also names the popover, and the sheet's heading on a phone, so keep it short. */\n  label: string;\n  /** Help under the field, linked with aria-describedby. An error replaces it. */\n  hint?: ReactNode;\n  /** An error under the field in --litt-danger. It sets aria-invalid. */\n  error?: ReactNode;\n  /** What the field says when there is no color. */\n  placeholder?: string;\n  /** Height on the control scale: sm 32, md 44, lg 56. */\n  size?: ColorPickerSize;\n  /** The name a form sends the hex under, through a hidden field. */\n  name?: string;\n  /** Makes the field unavailable. */\n  disabled?: boolean;\n  /** The field's id. One is made if you leave it out. */\n  id?: string;\n  /** Extra classes for the wrapper, such as a width. */\n  className?: string;\n  /** The color as hex, or null for none, when you control it. Pair it with onValueChange. */\n  value?: string | null;\n  /** The color it starts at when you do not control it. */\n  defaultValue?: string | null;\n  /** Called with the new hex on every change while the panel is open. */\n  onValueChange?: (value: string) => void;\n  /** Called with the hex when a drag is released, a key moves a thumb, the hex is committed or a swatch is picked. Use it to save. */\n  onValueCommit?: (value: string) => void;\n  /** Colors to pick in one click, shown under the hex in the panel. */\n  swatches?: ColorSwatch[];\n};\n\n/**\n * A form field that opens a ColorPanel: the Select trigger's look with a chip of the color and its hex, and the panel\n * in a popover (a bottom sheet on a phone). The color changes live while it is open. Pass name and a form gets the hex.\n */\nexport function ColorPicker({\n  label,\n  hint,\n  error,\n  placeholder = \"Choose a color\",\n  size = \"md\",\n  name,\n  disabled = false,\n  id: idProp,\n  className = \"\",\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onValueCommit,\n  swatches,\n}: ColorPickerProps) {\n  const [inner, setInner] = useState<string | null>(() => (defaultValue ? parseHex(defaultValue) : null));\n  const value = valueProp === undefined ? inner : valueProp ? parseHex(valueProp) : null;\n  const [open, setOpen] = useState(false);\n  const trigger = useRef<HTMLButtonElement | null>(null);\n\n  const auto = useId();\n  const id = idProp ?? auto;\n  const labelId = `${id}-label`;\n  const valueId = `${id}-value`;\n  const noteId = `${id}-note`;\n  const note = error ?? hint;\n\n  const change = (next: string) => {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  return (\n    <div className={\"grid gap-2 \" + className}>\n      {/* The label points at the field, but a click only moves focus: a label click would otherwise open the panel. */}\n      <label\n        id={labelId}\n        htmlFor={id}\n        onClick={(e) => {\n          e.preventDefault();\n          trigger.current?.focus();\n        }}\n        className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}\n      >\n        {label}\n      </label>\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger {...asChildProps}>\n          <button\n            ref={trigger}\n            id={id}\n            type=\"button\"\n            disabled={disabled}\n            aria-labelledby={`${labelId} ${valueId}`}\n            aria-describedby={note ? noteId : undefined}\n            aria-invalid={error ? true : undefined}\n            data-placeholder={value ? undefined : \"\"}\n            className={field + \" \" + sizes[size]}\n          >\n            {value ? (\n              <>\n                <span aria-hidden=\"true\" className=\"relative size-4 shrink-0 rounded-litt-control\" style={{ backgroundColor: value }}>\n                  <span className={hairline} />\n                </span>\n                <span className=\"min-w-0 flex-1 truncate tabular-nums\">{value}</span>\n                <span id={valueId} className=\"sr-only\">\n                  {describeColor(value)}, {value}\n                </span>\n              </>\n            ) : (\n              <span id={valueId} className=\"min-w-0 flex-1 truncate\">\n                {placeholder}\n              </span>\n            )}\n            <ChevronDownIcon className=\"shrink-0 text-litt-fg-subtle group-disabled/trigger:text-litt-fg-disabled\" />\n          </button>\n        </PopoverTrigger>\n        <PopoverContent label={label}>\n          <ColorPanel\n            label={label}\n            value={value ?? undefined}\n            onValueChange={change}\n            onValueCommit={onValueCommit}\n            swatches={swatches}\n          />\n        </PopoverContent>\n      </Popover>\n      {name && <input type=\"hidden\" name={name} value={value ?? \"\"} />}\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/color-picker.tsx"
    }
  ],
  "docs": "Import ColorPicker and ColorPanel from components/litt/color-picker, plus parseHex, describeColor, getContrastInk, hexToHsv and hsvToHex. Values are six-digit lowercase hex strings with the hash. ColorPicker needs a label; hold the value in state with value and onValueChange (null for none), pass name for a form, and swatches as { label, value }. Save with onValueCommit, which fires on release. Use getContrastInk for text drawn on the picked color.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
