{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"color-picker","type":"registry:component","title":"Color Picker","description":"HSV color picker with a 2D saturation/brightness area, hue and optional alpha sliders whose thumbs spring to a clicked point and follow drags 1:1, a validated hex field, preset swatches with a gliding selection ring and a ColorPickerPopover trigger mode; keyboard accessible, dependency-free color math.","author":"kara <ztzyrant@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// karaui.kamisama-v0.my.id/components/motion/color-picker\n\nimport { Pipette } from \"lucide-react\";\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type RefObject,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  MorphPopover,\n  MorphPopoverContent,\n  MorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_MOUSE } from \"@/lib/ease\";\nimport { useSlider } from \"@/lib/hooks/use-slider\";\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/value/alpha 0–1. */\nexport interface HSVA {\n  h: number;\n  s: number;\n  v: number;\n  a: number;\n}\n\ninterface RGBA {\n  r: number;\n  g: number;\n  b: number;\n  a: number;\n}\n\nconst clamp = (n: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, n));\n\nexport function hsvToRgb({ h, s, v, a }: HSVA): RGBA {\n  const f = (n: number) => {\n    const k = (n + h / 60) % 6;\n    return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));\n  };\n  return {\n    r: Math.round(f(5) * 255),\n    g: Math.round(f(3) * 255),\n    b: Math.round(f(1) * 255),\n    a,\n  };\n}\n\n/** `fallbackHue` keeps the hue when the color is gray, where hue is undefined. */\nexport function rgbToHsv({ r, g, b, a }: RGBA, fallbackHue = 0): 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 min = Math.min(rn, gn, bn);\n  const d = max - min;\n  let h = fallbackHue;\n  if (d !== 0) {\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 *= 60;\n    if (h < 0) h += 360;\n  }\n  return { h, s: max === 0 ? 0 : d / max, v: max, a };\n}\n\n/** Accepts `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, with or without `#`. */\nexport function parseHex(input: string): RGBA | null {\n  let hex = input.trim().replace(/^#/, \"\").toLowerCase();\n  if (!/^[0-9a-f]+$/.test(hex) || ![3, 4, 6, 8].includes(hex.length)) {\n    return null;\n  }\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 ? n(6) / 255 : 1 };\n}\n\nconst byte = (n: number) => Math.round(n).toString(16).padStart(2, \"0\");\n\n/** Lowercase `#rrggbb`, or `#rrggbbaa` when `withAlpha` and alpha < 1. */\nexport function rgbToHex({ r, g, b, a }: RGBA, withAlpha = false): string {\n  const base = `#${byte(r)}${byte(g)}${byte(b)}`;\n  return withAlpha && a < 1 ? `${base}${byte(a * 255)}` : base;\n}\n\nexport const hsvaToHex = (c: HSVA, withAlpha = false) =>\n  rgbToHex(hsvToRgb(c), withAlpha);\n\nfunction hexToHsva(hex: string, fallbackHue = 0): HSVA | null {\n  const rgba = parseHex(hex);\n  return rgba ? rgbToHsv(rgba, fallbackHue) : null;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                   Helpers                                  */\n/* -------------------------------------------------------------------------- */\n\nconst DEFAULT_SWATCHES = [\n  \"#ef4444\",\n  \"#f97316\",\n  \"#eab308\",\n  \"#22c55e\",\n  \"#06b6d4\",\n  \"#3b82f6\",\n  \"#6366f1\",\n  \"#a855f7\",\n  \"#ec4899\",\n  \"#18181b\",\n];\n\nconst HUE_GRADIENT =\n  \"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)\";\n\nconst CHECKER = {\n  backgroundImage: \"repeating-conic-gradient(#d4d4d8 0% 25%, #ffffff 0% 50%)\",\n  backgroundSize: \"8px 8px\",\n} as const;\n\nconst SHAKE = { x: [0, -5, 5, -3, 3, 0] };\n\n// A click that wanders less than this still counts as a click, so the\n// jump-spring is not cut short by hand jitter.\nconst DRAG_SLOP = 3;\n\nfunction useElementWidth(ref: RefObject<HTMLElement | null>) {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () =>\n      setSize({ width: el.clientWidth, height: el.clientHeight });\n    read();\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref]);\n  return size;\n}\n\n/**\n * Pixel position for a thumb. Pointer-down and keyboard jumps spring there\n * (SPRING_MOUSE); a drag follows the pointer 1:1.\n */\nfunction useThumb(\n  target: number,\n  springNext: RefObject<boolean>,\n  reduce: boolean,\n): MotionValue<number> {\n  const mv = useMotionValue(target);\n  useEffect(() => {\n    if (reduce || !springNext.current) {\n      mv.jump(target);\n      return;\n    }\n    const controls = animate(mv, target, { type: \"spring\", ...SPRING_MOUSE });\n    return () => controls.stop();\n  }, [target, mv, reduce, springNext]);\n  return mv;\n}\n\n/** Shared \"spring the next move\" flag, flipped off once a press becomes a drag. */\nfunction useJumpFlag() {\n  const springNext = useRef(false);\n  const downAt = useRef<{ x: number; y: number } | null>(null);\n  return {\n    springNext,\n    down(event: PointerEvent) {\n      springNext.current = true;\n      downAt.current = { x: event.clientX, y: event.clientY };\n    },\n    move(event: PointerEvent) {\n      const d = downAt.current;\n      if (!d) return;\n      if (Math.hypot(event.clientX - d.x, event.clientY - d.y) > DRAG_SLOP) {\n        springNext.current = false;\n        downAt.current = null;\n      }\n    },\n    up() {\n      downAt.current = null;\n    },\n    key() {\n      springNext.current = true;\n    },\n  };\n}\n\ntype JumpFlag = ReturnType<typeof useJumpFlag>;\n\n/* -------------------------------------------------------------------------- */\n/*                                ColorPicker                                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface ColorPickerProps {\n  /** Controlled hex color (`#rrggbb`, or `#rrggbbaa` with `alpha`). */\n  value?: string;\n  /** Uncontrolled initial color. Default `#6366f1`. */\n  defaultValue?: string;\n  /** Receives lowercase hex; 8-digit only when `alpha` is on and alpha < 1. */\n  onChange?: (hex: string) => void;\n  /** Show the alpha slider and emit 8-digit hex when translucent. */\n  alpha?: boolean;\n  /** Preset colors. Pass `[]` to hide the row. */\n  swatches?: string[];\n  /** Show the hex text field. Default true. */\n  showInput?: boolean;\n  /** Show an eyedropper button where the browser supports the EyeDropper API. Default true. */\n  eyeDropper?: boolean;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function ColorPicker({\n  value: valueProp,\n  defaultValue = \"#6366f1\",\n  onChange,\n  alpha = false,\n  swatches = DEFAULT_SWATCHES,\n  showInput = true,\n  eyeDropper = true,\n  disabled = false,\n  className,\n}: ColorPickerProps) {\n  const uid = useId();\n  const reduce = useReducedMotion() ?? false;\n  const jump = useJumpFlag();\n\n  // HSV is the source of truth so hue survives trips through gray and black.\n  const [hsva, setHsva] = useState<HSVA>(\n    () =>\n      hexToHsva(valueProp ?? defaultValue) ?? { h: 239, s: 0.6, v: 0.95, a: 1 },\n  );\n  const hex = hsvaToHex(hsva, alpha);\n  const lastEmitted = useRef(hex);\n\n  // Pull in outside changes; ignore the parent echoing our own onChange.\n  useEffect(() => {\n    if (valueProp === undefined) return;\n    const parsed = parseHex(valueProp);\n    if (!parsed) return;\n    const normalized = rgbToHex(parsed, alpha);\n    if (normalized === lastEmitted.current) return;\n    lastEmitted.current = normalized;\n    setHsva((prev) => rgbToHsv(parsed, prev.h));\n  }, [valueProp, alpha]);\n\n  const update = (next: HSVA) => {\n    setHsva(next);\n    const out = hsvaToHex(next, alpha);\n    if (out === lastEmitted.current) return;\n    lastEmitted.current = out;\n    onChange?.(out);\n  };\n\n  const rgb = hsvToRgb(hsva);\n  const solid = `rgb(${rgb.r} ${rgb.g} ${rgb.b})`;\n\n  return (\n    <div\n      className={cn(\n        \"flex w-64 flex-col gap-3\",\n        disabled && \"pointer-events-none opacity-60\",\n        className,\n      )}\n    >\n      <SaturationArea\n        hsva={hsva}\n        solid={solid}\n        jump={jump}\n        reduce={reduce}\n        disabled={disabled}\n        onChange={update}\n      />\n\n      <div className=\"flex items-center gap-3\">\n        <div\n          className=\"relative size-9 shrink-0 overflow-hidden rounded-full ring-1 ring-border\"\n          style={CHECKER}\n        >\n          <span\n            className=\"absolute inset-0 transition-colors duration-150\"\n            style={{ backgroundColor: `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${hsva.a})` }}\n          />\n        </div>\n        <div className=\"flex flex-1 flex-col gap-2.5\">\n          <ChannelSlider\n            label=\"Hue\"\n            value={Math.round(hsva.h)}\n            max={360}\n            valueText={(v) => `Hue ${v}°`}\n            background={HUE_GRADIENT}\n            thumbColor={`hsl(${hsva.h} 100% 50%)`}\n            jump={jump}\n            reduce={reduce}\n            disabled={disabled}\n            onChange={(h) => update({ ...hsva, h })}\n          />\n          {alpha ? (\n            <ChannelSlider\n              label=\"Alpha\"\n              value={Math.round(hsva.a * 100)}\n              max={100}\n              valueText={(v) => `Opacity ${v}%`}\n              background={`linear-gradient(to right, transparent, ${solid})`}\n              checker\n              thumbColor={`rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${hsva.a})`}\n              jump={jump}\n              reduce={reduce}\n              disabled={disabled}\n              onChange={(a) => update({ ...hsva, a: a / 100 })}\n            />\n          ) : null}\n        </div>\n      </div>\n\n      {showInput ? (\n        <HexField\n          id={`${uid}-hex`}\n          hex={hex}\n          alpha={alpha}\n          disabled={disabled}\n          reduce={reduce}\n          eyeDropper={eyeDropper}\n          onCommit={(rgba) => {\n            jump.key();\n            update(rgbToHsv(alpha ? rgba : { ...rgba, a: 1 }, hsva.h));\n          }}\n        />\n      ) : null}\n\n      {swatches.length ? (\n        <fieldset className=\"m-0 flex min-w-0 flex-wrap gap-2 border-0 p-0\">\n          <legend className=\"sr-only\">Preset colors</legend>\n          {swatches.map((swatch) => {\n            const parsed = parseHex(swatch);\n            if (!parsed) return null;\n            const swatchHex = rgbToHex(parsed, alpha);\n            const selected =\n              swatchHex === hex ||\n              (parsed.a === 1 && rgbToHex(parsed) === hsvaToHex(hsva));\n            return (\n              <motion.button\n                key={swatch}\n                type=\"button\"\n                disabled={disabled}\n                aria-label={swatchHex}\n                aria-pressed={selected}\n                whileTap={reduce ? undefined : { scale: 0.88 }}\n                onClick={() => {\n                  jump.key();\n                  update(\n                    rgbToHsv(\n                      alpha ? parsed : { ...parsed, a: 1 },\n                      hsva.h,\n                    ),\n                  );\n                }}\n                className=\"relative size-6 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n              >\n                <span\n                  className=\"absolute inset-0 overflow-hidden rounded-full ring-1 ring-inset ring-black/10\"\n                  style={CHECKER}\n                >\n                  <span\n                    className=\"absolute inset-0\"\n                    style={{ backgroundColor: swatch }}\n                  />\n                </span>\n                {selected ? (\n                  <motion.span\n                    layoutId={`${uid}-swatch-ring`}\n                    transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                    className=\"pointer-events-none absolute -inset-1 rounded-full border-2 border-foreground\"\n                  />\n                ) : null}\n              </motion.button>\n            );\n          })}\n        </fieldset>\n      ) : null}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Saturation area                               */\n/* -------------------------------------------------------------------------- */\n\nfunction SaturationArea({\n  hsva,\n  solid,\n  jump,\n  reduce,\n  disabled,\n  onChange,\n}: {\n  hsva: HSVA;\n  solid: string;\n  jump: JumpFlag;\n  reduce: boolean;\n  disabled: boolean;\n  onChange: (next: HSVA) => void;\n}) {\n  const areaRef = useRef<HTMLDivElement>(null);\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const dragging = useRef(false);\n  const { width, height } = useElementWidth(areaRef);\n  const x = useThumb(hsva.s * width, jump.springNext, reduce);\n  const y = useThumb((1 - hsva.v) * height, jump.springNext, reduce);\n\n  const fromPointer = (clientX: number, clientY: number) => {\n    const rect = areaRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0 || rect.height === 0) return;\n    onChange({\n      ...hsva,\n      s: clamp((clientX - rect.left) / rect.width, 0, 1),\n      v: 1 - clamp((clientY - rect.top) / rect.height, 0, 1),\n    });\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const big = e.shiftKey ? 0.1 : 0.01;\n    const moves: Record<string, [number, number]> = {\n      ArrowLeft: [-big, 0],\n      ArrowRight: [big, 0],\n      ArrowUp: [0, big],\n      ArrowDown: [0, -big],\n      PageUp: [0, 0.1],\n      PageDown: [0, -0.1],\n      Home: [-1, 0],\n      End: [1, 0],\n    };\n    const move = moves[e.key];\n    if (!move) return;\n    e.preventDefault();\n    jump.key();\n    onChange({\n      ...hsva,\n      s: clamp(hsva.s + move[0], 0, 1),\n      v: clamp(hsva.v + move[1], 0, 1),\n    });\n  };\n\n  const s = Math.round(hsva.s * 100);\n  const v = Math.round(hsva.v * 100);\n\n  return (\n    // Pointer surface only — the focusable slider thumb inside carries role,\n    // value and keyboard.\n    <div\n      ref={areaRef}\n      onPointerDown={(e) => {\n        if (disabled || e.button !== 0) return;\n        dragging.current = true;\n        jump.down(e);\n        capturePointer(e.currentTarget, e.pointerId);\n        thumbRef.current?.focus({ preventScroll: true });\n        fromPointer(e.clientX, e.clientY);\n      }}\n      onPointerMove={(e) => {\n        if (!dragging.current) return;\n        jump.move(e);\n        fromPointer(e.clientX, e.clientY);\n      }}\n      onPointerUp={(e) => {\n        dragging.current = false;\n        jump.up();\n        releasePointer(e.currentTarget, e.pointerId);\n      }}\n      onPointerCancel={() => {\n        dragging.current = false;\n        jump.up();\n      }}\n      className={cn(\n        \"relative h-40 w-full cursor-crosshair touch-none rounded-xl\",\n        TOUCH_GESTURE_CLASS,\n      )}\n      style={{ backgroundColor: `hsl(${hsva.h} 100% 50%)` }}\n    >\n      <div className=\"pointer-events-none absolute inset-0 rounded-xl bg-linear-to-r from-white to-transparent\" />\n      <div className=\"pointer-events-none absolute inset-0 rounded-xl bg-linear-to-t from-black to-transparent\" />\n      <div className=\"pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-black/10\" />\n      <motion.div\n        ref={thumbRef}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label=\"Saturation and brightness\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={s}\n        aria-valuetext={`Saturation ${s}%, brightness ${v}%`}\n        aria-disabled={disabled || undefined}\n        onKeyDown={onKeyDown}\n        style={{ x, y, backgroundColor: solid, opacity: width ? 1 : 0 }}\n        className=\"absolute left-0 top-0 -ml-2 -mt-2 size-4 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[box-shadow] focus-visible:ring-4 focus-visible:ring-white/60\"\n      />\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               Channel slider                               */\n/* -------------------------------------------------------------------------- */\n\nfunction ChannelSlider({\n  label,\n  value,\n  max,\n  valueText,\n  background,\n  checker = false,\n  thumbColor,\n  jump,\n  reduce,\n  disabled,\n  onChange,\n}: {\n  label: string;\n  value: number;\n  max: number;\n  valueText: (v: number) => string;\n  background: string;\n  checker?: boolean;\n  thumbColor: string;\n  jump: JumpFlag;\n  reduce: boolean;\n  disabled: boolean;\n  onChange: (v: number) => void;\n}) {\n  const { trackProps, sliderProps, percent } = useSlider({\n    value,\n    onValueChange: onChange,\n    min: 0,\n    max,\n    step: 1,\n    disabled,\n    \"aria-label\": label,\n    formatValueText: valueText,\n  });\n  const { width } = useElementWidth(trackProps.ref);\n  const x = useThumb((percent / 100) * width, jump.springNext, reduce);\n\n  return (\n    <div\n      {...trackProps}\n      onPointerDown={(e) => {\n        if (e.button !== 0) return;\n        jump.down(e);\n        trackProps.onPointerDown(e);\n      }}\n      onPointerMove={(e) => {\n        jump.move(e);\n        trackProps.onPointerMove(e);\n      }}\n      onPointerUp={(e) => {\n        jump.up();\n        trackProps.onPointerUp(e);\n      }}\n      className={cn(\n        \"relative h-3 w-full cursor-pointer touch-none rounded-full\",\n        TOUCH_GESTURE_CLASS,\n      )}\n      style={checker ? CHECKER : undefined}\n    >\n      <div\n        className=\"pointer-events-none absolute inset-0 rounded-full ring-1 ring-inset ring-black/10\"\n        style={{ backgroundImage: background }}\n      />\n      <motion.div\n        {...sliderProps}\n        onKeyDown={(e) => {\n          jump.key();\n          sliderProps.onKeyDown(e);\n        }}\n        style={{ x, opacity: width ? 1 : 0 }}\n        className=\"absolute left-0 top-1/2 -ml-2.5 -mt-2.5 size-5 overflow-hidden rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 focus-visible:ring-4 focus-visible:ring-white/60\"\n      >\n        <span className=\"absolute inset-0\" style={checker ? CHECKER : undefined} />\n        <span\n          className=\"absolute inset-0\"\n          style={{ backgroundColor: thumbColor }}\n        />\n      </motion.div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Hex field                                 */\n/* -------------------------------------------------------------------------- */\n\ninterface EyeDropperCtor {\n  new (): { open: () => Promise<{ sRGBHex: string }> };\n}\n\nfunction HexField({\n  id,\n  hex,\n  alpha,\n  disabled,\n  reduce,\n  eyeDropper,\n  onCommit,\n}: {\n  id: string;\n  hex: string;\n  alpha: boolean;\n  disabled: boolean;\n  reduce: boolean;\n  eyeDropper: boolean;\n  onCommit: (rgba: RGBA) => void;\n}) {\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const [draft, setDraft] = useState<string | null>(null);\n  // Feature-detected after mount so server and client render the same markup.\n  const [dropper, setDropper] = useState<EyeDropperCtor | null>(null);\n\n  useEffect(() => {\n    if (!eyeDropper) return;\n    const ctor = (window as unknown as { EyeDropper?: EyeDropperCtor })\n      .EyeDropper;\n    if (ctor) setDropper(() => ctor);\n  }, [eyeDropper]);\n\n  const shown = draft ?? hex.slice(1).toUpperCase();\n  const invalid = draft !== null && parseHex(draft) === null;\n\n  const shake = () => {\n    if (!reduce && fieldRef.current) {\n      animate(fieldRef.current, SHAKE, { duration: 0.35, ease: EASE_OUT });\n    }\n  };\n\n  const settle = () => {\n    if (draft === null) return;\n    if (invalid) shake();\n    setDraft(null);\n  };\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <motion.div\n        ref={fieldRef}\n        className={cn(\n          \"flex h-9 flex-1 items-center rounded-lg border border-border bg-background pl-2.5 text-sm transition-colors duration-150 focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40\",\n          invalid &&\n            \"border-destructive focus-within:border-destructive focus-within:ring-destructive/25\",\n        )}\n      >\n        <label htmlFor={id} className=\"select-none text-muted-foreground\">\n          <span aria-hidden>#</span>\n          <span className=\"sr-only\">Hex color</span>\n        </label>\n        <input\n          id={id}\n          type=\"text\"\n          value={shown}\n          disabled={disabled}\n          maxLength={alpha ? 9 : 7}\n          spellCheck={false}\n          autoComplete=\"off\"\n          aria-invalid={invalid || undefined}\n          onChange={(e) => {\n            const next = e.target.value.replace(/^#/, \"\");\n            setDraft(next);\n            // Only complete 6/8-digit codes apply live; short forms wait for\n            // commit so typing \"abc\" on the way to \"abcdef\" doesn't flash.\n            const ok = next.length === 6 || (alpha && next.length === 8);\n            const parsed = ok ? parseHex(next) : null;\n            if (parsed) onCommit(parsed);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.preventDefault();\n              const parsed = draft === null ? null : parseHex(draft);\n              if (parsed) onCommit(parsed);\n              settle();\n            } else if (e.key === \"Escape\") {\n              setDraft(null);\n            }\n          }}\n          onBlur={() => {\n            const parsed = draft === null ? null : parseHex(draft);\n            if (parsed) onCommit(parsed);\n            settle();\n          }}\n          className=\"h-full w-full min-w-0 bg-transparent pr-2.5 pl-0.5 font-mono uppercase tabular-nums text-foreground outline-none\"\n        />\n      </motion.div>\n      {dropper ? (\n        <motion.button\n          type=\"button\"\n          aria-label=\"Pick a color from the screen\"\n          disabled={disabled}\n          whileTap={reduce ? undefined : { scale: 0.9 }}\n          onClick={async () => {\n            try {\n              const result = await new dropper().open();\n              const parsed = parseHex(result.sRGBHex);\n              if (parsed) onCommit(parsed);\n            } catch {\n              // User dismissed the eyedropper.\n            }\n          }}\n          className=\"grid size-9 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <Pipette className=\"size-4\" aria-hidden />\n        </motion.button>\n      ) : null}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                             ColorPickerPopover                             */\n/* -------------------------------------------------------------------------- */\n\nexport interface ColorPickerPopoverProps\n  extends Omit<ColorPickerProps, \"className\"> {\n  /** Controlled open state. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Visible label next to the swatch in the trigger. */\n  label?: string;\n  side?: \"top\" | \"bottom\";\n  align?: \"start\" | \"end\";\n  className?: string;\n  /** Class for the picker inside the panel. */\n  pickerClassName?: string;\n}\n\n/** A swatch trigger that morphs a `ColorPicker` open in a `MorphPopover`. */\nexport function ColorPickerPopover({\n  open,\n  defaultOpen,\n  onOpenChange,\n  label,\n  side = \"bottom\",\n  align = \"start\",\n  className,\n  pickerClassName,\n  value: valueProp,\n  defaultValue = \"#6366f1\",\n  onChange,\n  disabled,\n  alpha,\n  ...pickerProps\n}: ColorPickerPopoverProps) {\n  const [internal, setInternal] = useState(defaultValue);\n  const value = valueProp ?? internal;\n  const swatch = useMemo(() => parseHex(value), [value]);\n\n  return (\n    <MorphPopover\n      open={open}\n      defaultOpen={defaultOpen}\n      onOpenChange={onOpenChange}\n    >\n      <MorphPopoverTrigger>\n        <button\n          type=\"button\"\n          disabled={disabled}\n          aria-label={`${label ?? \"Color\"}: ${value}`}\n          className={cn(\n            \"inline-flex h-10 items-center gap-2.5 rounded-xl border border-border bg-background pr-3 pl-1.5 text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n            className,\n          )}\n        >\n          <span\n            className=\"relative size-7 overflow-hidden rounded-lg ring-1 ring-inset ring-black/10\"\n            style={CHECKER}\n          >\n            <span\n              className=\"absolute inset-0 transition-colors duration-150\"\n              style={{\n                backgroundColor: swatch\n                  ? `rgb(${swatch.r} ${swatch.g} ${swatch.b} / ${swatch.a})`\n                  : value,\n              }}\n            />\n          </span>\n          {label ? <span className=\"text-foreground\">{label}</span> : null}\n          <span className=\"font-mono uppercase tabular-nums text-muted-foreground\">\n            {value}\n          </span>\n        </button>\n      </MorphPopoverTrigger>\n      <MorphPopoverContent side={side} align={align} className=\"p-3\">\n        <ColorPicker\n          {...pickerProps}\n          alpha={alpha}\n          disabled={disabled}\n          value={value}\n          onChange={(hex) => {\n            if (valueProp === undefined) setInternal(hex);\n            onChange?.(hex);\n          }}\n          className={pickerClassName}\n        />\n      </MorphPopoverContent>\n    </MorphPopover>\n  );\n}\n"},{"path":"components/motion/popover-morph.tsx","type":"registry:component","target":"@components/motion/popover-morph.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n};\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child.props.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergeRefs(childRef, ctx.registerTrigger),\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipHidden(side: Side, align: Align, radius: number) {\n  const top = side === \"bottom\" ? \"0%\" : \"92%\";\n  const bottom = side === \"bottom\" ? \"92%\" : \"0%\";\n  const right = align === \"end\" ? \"0%\" : \"92%\";\n  const left = align === \"end\" ? \"92%\" : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\nconst clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  className?: string;\n}\n\nexport function MorphPopoverContent({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [portalReady, setPortalReady] = useState(false);\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    portalReady && ctx.open,\n  );\n\n  useEffect(() => setPortalReady(true), []);\n  const left = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  const top = layout\n    ? side === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : layout.trigger.top - layout.content.height - sideOffset\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },\n        show: { opacity: 1, scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipHidden(side, align, radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipShown(radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n\n  // Keep the server and first client render identical, then mount the portal.\n  if (!portalReady) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open ? (\n        <motion.div\n          data-morph-popover-portal=\"\"\n          // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n          // clipped shape below (box-shadow would just get clipped away).\n          variants={wrap}\n          initial={reduce ? { opacity: 0 } : \"hidden\"}\n          animate={reduce ? { opacity: 1 } : \"show\"}\n          exit={reduce ? { opacity: 0 } : \"hidden\"}\n          transition={reduce ? { duration: 0.12 } : undefined}\n          style={{\n            left,\n            top,\n            visibility: layout ? \"visible\" : \"hidden\",\n            transformOrigin: originFor(side, align),\n          }}\n          className=\"fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]\"\n        >\n          <motion.div\n            ref={ctx.contentRef}\n            id={ctx.contentId}\n            role=\"dialog\"\n            aria-labelledby={ctx.triggerId}\n            variants={clip}\n            style={{ borderRadius: radius }}\n            className={cn(\n              \"overflow-hidden border border-border bg-background\",\n              className,\n            )}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\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-slider.ts","type":"registry:hook","target":"@lib/hooks/use-slider.ts","content":"\"use client\";\n\nimport { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from \"react\";\nimport { capturePointer, releasePointer } from \"@/lib/touch\";\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Nearest legal value on [min, max] for the given step. max counts as a\n * candidate when the step does not divide the range, so a pointer near the end\n * does not snap back onto the last whole step. */\nexport function snapSliderValue(next: number, min: number, max: number, step: number): number {\n  // Neither case has a grid to walk. An empty range has exactly one legal\n  // point, and a non-positive step only needs a clamp, which also keeps the\n  // division below away from zero.\n  if (!(max > min)) return min;\n  if (!(step > 0)) return clamp(next, min, max);\n  const whole = Math.floor(Number(((max - min) / step).toFixed(6)));\n  const lastWhole = Number((min + whole * step).toFixed(6));\n  const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);\n  const snapped =\n    lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;\n  return Number(snapped.toFixed(6));\n}\n\nexport interface SliderOptions {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  /** Announced instead of the raw number — pass one when the value carries a\n   * unit or a suffix (\"72.5 kg\", \"35%\"); a bare number needs no valueText. */\n  formatValueText?: (value: number) => string;\n}\n\n/**\n * Shared value + input plumbing for slider designs: controlled/uncontrolled\n * value, step snapping, pointer-capture drag along a track and arrow-key\n * control. Visuals and motion live in the component; this only owns the number.\n */\nexport function useSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  formatValueText,\n}: SliderOptions) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const sliderEl = useRef<HTMLElement | null>(null);\n  // The state drives visuals. Move reads this ref instead, so the first\n  // pointermove after pointerdown does not have to wait on a re-render.\n  const draggingRef = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const controlled = value !== undefined;\n  // Collapse inverted or empty ranges and non-positive steps here, so that\n  // percent, ticks and the keyboard maths never divide by zero or walk a\n  // NaN grid.\n  const lo = min;\n  const hi = max > min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const current = clamp(controlled ? value : internal, lo, hi);\n  const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = snapSliderValue(next, lo, hi, stride);\n      if (!controlled) setInternal(clean);\n      onValueChange?.(clean);\n    },\n    [controlled, onValueChange, lo, hi, stride],\n  );\n\n  const commitFromX = useCallback(\n    (clientX: number) => {\n      const rect = trackRef.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0) return;\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      commit(lo + ratio * (hi - lo));\n    },\n    [commit, lo, hi],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      // Start the drag first: capture is a convenience, and a browser that\n      // refuses it — or a test DOM that has no pointer capture at all — must\n      // not take the drag down with it.\n      draggingRef.current = true;\n      setDragging(true);\n      capturePointer(event.currentTarget, event.pointerId);\n      // A click on the track should land keyboard focus on the handle.\n      sliderEl.current?.focus({ preventScroll: true });\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current || disabled) return;\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    releasePointer(event.currentTarget, event.pointerId);\n    draggingRef.current = false;\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowRight: current + stride,\n        ArrowUp: current + stride,\n        ArrowLeft: current - stride,\n        ArrowDown: current - stride,\n        PageUp: current + stride * 10,\n        PageDown: current - stride * 10,\n        Home: lo,\n        End: hi,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        commit(map[event.key]);\n      }\n    },\n    [disabled, current, stride, lo, hi, commit],\n  );\n\n  return {\n    current,\n    percent,\n    dragging,\n    min: lo,\n    max: hi,\n    step: stride,\n    commit,\n    /** Pointer handlers for the track element — drag anywhere on it. */\n    trackProps: {\n      ref: trackRef,\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: endDrag,\n      onPointerCancel: endDrag,\n      onLostPointerCapture: endDrag,\n    },\n    /** ARIA + keyboard props for the focusable slider element. */\n    sliderProps: {\n      // Callback keeps the handle typed across button/div/motion hosts.\n      ref: (node: HTMLElement | null) => {\n        sliderEl.current = node;\n      },\n      role: \"slider\" as const,\n      tabIndex: disabled ? -1 : 0,\n      \"aria-label\": ariaLabel,\n      \"aria-valuemin\": lo,\n      \"aria-valuemax\": hi,\n      \"aria-valuenow\": current,\n      \"aria-valuetext\": formatValueText?.(current),\n      \"aria-disabled\": disabled || undefined,\n      onKeyDown,\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 * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\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"},{"path":"components/motion/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"}]}