{"slug":"color-picker","name":"Color Picker","description":"A popover picker with a loupe under your finger.","group":"Forms","detail_url":"https://beui.dev/native/r/color-picker","page_url":"https://beui.dev/react-native/color-picker","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/color-picker?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/color-picker","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-worklets","uniwind"],"install":["npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/color-picker/color.ts","content":"// Color math, matching the web version.\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\nexport const clamp = (n: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, n));\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 {\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/** `previous` keeps hue for greys and saturation for black, which RGB can't 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  const digits = match?.[1];\n  if (!digits) return null;\n  const hex =\n    digits.length <= 4 ? [...digits].map((c) => c + c).join(\"\") : digits;\n  const n = (i: number) => Number.parseInt(hex.slice(i, i + 2), 16);\n  return {\n    r: n(0),\n    g: n(2),\n    b: n(4),\n    a: hex.length === 8 ? round(n(6) / 255, 2) : 1,\n  };\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\nexport function 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\nexport function hslToHsv(\n  h: number,\n  s: number,\n  l: number,\n  previous: Hsva,\n  a: number,\n): 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\nexport const BLACK: Rgba = { r: 0, g: 0, b: 0, a: 1 };\n\n/** `rgba(…)` for a color, with an optional alpha override. */\nexport function rgbaString({ r, g, b, a }: Rgba, alpha = a) {\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n/** The fully saturated, fully bright color for a hue. */\nexport function hueColor(h: number) {\n  return rgbaString(hsvaToRgba({ h, s: 100, v: 100, a: 1 }));\n}\n","type":"component"},{"path":"src/components/color-picker/context.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { View, type ViewProps } from \"react-native\";\nimport { type SharedValue, useSharedValue } from \"react-native-reanimated\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport {\n  BLACK,\n  clamp,\n  formatHex,\n  type Hsva,\n  hsvaToRgba,\n  parseHex,\n  type Rgba,\n  rgbaToHsva,\n} from \"./color\";\n\n/** A frame in window coordinates. */\nexport type Frame = { x: number; y: number; width: number; height: number };\n\nexport type ColorPickerContextValue = {\n  hsva: Hsva;\n  rgba: Rgba;\n  hex: string;\n  alpha: boolean;\n  disabled: boolean;\n  reduce: boolean;\n  /** Sets the color live, clamping each channel. */\n  update: (next: Hsva) => void;\n  /** Reports the settled color through onValueCommit. */\n  commit: () => void;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  /** 0 closed to 1 open; drives the panel and its rows. */\n  progress: SharedValue<number>;\n  /** The trigger, which the panel lines up under. */\n  triggerRef: RefObject<View | null>;\n  triggerFrame: Frame | null;\n};\n\nconst ColorPickerContext = createContext<ColorPickerContextValue | null>(null);\n\nexport function usePickerContext(component: string) {\n  const context = useContext(ColorPickerContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <ColorPicker>`);\n  }\n  return context;\n}\n\n/** The picker's state, for custom parts. */\nexport function useColorPicker() {\n  const { hsva, rgba, hex, update, commit, open, setOpen } =\n    usePickerContext(\"useColorPicker\");\n  return { hsva, rgba, hex, update, commit, open, setOpen };\n}\n\nexport type ColorPickerProps = Omit<ViewProps, \"children\"> & {\n  /** Controlled hex color. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string;\n  /** Defaults to \"#3478f6\". */\n  defaultValue?: string;\n  /** Called on every change, such as each step of a drag. */\n  onValueChange?: (value: string) => void;\n  /** Called when a change settles: a drag ends, a field is submitted, a preset is picked. */\n  onValueCommit?: (value: string) => void;\n  /** Allows translucent colors and shows the opacity slider. Defaults to true. */\n  alpha?: boolean;\n  disabled?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Moves without springing, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  children: ReactNode;\n};\n\n/**\n * A color picker built from parts: ColorPickerTrigger opens ColorPickerContent,\n * which holds the area, sliders, fields and presets. The parts also work\n * inline, without a trigger.\n */\nexport function ColorPicker({\n  value,\n  defaultValue = \"#3478f6\",\n  onValueChange,\n  onValueCommit,\n  alpha = true,\n  disabled = false,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disableAnimation = false,\n  className = \"\",\n  children,\n  ...props\n}: ColorPickerProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const triggerRef = useRef<View>(null);\n  const [triggerFrame, setTriggerFrame] = useState<Frame | null>(null);\n  const progress = useSharedValue(defaultOpen ? 1 : 0);\n\n  const [openInternal, setOpenInternal] = useState(defaultOpen);\n  const open = openProp ?? openInternal;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (next === open || (next && disabled)) return;\n      const apply = () => {\n        if (openProp === undefined) setOpenInternal(next);\n        onOpenChange?.(next);\n      };\n      if (!next) {\n        apply();\n        return;\n      }\n      // Find the trigger on screen before the panel appears beside it.\n      if (!triggerRef.current) {\n        apply();\n        return;\n      }\n      triggerRef.current.measureInWindow((x, y, width, height) => {\n        setTriggerFrame({ x, y, width, height });\n        apply();\n      });\n    },\n    [disabled, onOpenChange, open, openProp],\n  );\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 didn't emit is read during render, so the\n  // thumbs never show 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  // Handlers read the latest emitted hex, which can be ahead of the render.\n  const hexRef = useRef(hex);\n  hexRef.current = hex;\n\n  const update = useCallback(\n    (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    [alpha, onValueChange],\n  );\n  const commit = useCallback(\n    () => onValueCommit?.(hexRef.current),\n    [onValueCommit],\n  );\n\n  const context = useMemo<ColorPickerContextValue>(\n    () => ({\n      hsva,\n      rgba,\n      hex,\n      alpha,\n      disabled,\n      reduce,\n      update,\n      commit,\n      open,\n      setOpen,\n      progress,\n      triggerRef,\n      triggerFrame,\n    }),\n    [\n      alpha,\n      commit,\n      disabled,\n      hex,\n      hsva,\n      open,\n      progress,\n      reduce,\n      rgba,\n      setOpen,\n      triggerFrame,\n      update,\n    ],\n  );\n\n  return (\n    <ColorPickerContext.Provider value={context}>\n      <View\n        {...props}\n        className={`${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      >\n        {children}\n      </View>\n    </ColorPickerContext.Provider>\n  );\n}\n","type":"component"},{"path":"src/components/color-picker/fields.tsx","content":"import { useState } from \"react\";\nimport {\n  Platform,\n  StyleSheet,\n  Text,\n  TextInput,\n  type TextInputProps,\n  View,\n} from \"react-native\";\nimport Animated from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useShake } from \"../../hooks/use-shake\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport {\n  clamp,\n  type Hsva,\n  hslToHsv,\n  hsvToHsl,\n  parseHex,\n  type Rgba,\n  rgbaToHsva,\n} from \"./color\";\nimport { usePickerContext } from \"./context\";\n\nconst SHAKE = [0, -5, 5, -3, 3, 0];\n\n// Web draws a focus outline; the field's border shows focus instead. React\n// Native's types don't list \"none\", but React Native Web passes it through.\nconst WEB_NO_OUTLINE =\n  Platform.OS === \"web\" ? { outlineStyle: \"none\" as \"solid\" } : null;\n\nfunction useFieldColors() {\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  return { foreground, muted };\n}\n\nexport type ColorPickerHexInputProps = Omit<\n  TextInputProps,\n  \"value\" | \"defaultValue\" | \"onChangeText\"\n> & { className?: string };\n\n/**\n * The hex field. It updates the color live once six or eight digits are in,\n * and shakes and reverts if what's left on submit isn't a color.\n */\nexport function ColorPickerHexInput({\n  className = \"\",\n  accessibilityLabel = \"Hex color\",\n  onBlur,\n  onSubmitEditing,\n  style,\n  ...props\n}: ColorPickerHexInputProps) {\n  const { hsva, hex, update, commit, disabled, reduce } = usePickerContext(\n    \"ColorPickerHexInput\",\n  );\n  const { foreground, muted } = useFieldColors();\n  const [draft, setDraft] = useState<string | null>(null);\n  const [focused, setFocused] = useState(false);\n  const { shake, style: shakeStyle } = useShake(SHAKE, 300, EASE_OUT);\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 if (!reduce) {\n      shake();\n    }\n    setDraft(null);\n  };\n\n  return (\n    <Animated.View\n      style={shakeStyle}\n      className={`min-w-0 flex-1 flex-row items-center rounded-lg border bg-background pl-3 ${focused ? \"border-border-strong\" : \"border-border\"} ${className}`}\n    >\n      <Text className=\"text-sm text-muted\">#</Text>\n      <TextInput\n        {...props}\n        accessibilityLabel={accessibilityLabel}\n        editable={!disabled}\n        autoCapitalize=\"characters\"\n        autoCorrect={false}\n        spellCheck={false}\n        maxLength={9}\n        returnKeyType=\"done\"\n        value={draft ?? hex.slice(1).toUpperCase()}\n        placeholderTextColor={muted}\n        onFocus={() => setFocused(true)}\n        onChangeText={(text) => {\n          const next = text.replace(/^#/, \"\");\n          setDraft(next);\n          // Live only at full length, so \"ABC\" on the way to \"ABCDEF\" doesn't\n          // 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          setFocused(false);\n          settle();\n          onBlur?.(event);\n        }}\n        onSubmitEditing={(event) => {\n          settle();\n          onSubmitEditing?.(event);\n        }}\n        style={[styles.field, { color: foreground }, WEB_NO_OUTLINE, style]}\n      />\n    </Animated.View>\n  );\n}\n\ntype ChannelSpec = {\n  key: string;\n  short: string;\n  name: string;\n  max: number;\n  value: number;\n  set: (n: number) => Hsva;\n};\n\nfunction ChannelField({ spec }: { spec: ChannelSpec }) {\n  const { update, commit, disabled } = usePickerContext(\"ColorPickerChannels\");\n  const { foreground } = useFieldColors();\n  const [draft, setDraft] = useState<string | null>(null);\n  const [focused, setFocused] = useState(false);\n  const shown = Math.round(spec.value);\n\n  return (\n    <View className=\"min-w-0 flex-1 items-center gap-1\">\n      <View\n        className={`h-9 w-full justify-center rounded-lg border bg-background ${focused ? \"border-border-strong\" : \"border-border\"}`}\n      >\n        <TextInput\n          accessibilityLabel={spec.name}\n          editable={!disabled}\n          keyboardType=\"number-pad\"\n          returnKeyType=\"done\"\n          maxLength={3}\n          value={draft ?? String(shown)}\n          onFocus={() => setFocused(true)}\n          onChangeText={(text) => {\n            const next = text.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            setFocused(false);\n            if (draft !== null) commit();\n            setDraft(null);\n          }}\n          style={[\n            styles.field,\n            styles.center,\n            { color: foreground },\n            WEB_NO_OUTLINE,\n          ]}\n        />\n      </View>\n      <Text\n        accessibilityElementsHidden\n        importantForAccessibility=\"no\"\n        className=\"text-[11px] font-medium text-muted\"\n      >\n        {spec.short}\n      </Text>\n    </View>\n  );\n}\n\nexport type ColorPickerChannelsProps = {\n  /** Which channels to edit. Defaults to \"rgb\". */\n  format?: \"rgb\" | \"hsl\";\n  className?: string;\n};\n\n/** Numeric RGB or HSL fields, plus opacity when the picker allows alpha. */\nexport function ColorPickerChannels({\n  format = \"rgb\",\n  className = \"\",\n}: ColorPickerChannelsProps) {\n  const { hsva, rgba, alpha } = usePickerContext(\"ColorPickerChannels\");\n  const fromRgb = (patch: Partial<Rgba>) =>\n    rgbaToHsva({ ...rgba, ...patch }, hsva);\n  let specs: ChannelSpec[];\n  if (format === \"hsl\") {\n    const hsl = hsvToHsl(hsva);\n    specs = [\n      {\n        key: \"h\",\n        short: \"H\",\n        name: \"Hue\",\n        max: 360,\n        value: hsl.h,\n        set: (n) => hslToHsv(n, hsl.s, hsl.l, hsva, hsva.a),\n      },\n      {\n        key: \"s\",\n        short: \"S\",\n        name: \"Saturation\",\n        max: 100,\n        value: hsl.s,\n        set: (n) => hslToHsv(hsl.h, n, hsl.l, hsva, hsva.a),\n      },\n      {\n        key: \"l\",\n        short: \"L\",\n        name: \"Lightness\",\n        max: 100,\n        value: hsl.l,\n        set: (n) => hslToHsv(hsl.h, hsl.s, n, hsva, hsva.a),\n      },\n    ];\n  } else {\n    specs = [\n      {\n        key: \"r\",\n        short: \"R\",\n        name: \"Red\",\n        max: 255,\n        value: rgba.r,\n        set: (n) => fromRgb({ r: n }),\n      },\n      {\n        key: \"g\",\n        short: \"G\",\n        name: \"Green\",\n        max: 255,\n        value: rgba.g,\n        set: (n) => fromRgb({ g: n }),\n      },\n      {\n        key: \"b\",\n        short: \"B\",\n        name: \"Blue\",\n        max: 255,\n        value: rgba.b,\n        set: (n) => fromRgb({ b: n }),\n      },\n    ];\n  }\n  if (alpha) {\n    specs.push({\n      key: \"a\",\n      short: \"A\",\n      name: \"Opacity percent\",\n      max: 100,\n      value: hsva.a * 100,\n      set: (n) => ({ ...hsva, a: n / 100 }),\n    });\n  }\n  return (\n    <View className={`min-w-0 flex-1 flex-row gap-1.5 ${className}`}>\n      {specs.map((spec) => (\n        <ChannelField key={`${format}-${spec.key}`} spec={spec} />\n      ))}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  field: {\n    flex: 1,\n    height: 34,\n    fontSize: 14,\n    paddingVertical: 0,\n    paddingHorizontal: 6,\n    textAlignVertical: \"center\",\n    includeFontPadding: false,\n    fontVariant: [\"tabular-nums\"],\n  },\n  center: { textAlign: \"center\" },\n});\n","type":"component"},{"path":"src/components/color-picker/index.ts","content":"export type { Hsva, Rgba } from \"./color\";\nexport { formatHex, hsvaToRgba, parseHex, rgbaToHsva } from \"./color\";\nexport type { ColorPickerProps } from \"./context\";\nexport { ColorPicker, useColorPicker } from \"./context\";\nexport type {\n  ColorPickerChannelsProps,\n  ColorPickerHexInputProps,\n} from \"./fields\";\nexport { ColorPickerChannels, ColorPickerHexInput } from \"./fields\";\nexport type {\n  ColorPickerContentProps,\n  ColorPickerTriggerProps,\n} from \"./popover\";\nexport { ColorPickerContent, ColorPickerTrigger } from \"./popover\";\nexport type {\n  ColorPickerPresetProps,\n  ColorPickerPresetsProps,\n} from \"./presets\";\nexport { ColorPickerPreset, ColorPickerPresets } from \"./presets\";\nexport type { ColorPickerAreaProps, ColorPickerSliderProps } from \"./surfaces\";\nexport {\n  ColorPickerAlpha,\n  ColorPickerArea,\n  ColorPickerHue,\n  ColorPickerSwatch,\n} from \"./surfaces\";\n","type":"component"},{"path":"src/components/color-picker/popover.tsx","content":"import {\n  Children,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\nimport {\n  Keyboard,\n  Modal,\n  Platform,\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  useWindowDimensions,\n  View,\n} from \"react-native\";\nimport { GestureHandlerRootView } from \"react-native-gesture-handler\";\nimport Animated, {\n  cancelAnimation,\n  Extrapolation,\n  interpolate,\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport { usePickerContext } from \"./context\";\nimport { Checkerboard } from \"./surfaces\";\n\n// Opening springs the panel out of the trigger's corner, quick and lightly\n// damped, like a native popover. Closing runs the same path back as a short\n// timed fade, so it has a fixed end: the modal is gone the moment it's done,\n// and the next tap lands on the page instead of an invisible overlay.\nconst OPEN = { stiffness: 380, damping: 30, mass: 0.8 };\nconst CLOSE = { duration: 160, easing: EASE_OUT };\nconst REDUCED = { duration: 150 };\nconst PANEL_WIDTH = 288;\nconst GUTTER = 8;\nconst GAP = 8;\nconst EDGE = 16;\nconst CHIP = 24;\nconst CHIP_RADIUS = 6;\nconst KEYBOARD_SHIFT = { duration: 220, easing: EASE_OUT };\n\n/** The on-screen keyboard's height while `active`, or 0. */\nfunction useKeyboardHeight(active: boolean) {\n  const [height, setHeight] = useState(0);\n  useEffect(() => {\n    if (!active) return;\n    const ios = Platform.OS === \"ios\";\n    const show = Keyboard.addListener(\n      ios ? \"keyboardWillShow\" : \"keyboardDidShow\",\n      (event) => setHeight(event.endCoordinates.height),\n    );\n    const hide = Keyboard.addListener(\n      ios ? \"keyboardWillHide\" : \"keyboardDidHide\",\n      () => setHeight(0),\n    );\n    return () => {\n      show.remove();\n      hide.remove();\n    };\n  }, [active]);\n  return height;\n}\n\nexport type ColorPickerTriggerProps = Omit<\n  PressableProps,\n  \"children\" | \"style\"\n> & {\n  /** Replaces the hex label beside the chip. */\n  children?: ReactNode;\n  className?: string;\n};\n\n/** A color chip and the hex. Opens ColorPickerContent. */\nexport function ColorPickerTrigger({\n  children,\n  className = \"\",\n  onPress,\n  accessibilityLabel,\n  ...props\n}: ColorPickerTriggerProps) {\n  const { hex, open, setOpen, disabled, triggerRef } =\n    usePickerContext(\"ColorPickerTrigger\");\n  const alpha = hex.slice(7);\n\n  return (\n    <Pressable\n      {...props}\n      ref={triggerRef}\n      collapsable={false}\n      accessibilityRole=\"button\"\n      accessibilityLabel={accessibilityLabel ?? `Color, ${hex.toUpperCase()}`}\n      accessibilityHint={open ? undefined : \"Opens the color picker\"}\n      aria-expanded={open}\n      aria-disabled={disabled}\n      disabled={disabled}\n      onPress={(event) => {\n        onPress?.(event);\n        if (!event.defaultPrevented) setOpen(!open);\n      }}\n      className={`h-11 flex-row items-center gap-2.5 self-start rounded-xl border border-border bg-background pr-3.5 pl-2.5 active:opacity-80 ${className}`}\n    >\n      <View style={styles.chip}>\n        <Checkerboard width={CHIP} height={CHIP} />\n        <View\n          style={[\n            StyleSheet.absoluteFill,\n            styles.edge,\n            { backgroundColor: hex },\n          ]}\n        />\n      </View>\n      {children ?? (\n        <Text className=\"font-mono text-sm uppercase text-foreground tabular-nums\">\n          {hex.slice(0, 7)}\n          {alpha ? <Text className=\"text-muted\">{alpha}</Text> : null}\n        </Text>\n      )}\n    </Pressable>\n  );\n}\n\n/** One row of the panel, settling in just behind the one above it. */\nfunction Row({\n  index,\n  progress,\n  children,\n}: {\n  index: number;\n  progress: SharedValue<number>;\n  children: ReactNode;\n}) {\n  const style = useAnimatedStyle(() => {\n    const start = index * 0.07;\n    const q = interpolate(\n      progress.value,\n      [start, start + 0.6],\n      [0, 1],\n      Extrapolation.CLAMP,\n    );\n    return { opacity: q, transform: [{ translateY: -4 * (1 - q) }] };\n  });\n  return <Animated.View style={style}>{children}</Animated.View>;\n}\n\nexport type ColorPickerContentProps = {\n  children: ReactNode;\n  /** Names the panel for screen readers. Defaults to \"Color picker\". */\n  accessibilityLabel?: string;\n  className?: string;\n};\n\n/**\n * The panel ColorPickerTrigger opens. It floats beside the trigger: below it,\n * unless there's more room above. It springs out of the trigger's corner with\n * its rows settling in behind, and folds back the same way, quicker. A tap\n * outside, or Android's back button, closes it.\n */\nexport function ColorPickerContent({\n  children,\n  accessibilityLabel = \"Color picker\",\n  className = \"\",\n}: ColorPickerContentProps) {\n  const { open, setOpen, progress, reduce, triggerFrame } =\n    usePickerContext(\"ColorPickerContent\");\n  const window = useWindowDimensions();\n  const keyboard = useKeyboardHeight(open);\n  const [present, setPresent] = useState(open);\n  if (open && !present) setPresent(true);\n  // A modal takes a moment to appear. Waiting for it, and for the panel's\n  // first layout, means the opening plays where it can be seen.\n  const [shown, setShown] = useState(false);\n  if (!present && shown) setShown(false);\n  const [panelHeight, setPanelHeight] = useState(0);\n  const shift = useSharedValue(0);\n\n  const hide = useCallback(() => setPresent(false), []);\n\n  // onShow doesn't fire everywhere, such as on web; don't wait on it forever.\n  useEffect(() => {\n    if (!present || shown) return;\n    const timer = setTimeout(() => setShown(true), 300);\n    return () => clearTimeout(timer);\n  }, [present, shown]);\n\n  const ready = shown && panelHeight > 0;\n\n  useEffect(() => {\n    if (!present) return;\n    if (open && !ready) return;\n    cancelAnimation(progress);\n    if (open) {\n      progress.value = reduce ? withTiming(1, REDUCED) : withSpring(1, OPEN);\n      return;\n    }\n    Keyboard.dismiss();\n    progress.value = withTiming(0, reduce ? REDUCED : CLOSE, (finished) => {\n      if (finished) scheduleOnRN(hide);\n    });\n  }, [hide, open, present, progress, ready, reduce]);\n\n  // Side and position, from the trigger's frame.\n  const width = Math.min(PANEL_WIDTH, window.width - GUTTER * 2);\n  const trigger = triggerFrame ?? { x: GUTTER, y: 120, width: 0, height: 44 };\n  const left = Math.min(\n    Math.max(trigger.x, GUTTER),\n    window.width - width - GUTTER,\n  );\n  const below = window.height - (trigger.y + trigger.height) - GAP - EDGE;\n  const above = trigger.y - GAP - EDGE;\n  const isTop = panelHeight > 0 && below < panelHeight && above > below;\n  const top = isTop\n    ? trigger.y - GAP - panelHeight\n    : trigger.y + trigger.height + GAP;\n\n  // When typing in a field, lift the panel clear of the keyboard.\n  useEffect(() => {\n    const limit = window.height - keyboard - GUTTER;\n    const overflow = keyboard > 0 ? Math.max(0, top + panelHeight - limit) : 0;\n    shift.value = withTiming(-overflow, KEYBOARD_SHIFT);\n  }, [keyboard, panelHeight, shift, top, window.height]);\n\n  // The panel grows from the corner nearest the trigger's chip.\n  const panelStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    const settled = Math.min(1, Math.max(0, p));\n    return {\n      opacity: interpolate(p, [0, 0.5], [0, 1], Extrapolation.CLAMP),\n      transform: [\n        { translateY: shift.value + (isTop ? 8 : -8) * (1 - settled) },\n        { scale: reduce ? 1 : 0.9 + 0.1 * p },\n      ],\n    };\n  });\n\n  let row = 0;\n  const rows = Children.map(children, (child) => {\n    const index = row;\n    row += 1;\n    return (\n      <Row index={index} progress={progress}>\n        {child}\n      </Row>\n    );\n  });\n\n  return (\n    <Modal\n      transparent\n      visible={present}\n      animationType=\"none\"\n      statusBarTranslucent\n      navigationBarTranslucent\n      onShow={() => setShown(true)}\n      onRequestClose={() => setOpen(false)}\n    >\n      {/* Gestures inside a modal need their own root on Android. */}\n      <GestureHandlerRootView style={styles.root}>\n        <Pressable\n          accessibilityLabel=\"Close\"\n          accessibilityRole=\"button\"\n          style={StyleSheet.absoluteFill}\n          onPress={() => setOpen(false)}\n        />\n        <Animated.View\n          accessibilityViewIsModal\n          accessibilityLabel={accessibilityLabel}\n          onLayout={({ nativeEvent }) =>\n            setPanelHeight(nativeEvent.layout.height)\n          }\n          className={`gap-3 rounded-2xl border border-border bg-background p-3 ${className}`}\n          style={[\n            styles.panel,\n            { left, top, width },\n            isTop ? styles.fromBottom : styles.fromTop,\n            panelStyle,\n          ]}\n        >\n          {rows}\n        </Animated.View>\n      </GestureHandlerRootView>\n    </Modal>\n  );\n}\n\nconst styles = StyleSheet.create({\n  root: { flex: 1 },\n  panel: {\n    position: \"absolute\",\n    boxShadow: \"0 12px 32px -8px rgba(0, 0, 0, 0.25)\",\n  },\n  // The corner nearest the trigger's chip, at the panel's left edge.\n  fromTop: { transformOrigin: \"24px 0px\" },\n  fromBottom: { transformOrigin: \"24px 100%\" },\n  chip: {\n    width: CHIP,\n    height: CHIP,\n    borderRadius: CHIP_RADIUS,\n    overflow: \"hidden\",\n  },\n  edge: {\n    borderRadius: CHIP_RADIUS,\n    borderWidth: 1,\n    borderColor: \"rgba(0, 0, 0, 0.1)\",\n  },\n});\n","type":"component"},{"path":"src/components/color-picker/presets.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Pressable, StyleSheet, View } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { SPRING_LAYOUT, SPRING_PRESS } from \"../../styles/motion\";\nimport { formatHex, parseHex, rgbaToHsva } from \"./color\";\nimport { usePickerContext } from \"./context\";\nimport { Checkerboard } from \"./surfaces\";\n\nconst SIZE = 24;\nconst RING_GAP = 3;\n// The ring hops between swatches over a low arc, higher the further it goes.\nconst HOP = 0.15;\n\ntype Spot = { x: number; y: number };\n\nconst PresetsContext = createContext<{\n  place: (value: string, spot: Spot) => void;\n} | null>(null);\n\nexport type ColorPickerPresetsProps = {\n  children: ReactNode;\n  /** Names the group for screen readers, such as \"Presets\". */\n  accessibilityLabel?: string;\n  className?: string;\n};\n\n/** A row of preset swatches. A ring marks the one matching the current color. */\nexport function ColorPickerPresets({\n  children,\n  accessibilityLabel,\n  className = \"\",\n}: ColorPickerPresetsProps) {\n  const { hex, reduce } = usePickerContext(\"ColorPickerPresets\");\n  // On web, borders resolve per side.\n  const ringClass = useResolveClassNames(\"border-foreground/70\");\n  const ring = ringClass.borderColor ?? ringClass.borderTopColor;\n  const spots = useRef(new Map<string, Spot>());\n  const [version, setVersion] = useState(0);\n  const place = useCallback((value: string, spot: Spot) => {\n    const known = spots.current.get(value);\n    if (known && known.x === spot.x && known.y === spot.y) return;\n    spots.current.set(value, spot);\n    setVersion((n) => n + 1);\n  }, []);\n\n  const from = useSharedValue<Spot>({ x: 0, y: 0 });\n  const to = useSharedValue<Spot>({ x: 0, y: 0 });\n  const travel = useSharedValue(1);\n  const shown = useSharedValue(0);\n  const last = useRef<Spot | null>(null);\n\n  useEffect(() => {\n    const target = spots.current.get(hex);\n    if (!target) {\n      shown.value = withTiming(0, { duration: 120 });\n      last.current = null;\n      return;\n    }\n    const start = last.current;\n    last.current = target;\n    if (!start || reduce) {\n      from.value = target;\n      to.value = target;\n      travel.value = 1;\n      shown.value = withTiming(1, { duration: 120 });\n      return;\n    }\n    if (start.x === target.x && start.y === target.y) return;\n    from.value = start;\n    to.value = target;\n    travel.value = 0;\n    travel.value = withSpring(1, SPRING_LAYOUT);\n    shown.value = 1;\n  }, [from, hex, reduce, shown, to, travel, version]);\n\n  const ringStyle = useAnimatedStyle(() => {\n    const t = travel.value;\n    const a = from.value;\n    const b = to.value;\n    const distance = Math.hypot(b.x - a.x, b.y - a.y);\n    const arc =\n      HOP * distance * Math.sin(Math.PI * Math.min(1, Math.max(0, t)));\n    return {\n      opacity: shown.value,\n      transform: [\n        { translateX: a.x + (b.x - a.x) * t - RING_GAP },\n        { translateY: a.y + (b.y - a.y) * t - RING_GAP - arc },\n      ],\n    };\n  });\n\n  return (\n    <PresetsContext.Provider value={{ place }}>\n      <View\n        accessibilityLabel={accessibilityLabel}\n        className={`flex-row flex-wrap gap-2 ${className}`}\n      >\n        {children}\n        <Animated.View\n          pointerEvents=\"none\"\n          style={[styles.ring, { borderColor: ring }, ringStyle]}\n        />\n      </View>\n    </PresetsContext.Provider>\n  );\n}\n\nexport type ColorPickerPresetProps = {\n  /** The hex color this preset applies. */\n  value: string;\n  /** The color's name for screen readers. Defaults to the hex. */\n  label?: string;\n  onPress?: () => void;\n  className?: string;\n};\n\nexport function ColorPickerPreset({\n  value,\n  label,\n  onPress,\n  className = \"\",\n}: ColorPickerPresetProps) {\n  const { hsva, hex, update, commit, disabled, reduce } =\n    usePickerContext(\"ColorPickerPreset\");\n  const presets = useContext(PresetsContext);\n  const parsed = parseHex(value);\n  const normalized = parsed ? formatHex(parsed) : value;\n  const selected = normalized === hex;\n  const scale = useSharedValue(1);\n  const pressStyle = useAnimatedStyle(() => ({\n    transform: [{ scale: scale.value }],\n  }));\n\n  return (\n    <Pressable\n      accessibilityRole=\"button\"\n      accessibilityLabel={label ?? normalized}\n      accessibilityState={{ selected, disabled }}\n      disabled={disabled}\n      hitSlop={6}\n      onLayout={({ nativeEvent }) =>\n        presets?.place(normalized, {\n          x: nativeEvent.layout.x,\n          y: nativeEvent.layout.y,\n        })\n      }\n      onPressIn={() => {\n        if (!reduce) scale.value = withSpring(0.88, SPRING_PRESS);\n      }}\n      onPressOut={() => {\n        scale.value = withSpring(1, SPRING_PRESS);\n      }}\n      onPress={() => {\n        onPress?.();\n        if (!parsed) return;\n        update(rgbaToHsva(parsed, hsva));\n        commit();\n      }}\n      className={className}\n    >\n      <Animated.View style={[styles.swatch, pressStyle]}>\n        <Checkerboard width={SIZE} height={SIZE} />\n        <View style={[styles.color, { backgroundColor: normalized }]} />\n      </Animated.View>\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  swatch: {\n    width: SIZE,\n    height: SIZE,\n    borderRadius: SIZE / 2,\n    overflow: \"hidden\",\n  },\n  color: {\n    ...StyleSheet.absoluteFill,\n    borderRadius: SIZE / 2,\n    borderWidth: 1,\n    borderColor: \"rgba(0, 0, 0, 0.1)\",\n  },\n  ring: {\n    position: \"absolute\",\n    left: 0,\n    top: 0,\n    width: SIZE + RING_GAP * 2,\n    height: SIZE + RING_GAP * 2,\n    borderRadius: (SIZE + RING_GAP * 2) / 2,\n    borderWidth: 2,\n  },\n});\n","type":"component"},{"path":"src/components/color-picker/surfaces.tsx","content":"import { memo, useEffect, useRef } from \"react\";\nimport {\n  type AccessibilityActionEvent,\n  Platform,\n  StyleSheet,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { EASE_OUT, SPRING_GLIDE, SPRING_PRESS } from \"../../styles/motion\";\nimport { hueColor, rgbaString } from \"./color\";\nimport { usePickerContext } from \"./context\";\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 = { stiffness: 520, damping: 24, mass: 0.6 };\nconst LOUPE_DROP = { duration: 120, easing: EASE_OUT };\n// Drag speed, in track widths per second, swings the loupe like a pendulum.\nconst LOUPE_TILT = 16;\nconst LOUPE_TILT_SPEED = 4;\nconst THUMB = 18;\n// Sliders inset the thumb's path by half a thumb, so it never leaves the track.\nconst RAIL_INSET = THUMB / 2;\nconst CHECK = 4;\nconst CHECK_LIGHT = \"#fafafa\";\nconst CHECK_DARK = \"#d4d4d8\";\n\n/** A gradient fill, through experimental_backgroundImage on native. */\nexport function gradient(css: string) {\n  return Platform.OS === \"web\"\n    ? ({ backgroundImage: css } as object)\n    : { experimental_backgroundImage: css };\n}\n\n/**\n * The transparency checkerboard. React Native has no conic gradient, so it's\n * drawn from small squares, once per size.\n */\nexport const Checkerboard = memo(function Checkerboard({\n  width,\n  height,\n}: {\n  width: number;\n  height: number;\n}) {\n  const columns = Math.ceil(width / CHECK);\n  const rows = Math.ceil(height / CHECK);\n  const squares: { left: number; top: number }[] = [];\n  for (let row = 0; row < rows; row++) {\n    for (let column = row % 2; column < columns; column += 2) {\n      squares.push({ left: column * CHECK, top: row * CHECK });\n    }\n  }\n  return (\n    <View\n      pointerEvents=\"none\"\n      style={[StyleSheet.absoluteFill, { backgroundColor: CHECK_LIGHT }]}\n    >\n      {squares.map(({ left, top }) => (\n        <View key={`${left}-${top}`} style={[styles.check, { left, top }]} />\n      ))}\n    </View>\n  );\n});\n\n/** Where a touch lands on a track, kept on the UI thread while dragging. */\nfunction useTrack({\n  axes,\n  inset,\n  onPoint,\n  onEnd,\n  disabled,\n}: {\n  axes: \"x\" | \"xy\";\n  inset: number;\n  onPoint: (x: number, y: number) => void;\n  onEnd: () => void;\n  disabled: boolean;\n}) {\n  const width = useSharedValue(0);\n  const height = useSharedValue(0);\n  const x = useSharedValue(0);\n  const y = useSharedValue(0);\n  const dragging = useSharedValue(false);\n  const lift = useSharedValue(0);\n  const tilt = useSharedValue(0);\n\n  const place = (px: number, py: number) => {\n    \"worklet\";\n    const span = Math.max(1, width.value - inset * 2);\n    const nx = Math.min(1, Math.max(0, (px - inset) / span));\n    const ny =\n      axes === \"xy\"\n        ? Math.min(1, Math.max(0, py / Math.max(1, height.value)))\n        : 0;\n    x.value = nx;\n    y.value = ny;\n    scheduleOnRN(onPoint, nx, ny);\n  };\n\n  // A finger down jumps the thumb there; moving drags it.\n  const gesture = Gesture.Pan()\n    .enabled(!disabled)\n    .minDistance(0)\n    .onBegin((event) => {\n      dragging.value = true;\n      lift.value = withSpring(1, LOUPE_LIFT);\n      place(event.x, event.y);\n    })\n    .onUpdate((event) => {\n      place(event.x, event.y);\n      const speed = event.velocityX / Math.max(1, width.value);\n      tilt.value = withSpring(\n        Math.max(-1, Math.min(1, speed / LOUPE_TILT_SPEED)) * -LOUPE_TILT,\n        SPRING_PRESS,\n      );\n    })\n    .onFinalize(() => {\n      dragging.value = false;\n      lift.value = withTiming(0, LOUPE_DROP);\n      tilt.value = withSpring(0, SPRING_PRESS);\n      scheduleOnRN(onEnd);\n    });\n\n  return { gesture, width, height, x, y, dragging, lift, tilt };\n}\n\n/** Thumbs follow the color when it changes from elsewhere, gliding there. */\nfunction useFollow(\n  shared: SharedValue<number>,\n  target: number,\n  dragging: SharedValue<boolean>,\n  reduce: boolean,\n) {\n  useEffect(() => {\n    if (dragging.value) return;\n    shared.value = reduce ? target : withSpring(target, SPRING_GLIDE);\n  }, [dragging, reduce, shared, target]);\n}\n\nfunction Thumb({\n  x,\n  y,\n  width,\n  height,\n  inset,\n  lift,\n  tilt,\n  color,\n  reduce,\n}: {\n  x: SharedValue<number>;\n  y?: SharedValue<number>;\n  width: SharedValue<number>;\n  height: SharedValue<number>;\n  inset: number;\n  lift: SharedValue<number>;\n  tilt: SharedValue<number>;\n  color: string;\n  reduce: boolean;\n}) {\n  const position = useAnimatedStyle(() => {\n    const span = Math.max(0, width.value - inset * 2);\n    return {\n      transform: [\n        { translateX: inset + x.value * span - THUMB / 2 },\n        {\n          translateY: y\n            ? y.value * height.value - THUMB / 2\n            : height.value / 2 - THUMB / 2,\n        },\n      ],\n    };\n  });\n  // The thumb tucks in while the loupe is up, so the finger's color shows above.\n  const dot = useAnimatedStyle(() => ({\n    transform: [{ scale: reduce ? 1 : 1 - 0.22 * lift.value }],\n  }));\n  const loupe = useAnimatedStyle(() => {\n    const l = reduce ? 0 : lift.value;\n    return {\n      opacity: Math.min(1, Math.max(0, l)),\n      transform: [\n        { translateY: 16 * (1 - l) },\n        { rotate: `${tilt.value}deg` },\n        { scale: 0.3 + 0.7 * l },\n      ],\n    };\n  });\n\n  return (\n    <Animated.View pointerEvents=\"none\" style={[styles.thumbSlot, position]}>\n      <Animated.View style={[styles.loupe, loupe]}>\n        {/* A teardrop: a square with one sharp corner, turned to point down.\n            The color is the drop's own background, which follows its mixed\n            corners; iOS wouldn't clip a filled child to them. */}\n        <View style={[styles.drop, { backgroundColor: color }]} />\n      </Animated.View>\n      <Animated.View style={[styles.dot, { backgroundColor: color }, dot]} />\n    </Animated.View>\n  );\n}\n\nexport type ColorPickerAreaProps = Omit<ViewProps, \"children\"> & {\n  /** Accessible name. Defaults to \"Saturation and brightness\". */\n  label?: string;\n  className?: string;\n};\n\n/**\n * The saturation and brightness area. Drag anywhere; a loupe lifts above the\n * finger to show the color under it.\n */\nexport function ColorPickerArea({\n  label = \"Saturation and brightness\",\n  className = \"\",\n  style,\n  ...props\n}: ColorPickerAreaProps) {\n  const { hsva, rgba, update, commit, disabled, reduce } =\n    usePickerContext(\"ColorPickerArea\");\n  const latest = useRef(hsva);\n  latest.current = hsva;\n\n  const track = useTrack({\n    axes: \"xy\",\n    inset: 0,\n    onPoint: (x, y) =>\n      update({ ...latest.current, s: x * 100, v: (1 - y) * 100 }),\n    onEnd: commit,\n    disabled,\n  });\n  useFollow(track.x, hsva.s / 100, track.dragging, reduce);\n  useFollow(track.y, 1 - hsva.v / 100, track.dragging, reduce);\n\n  const s = Math.round(hsva.s);\n  const v = Math.round(hsva.v);\n  const nudge = (event: AccessibilityActionEvent) => {\n    const step = 5;\n    const name = event.nativeEvent.actionName;\n    const next =\n      name === \"increment\"\n        ? { ...hsva, s: hsva.s + step }\n        : name === \"decrement\"\n          ? { ...hsva, s: hsva.s - step }\n          : name === \"brighter\"\n            ? { ...hsva, v: hsva.v + step }\n            : { ...hsva, v: hsva.v - step };\n    update(next);\n    commit();\n  };\n\n  return (\n    <GestureDetector gesture={track.gesture}>\n      <Animated.View\n        {...props}\n        accessible\n        accessibilityRole=\"adjustable\"\n        accessibilityLabel={label}\n        aria-valuetext={`Saturation ${s}%, brightness ${v}%`}\n        accessibilityActions={[\n          { name: \"increment\", label: \"More saturated\" },\n          { name: \"decrement\", label: \"Less saturated\" },\n          { name: \"brighter\", label: \"Brighter\" },\n          { name: \"darker\", label: \"Darker\" },\n        ]}\n        onAccessibilityAction={nudge}\n        onLayout={(event) => {\n          track.width.value = event.nativeEvent.layout.width;\n          track.height.value = event.nativeEvent.layout.height;\n          props.onLayout?.(event);\n        }}\n        className={`h-40 w-full rounded-xl ${className}`}\n        style={[{ backgroundColor: hueColor(hsva.h) }, style]}\n      >\n        <View\n          pointerEvents=\"none\"\n          style={[\n            styles.fill,\n            styles.areaRound,\n            gradient(\n              \"linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))\",\n            ),\n          ]}\n        />\n        <View\n          pointerEvents=\"none\"\n          style={[\n            styles.fill,\n            styles.areaRound,\n            gradient(\n              \"linear-gradient(to top, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0))\",\n            ),\n          ]}\n        />\n        <View\n          pointerEvents=\"none\"\n          style={[styles.fill, styles.areaRound, styles.inset]}\n        />\n        <Thumb\n          x={track.x}\n          y={track.y}\n          width={track.width}\n          height={track.height}\n          inset={0}\n          lift={track.lift}\n          tilt={track.tilt}\n          color={rgbaString(rgba, 1)}\n          reduce={reduce}\n        />\n      </Animated.View>\n    </GestureDetector>\n  );\n}\n\nfunction ChannelSlider({\n  value,\n  max,\n  onValue,\n  label,\n  valueText,\n  background,\n  thumbColor,\n  checker = false,\n  className = \"\",\n}: {\n  value: number;\n  max: number;\n  onValue: (next: number) => void;\n  label: string;\n  valueText: string;\n  background: string;\n  thumbColor: string;\n  checker?: boolean;\n  className?: string;\n}) {\n  const { commit, disabled, reduce } = usePickerContext(\"ColorPicker slider\");\n  const track = useTrack({\n    axes: \"x\",\n    inset: RAIL_INSET,\n    onPoint: (x) => onValue(x * max),\n    onEnd: commit,\n    disabled,\n  });\n  useFollow(track.x, value / max, track.dragging, reduce);\n  const widthRef = useRef(0);\n\n  return (\n    <GestureDetector gesture={track.gesture}>\n      <View\n        accessible\n        accessibilityRole=\"adjustable\"\n        accessibilityLabel={label}\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={Math.round(value)}\n        aria-valuetext={valueText}\n        accessibilityActions={[{ name: \"increment\" }, { name: \"decrement\" }]}\n        onAccessibilityAction={(event) => {\n          const step = Math.max(1, Math.round(max / 36));\n          onValue(\n            value +\n              (event.nativeEvent.actionName === \"increment\" ? step : -step),\n          );\n          commit();\n        }}\n        onLayout={(event) => {\n          widthRef.current = event.nativeEvent.layout.width;\n          track.width.value = event.nativeEvent.layout.width;\n          track.height.value = event.nativeEvent.layout.height;\n        }}\n        // A tall touch area around a thin track.\n        hitSlop={{ top: 12, bottom: 12 }}\n        className={`h-3.5 w-full rounded-full ${className}`}\n      >\n        <View style={[styles.fill, styles.round, styles.clip]}>\n          {checker ? <Checkerboard width={320} height={14} /> : null}\n          <View style={[styles.fill, gradient(background)]} />\n          <View style={[styles.fill, styles.round, styles.inset]} />\n        </View>\n        <Thumb\n          x={track.x}\n          width={track.width}\n          height={track.height}\n          inset={RAIL_INSET}\n          lift={track.lift}\n          tilt={track.tilt}\n          color={thumbColor}\n          reduce={reduce}\n        />\n      </View>\n    </GestureDetector>\n  );\n}\n\nexport type ColorPickerSliderProps = { label?: string; className?: string };\n\nexport function ColorPickerHue({\n  label = \"Hue\",\n  className,\n}: ColorPickerSliderProps) {\n  const { hsva, update } = usePickerContext(\"ColorPickerHue\");\n  const latest = useRef(hsva);\n  latest.current = hsva;\n  return (\n    <ChannelSlider\n      value={hsva.h}\n      max={360}\n      onValue={(next) => update({ ...latest.current, h: next })}\n      label={label}\n      valueText={`${Math.round(hsva.h)} degrees`}\n      background=\"linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)\"\n      thumbColor={hueColor(hsva.h)}\n      className={className}\n    />\n  );\n}\n\n/** Renders nothing when the picker has `alpha={false}`. */\nexport function ColorPickerAlpha({\n  label = \"Opacity\",\n  className,\n}: ColorPickerSliderProps) {\n  const { hsva, rgba, alpha, update } = usePickerContext(\"ColorPickerAlpha\");\n  const latest = useRef(hsva);\n  latest.current = hsva;\n  if (!alpha) return null;\n  return (\n    <ChannelSlider\n      value={hsva.a * 100}\n      max={100}\n      onValue={(next) =>\n        update({ ...latest.current, a: Math.round(next) / 100 })\n      }\n      label={label}\n      valueText={`${Math.round(hsva.a * 100)}%`}\n      background={`linear-gradient(to right, ${rgbaString(rgba, 0)}, ${rgbaString(rgba, 1)})`}\n      thumbColor={rgbaString(rgba)}\n      checker\n      className={className}\n    />\n  );\n}\n\n/** The current color over a transparency checkerboard. Decorative. */\nexport function ColorPickerSwatch({ className = \"\" }: { className?: string }) {\n  const { hex } = usePickerContext(\"ColorPickerSwatch\");\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      className={`size-9 overflow-hidden rounded-lg ${className}`}\n    >\n      <Checkerboard width={36} height={36} />\n      <View\n        style={[\n          styles.fill,\n          styles.inset,\n          styles.swatchRound,\n          { backgroundColor: hex },\n        ]}\n      />\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  fill: { ...StyleSheet.absoluteFill },\n  round: { borderRadius: 999 },\n  clip: { overflow: \"hidden\" },\n  // A faint inner edge so light colors don't melt into the panel.\n  inset: { borderWidth: 1, borderColor: \"rgba(0, 0, 0, 0.1)\" },\n  areaRound: { borderRadius: 12 },\n  swatchRound: { borderRadius: 8 },\n  check: {\n    position: \"absolute\",\n    width: CHECK,\n    height: CHECK,\n    backgroundColor: CHECK_DARK,\n  },\n  thumbSlot: {\n    position: \"absolute\",\n    left: 0,\n    top: 0,\n    width: THUMB,\n    height: THUMB,\n  },\n  dot: {\n    width: THUMB,\n    height: THUMB,\n    borderRadius: THUMB / 2,\n    borderWidth: 3,\n    borderColor: \"#ffffff\",\n    boxShadow: \"0 0 0 1px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.25)\",\n  },\n  loupe: {\n    position: \"absolute\",\n    left: THUMB / 2 - 20,\n    bottom: THUMB + 12,\n    width: 40,\n    height: 40,\n    transformOrigin: \"50% 130%\",\n  },\n  drop: {\n    width: 40,\n    height: 40,\n    borderWidth: 3,\n    borderColor: \"#ffffff\",\n    borderTopLeftRadius: 20,\n    borderTopRightRadius: 20,\n    borderBottomRightRadius: 20,\n    borderBottomLeftRadius: 0,\n    transform: [{ rotate: \"-45deg\" }],\n    boxShadow: \"0 0 0 1px rgba(0, 0, 0, 0.1), 0 6px 16px rgba(0, 0, 0, 0.28)\",\n  },\n});\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/hooks/use-shake.ts","content":"import { useCallback } from \"react\";\nimport {\n  type EasingFunction,\n  type EasingFunctionFactory,\n  useAnimatedStyle,\n  useSharedValue,\n  withSequence,\n  withTiming,\n} from \"react-native-reanimated\";\n\n/**\n * Horizontal shake through `keyframes` (in points) over `duration` ms, split\n * evenly like a Motion keyframe array. Callers skip it under reduced motion.\n */\nexport function useShake(\n  keyframes: number[],\n  duration: number,\n  easing?: EasingFunction | EasingFunctionFactory,\n) {\n  const offset = useSharedValue(0);\n  const step = duration / Math.max(1, keyframes.length - 1);\n\n  const shake = useCallback(() => {\n    const [first = 0, ...rest] = keyframes;\n    offset.value = first;\n    offset.value = withSequence(\n      // An explicit `easing: undefined` would replace Reanimated's default.\n      ...rest.map((point) =>\n        withTiming(\n          point,\n          easing ? { duration: step, easing } : { duration: step },\n        ),\n      ),\n    );\n  }, [easing, keyframes, offset, step]);\n\n  const style = useAnimatedStyle(() => ({\n    transform: [{ translateX: offset.value }],\n  }));\n\n  return { shake, style };\n}\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n}\n","type":"theme"}]}