{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"color-picker","type":"registry:component","title":"Color Picker","description":"Composable color picker whose trigger chip morphs into the saturation and brightness area. A loupe lifts off each thumb while you drag. Includes hue and opacity sliders, hex, RGB and HSL fields, an eye dropper, presets, full keyboard control, and a controlled or uncontrolled value.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/color-picker.tsx","type":"registry:component","target":"@components/motion/color-picker.tsx","content":"\"use client\";\n// beui.dev/components/motion/color-picker\n\nimport { Pipette } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  type MotionValue,\n  motion,\n  type Transition,\n  useAnimate,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport {\n  Children,\n  type ComponentPropsWithRef,\n  createContext,\n  isValidElement,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  type RefObject,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_GLIDE, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// ---------------------------------------------------------------------------\n// Color math\n// ---------------------------------------------------------------------------\n\n/** Hue 0-360, saturation and value 0-100, alpha 0-1. */\nexport type Hsva = { h: number; s: number; v: number; a: number };\n/** Channels 0-255, alpha 0-1. */\nexport type Rgba = { r: number; g: number; b: number; a: number };\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\nconst round = (n: number, places = 0) => {\n  const f = 10 ** places;\n  return Math.round(n * f) / f;\n};\n\nexport function hsvaToRgba({ h, s, v, a }: Hsva): Rgba {\n  const sat = s / 100;\n  const val = v / 100;\n  const f = (n: number) => {\n    const k = (n + h / 60) % 6;\n    return val - val * sat * 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/** `previous` keeps hue for greys and saturation for black, which RGB cannot carry. */\nexport function rgbaToHsva({ r, g, b, a }: Rgba, previous?: Hsva): Hsva {\n  const rn = r / 255;\n  const gn = g / 255;\n  const bn = b / 255;\n  const max = Math.max(rn, gn, bn);\n  const d = max - Math.min(rn, gn, bn);\n  let h = previous?.h ?? 0;\n  if (d) {\n    if (max === rn) h = ((gn - bn) / d) % 6;\n    else if (max === gn) h = (bn - rn) / d + 2;\n    else h = (rn - gn) / d + 4;\n    h = (h * 60 + 360) % 360;\n  }\n  const v = max * 100;\n  const s = max ? (d / max) * 100 : (previous?.s ?? 0);\n  return { h, s, v, a };\n}\n\nconst HEX_PATTERN = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;\n\n/** Parses #rgb, #rgba, #rrggbb and #rrggbbaa. Returns null for anything else. */\nexport function parseHex(input: string): Rgba | null {\n  const match = HEX_PATTERN.exec(input.trim());\n  if (!match) return null;\n  let hex = match[1];\n  if (hex.length <= 4) hex = [...hex].map((c) => c + c).join(\"\");\n  const n = (i: number) => Number.parseInt(hex.slice(i, i + 2), 16);\n  return { r: n(0), g: n(2), b: n(4), a: hex.length === 8 ? round(n(6) / 255, 2) : 1 };\n}\n\n/** Lowercase #rrggbb, or #rrggbbaa when the color is translucent. */\nexport function formatHex({ r, g, b, a }: Rgba): string {\n  const pair = (n: number) => Math.round(n).toString(16).padStart(2, \"0\");\n  return `#${pair(r)}${pair(g)}${pair(b)}${a < 1 ? pair(a * 255) : \"\"}`;\n}\n\nfunction hsvToHsl({ h, s, v }: Hsva) {\n  const sv = s / 100;\n  const vv = v / 100;\n  const l = vv * (1 - sv / 2);\n  const sl = l === 0 || l === 1 ? 0 : (vv - l) / Math.min(l, 1 - l);\n  return { h, s: sl * 100, l: l * 100 };\n}\n\nfunction hslToHsv(h: number, s: number, l: number, previous: Hsva, a: number): Hsva {\n  const sl = s / 100;\n  const ll = l / 100;\n  const v = ll + sl * Math.min(ll, 1 - ll);\n  return { h, s: v ? 2 * (1 - ll / v) * 100 : previous.s, v: v * 100, a };\n}\n\nconst BLACK: Rgba = { r: 0, g: 0, b: 0, a: 1 };\n\n// ---------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------\n\ntype ColorPickerContextValue = {\n  id: string;\n  hsva: Hsva;\n  rgba: Rgba;\n  hex: string;\n  alpha: boolean;\n  disabled: boolean;\n  update: (next: Hsva) => void;\n  commit: () => void;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n};\n\nconst ColorPickerContext = createContext<ColorPickerContextValue | null>(null);\n\nfunction usePickerContext(component: string) {\n  const context = useContext(ColorPickerContext);\n  if (!context) throw new Error(`${component} must be used within ColorPicker`);\n  return context;\n}\n\n/** Current color and a setter, for custom parts inside a ColorPicker. */\nexport function useColorPicker() {\n  const { hex, rgba, hsva, alpha, disabled, update, commit } = usePickerContext(\"useColorPicker\");\n  return {\n    hex,\n    rgba,\n    hsva,\n    alpha,\n    disabled,\n    /** Accepts any hex form. Invalid input is ignored. */\n    setColor: (value: string) => {\n      const parsed = parseHex(value);\n      if (!parsed) return;\n      update(rgbaToHsva(parsed, hsva));\n      commit();\n    },\n  };\n}\n\nexport interface ColorPickerProps\n  extends Omit<ComponentPropsWithRef<\"fieldset\">, \"onChange\" | \"defaultValue\"> {\n  /** Controlled hex color (#rgb, #rrggbb or #rrggbbaa). */\n  value?: string;\n  defaultValue?: string;\n  /** Fires on every change while dragging or typing, with a lowercase hex. */\n  onValueChange?: (value: string) => void;\n  /** Fires once an interaction settles: pointer release, key, preset or field commit. */\n  onValueCommit?: (value: string) => void;\n  /** Allow transparency. When false the value is always opaque. Default true. */\n  alpha?: boolean;\n  /** Submits the hex value with a form under this name. */\n  name?: string;\n  /** Controlled open state for ColorPickerTrigger + ColorPickerContent. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\n/** Give it an aria-label, or a legend through children. */\nexport function ColorPicker({\n  value,\n  defaultValue = \"#3478f6\",\n  onValueChange,\n  onValueCommit,\n  alpha = true,\n  disabled = false,\n  name,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  form,\n  className,\n  children,\n  ...props\n}: ColorPickerProps) {\n  const id = useId();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [openInternal, setOpenInternal] = useState(defaultOpen);\n  const open = openProp ?? openInternal;\n  const setOpen = (next: boolean) => {\n    if (next === open) return;\n    if (openProp === undefined) setOpenInternal(next);\n    onOpenChange?.(next);\n  };\n  const controlled = value !== undefined;\n  const incoming = controlled ? value : defaultValue;\n  const [state, setState] = useState(() => ({\n    source: incoming,\n    hsva: rgbaToHsva(parseHex(incoming) ?? BLACK),\n  }));\n\n  // A controlled value the picker did not emit itself is re-read here, during\n  // render, so the thumbs never paint a stale color for a frame.\n  let hsva = state.hsva;\n  if (controlled && value !== state.source) {\n    const parsed = parseHex(value);\n    hsva = parsed ? rgbaToHsva(parsed, state.hsva) : state.hsva;\n    setState({ source: value, hsva });\n  }\n  if (!alpha && hsva.a !== 1) hsva = { ...hsva, a: 1 };\n\n  const rgba = hsvaToRgba(hsva);\n  const hex = formatHex(rgba);\n  // Event handlers read the latest emitted hex, which may be ahead of render.\n  const hexRef = useRef(hex);\n  useLayoutEffect(() => {\n    hexRef.current = hex;\n  }, [hex]);\n\n  const update = (next: Hsva) => {\n    const clean: Hsva = {\n      h: clamp(next.h, 0, 360),\n      s: clamp(next.s, 0, 100),\n      v: clamp(next.v, 0, 100),\n      a: alpha ? clamp(next.a, 0, 1) : 1,\n    };\n    const nextHex = formatHex(hsvaToRgba(clean));\n    const changed = nextHex !== hexRef.current;\n    hexRef.current = nextHex;\n    setState({ source: nextHex, hsva: clean });\n    if (changed) onValueChange?.(nextHex);\n  };\n\n  return (\n    <ColorPickerContext.Provider\n      value={{\n        id,\n        hsva,\n        rgba,\n        hex,\n        alpha,\n        disabled,\n        update,\n        commit: () => onValueCommit?.(hexRef.current),\n        open,\n        setOpen,\n        triggerRef,\n      }}\n    >\n      <fieldset\n        {...props}\n        disabled={disabled}\n        form={form}\n        className={cn(\n          \"relative flex w-full min-w-0 max-w-72 flex-col gap-3 border-0 p-0\",\n          // Holding a trigger, the root is just the trigger's box, so the panel\n          // anchors under it even inside a stretching flex or grid parent.\n          \"has-[>[data-color-picker-trigger]]:w-fit has-[>[data-color-picker-trigger]]:self-start\",\n          disabled && \"opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {name && <input type=\"hidden\" name={name} value={hex} form={form} />}\n      </fieldset>\n    </ColorPickerContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Pointer + spring plumbing shared by the area and the sliders\n// ---------------------------------------------------------------------------\n\nfunction useGlide(target: number) {\n  const reduce = useReducedMotion();\n  const spring = useSpring(target, SPRING_GLIDE);\n  useEffect(() => {\n    if (reduce) spring.jump(target);\n    else spring.set(target);\n  }, [reduce, spring, target]);\n  return { spring, offset: useTransform(spring, (n) => `${n}%`) };\n}\n\nfunction useDrag(\n  onPoint: (x: number, y: number) => void,\n  onEnd: () => void,\n  disabled: boolean,\n) {\n  const railRef = useRef<HTMLDivElement>(null);\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const draggingRef = useRef(false);\n  const [dragging, setDragging] = useState(false);\n\n  const point = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = railRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0 || rect.height === 0) return;\n    onPoint(\n      clamp((event.clientX - rect.left) / rect.width, 0, 1),\n      clamp((event.clientY - rect.top) / rect.height, 0, 1),\n    );\n  };\n\n  const end = (event: PointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    releasePointer(event.currentTarget, event.pointerId);\n    draggingRef.current = false;\n    setDragging(false);\n    onEnd();\n  };\n\n  return {\n    railRef,\n    thumbRef,\n    dragging,\n    handlers: {\n      onPointerDown: (event: PointerEvent<HTMLDivElement>) => {\n        if (disabled || event.button !== 0) return;\n        draggingRef.current = true;\n        setDragging(true);\n        capturePointer(event.currentTarget, event.pointerId);\n        thumbRef.current?.focus({ preventScroll: true });\n        point(event);\n      },\n      onPointerMove: (event: PointerEvent<HTMLDivElement>) => {\n        if (draggingRef.current) point(event);\n      },\n      onPointerUp: end,\n      onPointerCancel: end,\n    },\n  };\n}\n\n/** Arrow, Page and Home/End stepping. Returns the delta, or null when unhandled. */\nfunction keyStep(event: KeyboardEvent, big: number): { axis: \"x\" | \"y\"; delta: number } | \"min\" | \"max\" | null {\n  const step = event.shiftKey ? big : 1;\n  switch (event.key) {\n    case \"ArrowRight\":\n      return { axis: \"x\", delta: step };\n    case \"ArrowLeft\":\n      return { axis: \"x\", delta: -step };\n    case \"ArrowUp\":\n      return { axis: \"y\", delta: step };\n    case \"ArrowDown\":\n      return { axis: \"y\", delta: -step };\n    case \"PageUp\":\n      return { axis: \"y\", delta: big };\n    case \"PageDown\":\n      return { axis: \"y\", delta: -big };\n    case \"Home\":\n      return \"min\";\n    case \"End\":\n      return \"max\";\n    default:\n      return null;\n  }\n}\n\nconst CHECKERBOARD =\n  \"repeating-conic-gradient(#d4d4d8 0% 25%, #fafafa 0% 50%) 0 0 / 8px 8px\";\n\n// The loupe lifts with a visible overshoot so it reads as picked up, unlike\n// the critically damped glide the thumb itself rides on.\nconst LOUPE_LIFT: Transition = { type: \"spring\", stiffness: 520, damping: 24, mass: 0.6 };\nconst LOUPE_DROP: Transition = { duration: 0.12, ease: EASE_OUT };\n// Glide velocity in %/s mapped to a pendulum swing, so the loupe trails the drag.\nconst LOUPE_TILT_INPUT = [-400, 400];\nconst LOUPE_TILT_DEGREES = [16, -16];\n\nfunction Thumb({\n  thumbRef,\n  dragging,\n  color,\n  glide,\n  x,\n  y,\n  className,\n  ...props\n}: ComponentPropsWithRef<\"div\"> & {\n  thumbRef: RefObject<HTMLDivElement | null>;\n  dragging: boolean;\n  color: string;\n  /** Horizontal glide spring, read for the loupe's tilt. */\n  glide: MotionValue<number>;\n  x: MotionValue<string>;\n  y?: MotionValue<string>;\n}) {\n  const reduce = useReducedMotion();\n  const velocity = useVelocity(glide);\n  const tilt = useSpring(\n    useTransform(velocity, LOUPE_TILT_INPUT, LOUPE_TILT_DEGREES, { clamp: true }),\n    SPRING_PRESS,\n  );\n  const lifted = dragging && !reduce;\n  return (\n    <motion.div className=\"pointer-events-none absolute inset-0\" style={{ x, y }}>\n      <div\n        {...props}\n        ref={thumbRef}\n        className={cn(\n          \"absolute left-0 size-[18px] -translate-x-1/2 -translate-y-1/2 rounded-full outline-none\",\n          y ? \"top-0\" : \"top-1/2\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          className,\n        )}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          animate={{ scale: lifted ? 0.78 : 1 }}\n          transition={SPRING_PRESS}\n          className=\"block size-full rounded-full border-[3px] border-white shadow-[0_0_0_1px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.25)]\"\n          style={{ backgroundColor: color }}\n        />\n        <AnimatePresence>\n          {lifted && (\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute bottom-[calc(100%+12px)] left-1/2 z-10 -ml-5 block size-10\"\n              style={{ rotate: tilt, originX: 0.5, originY: 1.3 }}\n              initial={{ opacity: 0, scale: 0.3, y: 16 }}\n              animate={{ opacity: 1, scale: 1, y: 0, transition: LOUPE_LIFT }}\n              exit={{ opacity: 0, scale: 0.3, y: 16, transition: LOUPE_DROP }}\n            >\n              {/* A square with one sharp corner, turned so the point aims at the thumb. */}\n              <span\n                className=\"absolute inset-0 -rotate-45 overflow-hidden rounded-[50%_50%_50%_0] border-[3px] border-white shadow-[0_0_0_1px_rgb(0_0_0/0.1),0_6px_16px_rgb(0_0_0/0.28)]\"\n                style={{ background: CHECKERBOARD }}\n              >\n                <span className=\"absolute inset-0\" style={{ background: color }} />\n              </span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Saturation / brightness area\n// ---------------------------------------------------------------------------\n\nexport interface ColorPickerAreaProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n  /** Accessible name for the 2D thumb. Default \"Saturation and brightness\". */\n  label?: string;\n}\n\n// One surface: the trigger chip grows into the area and shrinks back into it.\nconst MORPH: Transition = { type: \"spring\", duration: 0.5, bounce: 0.2 };\nconst AREA_RADIUS = 12;\nconst CHIP_RADIUS = 6;\n\n/** True inside ColorPickerContent, where the area morphs out of the trigger chip. */\nconst MorphContext = createContext(false);\n\nexport function ColorPickerArea({ label = \"Saturation and brightness\", className, style, ...props }: ColorPickerAreaProps) {\n  const { id, hsva, rgba, hex, disabled, update, commit, open } = usePickerContext(\"ColorPickerArea\");\n  const morph = useContext(MorphContext);\n  const reduce = useReducedMotion();\n  const { railRef, thumbRef, dragging, handlers } = useDrag(\n    (x, y) => update({ ...hsva, s: x * 100, v: (1 - y) * 100 }),\n    commit,\n    disabled,\n  );\n  const x = useGlide(hsva.s);\n  const y = useGlide(100 - hsva.v);\n  const s = Math.round(hsva.s);\n  const v = Math.round(hsva.v);\n  const morphing = morph && !reduce;\n  const rgb = `rgb(${rgba.r} ${rgba.g} ${rgba.b})`;\n\n  return (\n    <motion.div\n      {...(props as React.ComponentProps<typeof motion.div>)}\n      {...handlers}\n      ref={railRef}\n      layoutId={morphing ? `${id}-chip` : undefined}\n      // Measure only when open flips: every drag frame would otherwise force a\n      // layout read, and the close render must snapshot so the chip can morph\n      // back from here and the exit can finish.\n      layoutDependency={open}\n      transition={MORPH}\n      className={cn(\n        \"relative h-40 w-full touch-none\",\n        TOUCH_GESTURE_CLASS,\n        disabled ? \"cursor-not-allowed\" : \"cursor-crosshair\",\n        className,\n      )}\n      style={{\n        ...style,\n        borderRadius: AREA_RADIUS,\n        background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsva.h} 100% 50%))`,\n      }}\n    >\n      {/* Arriving from the chip, the solid color dissolves to reveal the spectrum behind it. */}\n      {morphing && (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n          style={{ backgroundColor: hex }}\n          initial={{ opacity: 1 }}\n          animate={{ opacity: 0 }}\n          transition={{ duration: 0.4, ease: EASE_OUT, delay: 0.06 }}\n        />\n      )}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-black/10 ring-inset\" />\n      <motion.div\n        className=\"absolute inset-0\"\n        initial={morphing ? { opacity: 0 } : false}\n        animate={{ opacity: 1 }}\n        transition={{ duration: 0.2, delay: 0.22 }}\n      >\n        <Thumb\n          thumbRef={thumbRef}\n          dragging={dragging}\n          color={rgb}\n          glide={x.spring}\n          x={x.offset}\n          y={y.offset}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={label}\n          aria-roledescription=\"2D slider\"\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={s}\n          aria-valuetext={`Saturation ${s}%, brightness ${v}%`}\n          aria-disabled={disabled || undefined}\n          onKeyDown={(event) => {\n            if (disabled) return;\n            const step = keyStep(event, 10);\n            if (!step) return;\n            event.preventDefault();\n            if (step === \"min\") update({ ...hsva, s: 0 });\n            else if (step === \"max\") update({ ...hsva, s: 100 });\n            else if (step.axis === \"x\") update({ ...hsva, s: hsva.s + step.delta });\n            else update({ ...hsva, v: hsva.v + step.delta });\n            commit();\n          }}\n        />\n      </motion.div>\n    </motion.div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Hue and alpha sliders\n// ---------------------------------------------------------------------------\n\nfunction ChannelSlider({\n  value,\n  max,\n  onValue,\n  label,\n  valueText,\n  background,\n  thumbColor,\n  className,\n  ...props\n}: Omit<ComponentPropsWithRef<\"div\">, \"children\"> & {\n  value: number;\n  max: number;\n  onValue: (next: number) => void;\n  label: string;\n  valueText: string;\n  background: string;\n  thumbColor: string;\n}) {\n  const { disabled, commit } = usePickerContext(\"ColorPicker slider\");\n  const { railRef, thumbRef, dragging, handlers } = useDrag((x) => onValue(x * max), commit, disabled);\n  const x = useGlide((value / max) * 100);\n\n  return (\n    <div\n      {...props}\n      {...handlers}\n      className={cn(\n        \"relative h-3.5 w-full touch-none rounded-full\",\n        TOUCH_GESTURE_CLASS,\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n      style={{ background }}\n    >\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-full ring-1 ring-black/10 ring-inset\" />\n      {/* The rail is inset by half a thumb so the thumb never leaves the rounded track. */}\n      <div ref={railRef} className=\"absolute inset-y-0 right-[9px] left-[9px]\">\n        <Thumb\n          thumbRef={thumbRef}\n          dragging={dragging}\n          color={thumbColor}\n          glide={x.spring}\n          x={x.offset}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={label}\n          aria-orientation=\"horizontal\"\n          aria-valuemin={0}\n          aria-valuemax={max}\n          aria-valuenow={Math.round(value)}\n          aria-valuetext={valueText}\n          aria-disabled={disabled || undefined}\n          onKeyDown={(event) => {\n            if (disabled) return;\n            const step = keyStep(event, 10);\n            if (!step) return;\n            event.preventDefault();\n            if (step === \"min\") onValue(0);\n            else if (step === \"max\") onValue(max);\n            else onValue(Math.round(value) + step.delta);\n            commit();\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport interface ColorPickerHueProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n  label?: string;\n}\n\nexport function ColorPickerHue({ label = \"Hue\", ...props }: ColorPickerHueProps) {\n  const { hsva, update } = usePickerContext(\"ColorPickerHue\");\n  const h = Math.round(hsva.h);\n  return (\n    <ChannelSlider\n      {...props}\n      value={hsva.h}\n      max={360}\n      onValue={(next) => update({ ...hsva, h: next })}\n      label={label}\n      valueText={`${h} degrees`}\n      background=\"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)\"\n      thumbColor={`hsl(${hsva.h} 100% 50%)`}\n    />\n  );\n}\n\nexport interface ColorPickerAlphaProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n  label?: string;\n}\n\n/** Renders nothing when the picker has `alpha={false}`. */\nexport function ColorPickerAlpha({ label = \"Opacity\", ...props }: ColorPickerAlphaProps) {\n  const { hsva, rgba, alpha, update } = usePickerContext(\"ColorPickerAlpha\");\n  if (!alpha) return null;\n  const rgb = `${rgba.r} ${rgba.g} ${rgba.b}`;\n  const percent = Math.round(hsva.a * 100);\n  return (\n    <ChannelSlider\n      {...props}\n      value={hsva.a * 100}\n      max={100}\n      onValue={(next) => update({ ...hsva, a: Math.round(next) / 100 })}\n      label={label}\n      valueText={`${percent}%`}\n      background={`linear-gradient(to right, rgb(${rgb} / 0), rgb(${rgb})), ${CHECKERBOARD}`}\n      thumbColor={`rgb(${rgb} / ${hsva.a})`}\n    />\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Swatch\n// ---------------------------------------------------------------------------\n\nexport type ColorPickerSwatchProps = Omit<ComponentPropsWithRef<\"span\">, \"children\">;\n\n/** The current color over a transparency checkerboard. Decorative. */\nexport function ColorPickerSwatch({ className, style, ...props }: ColorPickerSwatchProps) {\n  const { hex } = usePickerContext(\"ColorPickerSwatch\");\n  return (\n    <span\n      {...props}\n      aria-hidden=\"true\"\n      className={cn(\"relative block size-9 shrink-0 overflow-hidden rounded-lg\", className)}\n      style={{ ...style, background: CHECKERBOARD }}\n    >\n      <span\n        className=\"absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)] transition-colors duration-150\"\n        style={{ backgroundColor: hex }}\n      />\n    </span>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Text fields\n// ---------------------------------------------------------------------------\n\nconst FIELD_CLASS =\n  \"h-9 w-full min-w-0 rounded-lg border border-border bg-background text-sm text-foreground tabular-nums outline-none transition-colors focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed\";\n\n/** A horizontal shake for rejected input; dropped under reduced motion. */\nfunction useRejectShake() {\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const reduce = useReducedMotion();\n  return {\n    scope,\n    shake: () => {\n      if (reduce || !scope.current) return;\n      animate(scope.current, { x: [0, -5, 5, -3, 3, 0] }, { duration: 0.3 });\n    },\n  };\n}\n\nexport interface ColorPickerHexInputProps\n  extends Omit<ComponentPropsWithRef<\"input\">, \"value\" | \"defaultValue\" | \"type\"> {}\n\n/** Hex field. Updates live once six or eight digits are typed, reverts invalid input on blur. */\nexport function ColorPickerHexInput({\n  className,\n  onChange,\n  onBlur,\n  onKeyDown,\n  \"aria-label\": ariaLabel = \"Hex color\",\n  ...props\n}: ColorPickerHexInputProps) {\n  const { hsva, hex, update, commit, disabled } = usePickerContext(\"ColorPickerHexInput\");\n  const [draft, setDraft] = useState<string | null>(null);\n  const { scope, shake } = useRejectShake();\n\n  const settle = () => {\n    if (draft === null) return;\n    const parsed = parseHex(draft);\n    if (parsed) {\n      update(rgbaToHsva(parsed, hsva));\n      commit();\n    } else shake();\n    setDraft(null);\n  };\n\n  return (\n    <div ref={scope} className={cn(\"relative min-w-0 flex-1\", className)}>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-muted-foreground\">\n        #\n      </span>\n      <input\n        {...props}\n        type=\"text\"\n        inputMode=\"text\"\n        autoComplete=\"off\"\n        spellCheck={false}\n        maxLength={9}\n        disabled={disabled}\n        aria-label={ariaLabel}\n        value={draft ?? hex.slice(1).toUpperCase()}\n        onChange={(event) => {\n          onChange?.(event);\n          const next = event.target.value.replace(/^#/, \"\");\n          setDraft(next);\n          // Live preview only for full-length entries, so \"abc\" mid-way to\n          // \"abcdef\" does not flash a short-form color.\n          if (next.length === 6 || next.length === 8) {\n            const parsed = parseHex(next);\n            if (parsed) update(rgbaToHsva(parsed, hsva));\n          }\n        }}\n        onBlur={(event) => {\n          onBlur?.(event);\n          settle();\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          if (event.key === \"Enter\") settle();\n          if (event.key === \"Escape\" && draft !== null) {\n            event.preventDefault();\n            setDraft(null);\n          }\n        }}\n        className={cn(FIELD_CLASS, \"pr-3 pl-6 font-mono uppercase\")}\n      />\n    </div>\n  );\n}\n\ntype ChannelSpec = { key: string; short: string; name: string; max: number; value: number; set: (n: number) => Hsva };\n\nfunction ChannelField({ spec }: { spec: ChannelSpec }) {\n  const { update, commit, disabled } = usePickerContext(\"ColorPickerChannels\");\n  const [draft, setDraft] = useState<string | null>(null);\n  const shown = Math.round(spec.value);\n\n  return (\n    <label className=\"flex min-w-0 flex-1 flex-col items-center gap-1\">\n      <input\n        type=\"text\"\n        inputMode=\"numeric\"\n        autoComplete=\"off\"\n        disabled={disabled}\n        aria-label={spec.name}\n        value={draft ?? String(shown)}\n        onChange={(event) => {\n          const next = event.target.value.replace(/[^\\d]/g, \"\").slice(0, 3);\n          setDraft(next);\n          if (next !== \"\") update(spec.set(clamp(Number(next), 0, spec.max)));\n        }}\n        onBlur={() => {\n          if (draft !== null) commit();\n          setDraft(null);\n        }}\n        onKeyDown={(event) => {\n          const delta = event.key === \"ArrowUp\" ? 1 : event.key === \"ArrowDown\" ? -1 : 0;\n          if (delta) {\n            event.preventDefault();\n            setDraft(null);\n            update(spec.set(clamp(shown + delta * (event.shiftKey ? 10 : 1), 0, spec.max)));\n            commit();\n          } else if (event.key === \"Enter\") {\n            setDraft(null);\n            commit();\n          }\n        }}\n        className={cn(FIELD_CLASS, \"px-1 text-center\")}\n      />\n      <span aria-hidden=\"true\" className=\"text-[11px] font-medium text-muted-foreground\">\n        {spec.short}\n      </span>\n    </label>\n  );\n}\n\nexport interface ColorPickerChannelsProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n  /** Which channels to edit. Default \"rgb\". */\n  format?: \"rgb\" | \"hsl\";\n}\n\n/** Numeric RGB or HSL fields, plus an opacity field when alpha is enabled. Arrow keys nudge. */\nexport function ColorPickerChannels({ format = \"rgb\", className, ...props }: ColorPickerChannelsProps) {\n  const { hsva, rgba, alpha } = usePickerContext(\"ColorPickerChannels\");\n  const fromRgb = (patch: Partial<Rgba>) => rgbaToHsva({ ...rgba, ...patch }, hsva);\n  let specs: ChannelSpec[];\n  if (format === \"hsl\") {\n    const hsl = hsvToHsl(hsva);\n    specs = [\n      { key: \"h\", short: \"H\", name: \"Hue\", max: 360, value: hsl.h, set: (n) => hslToHsv(n, hsl.s, hsl.l, hsva, hsva.a) },\n      { key: \"s\", short: \"S\", name: \"Saturation\", max: 100, value: hsl.s, set: (n) => hslToHsv(hsl.h, n, hsl.l, hsva, hsva.a) },\n      { key: \"l\", short: \"L\", name: \"Lightness\", max: 100, value: hsl.l, set: (n) => hslToHsv(hsl.h, hsl.s, n, hsva, hsva.a) },\n    ];\n  } else {\n    specs = [\n      { key: \"r\", short: \"R\", name: \"Red\", max: 255, value: rgba.r, set: (n) => fromRgb({ r: n }) },\n      { key: \"g\", short: \"G\", name: \"Green\", max: 255, value: rgba.g, set: (n) => fromRgb({ g: n }) },\n      { key: \"b\", short: \"B\", name: \"Blue\", max: 255, value: rgba.b, set: (n) => fromRgb({ b: n }) },\n    ];\n  }\n  if (alpha) {\n    specs.push({ key: \"a\", short: \"A\", name: \"Opacity percent\", max: 100, value: hsva.a * 100, set: (n) => ({ ...hsva, a: n / 100 }) });\n  }\n  return (\n    <div {...props} className={cn(\"flex min-w-0 flex-1 gap-1.5\", className)}>\n      {specs.map((spec) => (\n        <ChannelField key={`${format}-${spec.key}`} spec={spec} />\n      ))}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Eye dropper\n// ---------------------------------------------------------------------------\n\ntype EyeDropperConstructor = new () => { open: () => Promise<{ sRGBHex: string }> };\n\nexport interface ColorPickerEyeDropperProps extends Omit<ComponentPropsWithRef<\"button\">, \"type\"> {\n  children?: ReactNode;\n}\n\n/** Samples a color from the screen. Renders nothing where the EyeDropper API is missing. */\nexport function ColorPickerEyeDropper({\n  className,\n  children,\n  onClick,\n  \"aria-label\": ariaLabel = \"Pick a color from the screen\",\n  ...props\n}: ColorPickerEyeDropperProps) {\n  const { hsva, update, commit, disabled } = usePickerContext(\"ColorPickerEyeDropper\");\n  const reduce = useReducedMotion();\n  const [EyeDropper, setEyeDropper] = useState<EyeDropperConstructor | null>(null);\n  useEffect(() => {\n    const ctor = (window as Window & { EyeDropper?: EyeDropperConstructor }).EyeDropper;\n    if (ctor) setEyeDropper(() => ctor);\n  }, []);\n  if (!EyeDropper) return null;\n\n  return (\n    <motion.button\n      {...(props as React.ComponentProps<typeof motion.button>)}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={ariaLabel}\n      whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n      transition={SPRING_PRESS}\n      onClick={async (event) => {\n        onClick?.(event);\n        if (event.defaultPrevented) return;\n        try {\n          const { sRGBHex } = await new EyeDropper().open();\n          const parsed = parseHex(sRGBHex);\n          if (!parsed) return;\n          update(rgbaToHsva({ ...parsed, a: hsva.a }, hsva));\n          commit();\n        } catch {\n          // Dismissed with Escape.\n        }\n      }}\n      className={cn(\n        \"inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed\",\n        className,\n      )}\n    >\n      {children ?? <Pipette aria-hidden=\"true\" className=\"size-4\" />}\n    </motion.button>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Presets\n// ---------------------------------------------------------------------------\n\nexport type ColorPickerPresetsProps = ComponentPropsWithRef<\"div\">;\n\nexport function ColorPickerPresets({ className, children, ...props }: ColorPickerPresetsProps) {\n  const { id } = usePickerContext(\"ColorPickerPresets\");\n  return (\n    <div {...props} className={cn(\"flex flex-wrap gap-2\", className)}>\n      <LayoutGroup id={`${id}-presets`}>{children}</LayoutGroup>\n    </div>\n  );\n}\n\nexport interface ColorPickerPresetProps\n  extends Omit<ComponentPropsWithRef<\"button\">, \"value\" | \"children\" | \"type\"> {\n  /** Hex color this preset applies. */\n  value: string;\n  /** Accessible color name. Defaults to the hex. */\n  label?: string;\n}\n\nexport function ColorPickerPreset({ value, label, className, onClick, ...props }: ColorPickerPresetProps) {\n  const { hsva, hex, update, commit, disabled } = usePickerContext(\"ColorPickerPreset\");\n  const reduce = useReducedMotion();\n  const parsed = parseHex(value);\n  const normalized = parsed ? formatHex(parsed) : value;\n  const selected = normalized === hex;\n\n  return (\n    <motion.button\n      {...(props as React.ComponentProps<typeof motion.button>)}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={label ?? normalized}\n      aria-pressed={selected}\n      whileTap={reduce || disabled ? undefined : { scale: 0.88 }}\n      transition={SPRING_PRESS}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || !parsed) return;\n        update(rgbaToHsva(parsed, hsva));\n        commit();\n      }}\n      className={cn(\n        \"relative size-6 shrink-0 rounded-full outline-none focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-foreground disabled:cursor-not-allowed\",\n        className,\n      )}\n    >\n      {selected && (\n        <motion.span\n          aria-hidden=\"true\"\n          layoutId={reduce ? undefined : \"color-picker-preset\"}\n          initial={false}\n          transition={SPRING_LAYOUT}\n          className=\"pointer-events-none absolute -inset-[3px] rounded-full border-2 border-foreground/70\"\n        />\n      )}\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 overflow-hidden rounded-full\"\n        style={{ background: CHECKERBOARD }}\n      >\n        <span\n          className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)]\"\n          style={{ backgroundColor: normalized }}\n        />\n      </span>\n    </motion.button>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Popover: trigger chip morphs into the area\n// ---------------------------------------------------------------------------\n\nexport interface ColorPickerTriggerProps extends Omit<ComponentPropsWithRef<\"button\">, \"type\"> {\n  /** Replaces the hex label beside the chip. */\n  children?: ReactNode;\n}\n\n/**\n * The hex beside the chip. Its width springs to the text instead of snapping,\n * so the trigger never jumps when the hex gains or drops its alpha pair. Width\n * is a layout property, but this is one small inline box with nothing laid out\n * after it inside the button, and a transform cannot resize the border.\n */\nfunction TriggerLabel({ hex }: { hex: string }) {\n  const reduce = useReducedMotion();\n  const textRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number | null>(null);\n  useLayoutEffect(() => {\n    const text = textRef.current;\n    if (!text) return;\n    setWidth(text.offsetWidth);\n    const observer = new ResizeObserver(() => setWidth(text.offsetWidth));\n    observer.observe(text);\n    return () => observer.disconnect();\n  }, []);\n  const alpha = hex.slice(7);\n\n  return (\n    <motion.span\n      className=\"relative inline-flex overflow-hidden\"\n      initial={false}\n      animate={{ width: width ?? \"auto\" }}\n      transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n    >\n      <span ref={textRef} className=\"relative inline-flex shrink-0 whitespace-nowrap font-mono uppercase tabular-nums\">\n        {hex.slice(0, 7)}\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {alpha && (\n            <motion.span\n              key=\"alpha\"\n              className=\"text-muted-foreground\"\n              initial={{ opacity: 0, x: reduce ? 0 : -4, filter: reduce ? \"blur(0px)\" : \"blur(3px)\" }}\n              animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, filter: reduce ? \"blur(0px)\" : \"blur(3px)\", transition: { duration: 0.12 } }}\n              transition={SPRING_LAYOUT}\n            >\n              {alpha}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\n/** Opens ColorPickerContent. Its color chip is the surface that grows into the area. */\nexport function ColorPickerTrigger({ className, children, onClick, ...props }: ColorPickerTriggerProps) {\n  const { id, hex, open, setOpen, triggerRef, disabled } = usePickerContext(\"ColorPickerTrigger\");\n  const reduce = useReducedMotion();\n  return (\n    <button\n      {...props}\n      ref={triggerRef}\n      type=\"button\"\n      data-color-picker-trigger=\"\"\n      disabled={disabled}\n      aria-haspopup=\"dialog\"\n      aria-expanded={open}\n      aria-controls={open ? `${id}-content` : undefined}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOpen(!open);\n      }}\n      className={cn(\n        \"inline-flex h-10 w-fit items-center gap-2.5 rounded-xl border border-border bg-background pr-3.5 pl-2.5 text-sm text-foreground outline-none transition-colors hover:border-foreground/20 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed\",\n        className,\n      )}\n    >\n      <span aria-hidden=\"true\" className=\"relative size-6 shrink-0\">\n        {/* The chip's empty socket while it is out being the area. */}\n        <span className=\"absolute inset-0 rounded-md border border-dashed border-foreground/20\" />\n        {!open && (\n          <motion.span\n            layoutId={reduce ? undefined : `${id}-chip`}\n            transition={MORPH}\n            className=\"absolute inset-0 overflow-hidden\"\n            style={{ borderRadius: CHIP_RADIUS, background: CHECKERBOARD }}\n          >\n            <span\n              className=\"absolute inset-0 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)]\"\n              style={{ backgroundColor: hex, borderRadius: CHIP_RADIUS }}\n            />\n          </motion.span>\n        )}\n      </span>\n      {children ?? <TriggerLabel hex={hex} />}\n    </button>\n  );\n}\n\nexport interface ColorPickerContentProps extends Omit<ComponentPropsWithRef<\"div\">, \"role\"> {\n  /** Edge of the trigger the panel lines up with. Default \"start\". */\n  align?: \"start\" | \"end\";\n}\n\n/** Minimum gap between the panel and the viewport edge, in px. */\nconst VIEWPORT_GUTTER = 8;\n\nconst PANEL = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.035, delayChildren: 0.1 } },\n  exit: {},\n};\n\nconst PANEL_ITEM = {\n  hidden: { opacity: 0, y: -6, filter: \"blur(4px)\" },\n  show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\nconst PANEL_ITEM_REDUCED = {\n  hidden: { opacity: 0 },\n  show: { opacity: 1 },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/**\n * Panel opened by ColorPickerTrigger, laid out under it. A ColorPickerArea\n * placed directly inside grows out of the trigger chip; the other children\n * stagger in behind it. Closes on outside press and Escape.\n */\nexport function ColorPickerContent({ align = \"start\", className, children, ...props }: ColorPickerContentProps) {\n  const { id, open, setOpen, triggerRef } = usePickerContext(\"ColorPickerContent\");\n  const reduce = useReducedMotion() ?? false;\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  // Keep the panel on screen horizontally. Written before paint, and before\n  // motion measures the area, so the chip morphs to where the panel really is.\n  useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (!open || !panel) return;\n    panel.style.marginLeft = \"0px\";\n    const rect = panel.getBoundingClientRect();\n    const max = document.documentElement.clientWidth - VIEWPORT_GUTTER;\n    let shift = 0;\n    if (rect.right > max) shift = max - rect.right;\n    if (rect.left + shift < VIEWPORT_GUTTER) shift = VIEWPORT_GUTTER - rect.left;\n    panel.style.marginLeft = `${shift}px`;\n  }, [open]);\n  useDismiss(open, () => setOpen(false), panelRef, {\n    ignore: (target) => Boolean(triggerRef.current?.contains(target)),\n  });\n\n  // Hand focus in on open, and back to the trigger when it closes from inside.\n  // Skipped on mount, so a picker that starts open does not steal page focus.\n  const wasOpen = useRef(open);\n  useEffect(() => {\n    if (wasOpen.current === open) return;\n    wasOpen.current = open;\n    if (open) {\n      panelRef.current\n        ?.querySelector<HTMLElement>(\n          '[role=\"slider\"]:not([aria-disabled]), input:not(:disabled), button:not(:disabled)',\n        )\n        ?.focus({ preventScroll: true });\n      return;\n    }\n    const active = document.activeElement;\n    if (!active || active === document.body || panelRef.current?.contains(active)) {\n      triggerRef.current?.focus({ preventScroll: true });\n    }\n  }, [open, triggerRef]);\n\n  return (\n    <AnimatePresence>\n      {open && (\n        <PresenceGate>\n          {({ gate }) => (\n            <motion.div\n              {...(props as React.ComponentProps<typeof motion.div>)}\n              {...gate}\n              ref={panelRef}\n              id={`${id}-content`}\n              role=\"dialog\"\n              aria-label={props[\"aria-label\"] ?? \"Color picker\"}\n              initial=\"hidden\"\n              animate=\"show\"\n              exit=\"exit\"\n              variants={PANEL}\n              className={cn(\n                \"absolute top-full z-50 mt-2 flex w-72 flex-col gap-3 p-3\",\n                align === \"end\" ? \"right-0\" : \"left-0\",\n                className,\n              )}\n            >\n              {/* The surface fades on its own layer so the morphing area is never faded with it. */}\n              <motion.div\n                aria-hidden=\"true\"\n                className=\"absolute inset-0 -z-10 rounded-2xl border border-border bg-background shadow-[0_12px_32px_-8px_rgb(0_0_0/0.25)]\"\n                variants={{ hidden: { opacity: 0 }, show: { opacity: 1 }, exit: { opacity: 0 } }}\n                transition={{ duration: 0.16, ease: EASE_OUT }}\n              />\n              <MorphContext.Provider value>\n                {/* Children.map keys each wrapper from its child, stable across the exit render. */}\n                {Children.map(children, (child) => {\n                  if (isValidElement(child) && child.type === ColorPickerArea) return child;\n                  return (\n                    <motion.div variants={reduce ? PANEL_ITEM_REDUCED : PANEL_ITEM} transition={SPRING_LAYOUT}>\n                      {child}\n                    </motion.div>\n                  );\n                })}\n              </MorphContext.Provider>\n            </motion.div>\n          )}\n        </PresenceGate>\n      )}\n    </AnimatePresence>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-dismiss.ts","type":"registry:hook","target":"@lib/hooks/use-dismiss.ts","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/presence-gate.tsx","type":"registry:lib","target":"@lib/presence-gate.tsx","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}