@annondeveloper/ui-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
  2. package/dist/chunk-2DWZVHZS.js.map +1 -0
  3. package/dist/form.d.ts +6 -6
  4. package/dist/form.js +1 -1
  5. package/dist/form.js.map +1 -1
  6. package/dist/index.d.ts +508 -52
  7. package/dist/index.js +2927 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
  10. package/package.json +1 -1
  11. package/src/components/animated-counter.tsx +2 -1
  12. package/src/components/avatar.tsx +2 -1
  13. package/src/components/badge.tsx +3 -2
  14. package/src/components/button.tsx +3 -2
  15. package/src/components/card.tsx +13 -12
  16. package/src/components/checkbox.tsx +3 -2
  17. package/src/components/color-input.tsx +414 -0
  18. package/src/components/command-bar.tsx +434 -0
  19. package/src/components/confidence-bar.tsx +115 -0
  20. package/src/components/confirm-dialog.tsx +2 -1
  21. package/src/components/copy-block.tsx +229 -0
  22. package/src/components/data-table.tsx +2 -1
  23. package/src/components/diff-viewer.tsx +319 -0
  24. package/src/components/dropdown-menu.tsx +2 -1
  25. package/src/components/empty-state.tsx +2 -1
  26. package/src/components/filter-pill.tsx +2 -1
  27. package/src/components/form-input.tsx +5 -4
  28. package/src/components/heatmap-calendar.tsx +213 -0
  29. package/src/components/infinite-scroll.tsx +243 -0
  30. package/src/components/kanban-column.tsx +198 -0
  31. package/src/components/live-feed.tsx +220 -0
  32. package/src/components/log-viewer.tsx +2 -1
  33. package/src/components/metric-card.tsx +2 -1
  34. package/src/components/notification-stack.tsx +226 -0
  35. package/src/components/pipeline-stage.tsx +2 -1
  36. package/src/components/popover.tsx +2 -1
  37. package/src/components/port-status-grid.tsx +2 -1
  38. package/src/components/progress.tsx +2 -1
  39. package/src/components/radio-group.tsx +2 -1
  40. package/src/components/realtime-value.tsx +283 -0
  41. package/src/components/select.tsx +2 -1
  42. package/src/components/severity-timeline.tsx +2 -1
  43. package/src/components/sheet.tsx +2 -1
  44. package/src/components/skeleton.tsx +4 -3
  45. package/src/components/slider.tsx +2 -1
  46. package/src/components/smart-table.tsx +383 -0
  47. package/src/components/sortable-list.tsx +268 -0
  48. package/src/components/sparkline.tsx +2 -1
  49. package/src/components/status-badge.tsx +2 -1
  50. package/src/components/status-pulse.tsx +2 -1
  51. package/src/components/step-wizard.tsx +372 -0
  52. package/src/components/streaming-text.tsx +163 -0
  53. package/src/components/success-checkmark.tsx +2 -1
  54. package/src/components/tabs.tsx +2 -1
  55. package/src/components/threshold-gauge.tsx +2 -1
  56. package/src/components/time-range-selector.tsx +2 -1
  57. package/src/components/toast.tsx +2 -1
  58. package/src/components/toggle-switch.tsx +2 -1
  59. package/src/components/tooltip.tsx +2 -1
  60. package/src/components/truncated-text.tsx +2 -1
  61. package/src/components/typing-indicator.tsx +123 -0
  62. package/src/components/uptime-tracker.tsx +2 -1
  63. package/src/components/utilization-bar.tsx +2 -1
  64. package/src/utils.ts +1 -1
  65. package/dist/chunk-5OKSXPWK.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
1
+ import React from 'react';
2
2
 
3
3
  interface SelectOption {
4
4
  value: string;
@@ -21,6 +21,6 @@ interface SelectProps {
21
21
  * @description A themed select dropdown built on Radix UI Select.
22
22
  * Supports dark/light mode via CSS custom property tokens.
23
23
  */
24
- declare function Select({ value, onValueChange, options, placeholder, className, disabled, }: SelectProps): react_jsx_runtime.JSX.Element;
24
+ declare function Select({ value, onValueChange, options, placeholder, className, disabled, }: SelectProps): React.JSX.Element;
25
25
 
26
26
  export { type SelectOption as S, Select as a, type SelectProps as b };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@annondeveloper/ui-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A React component library with dark/light theming, built on Radix UI, Tailwind CSS v4, and Framer Motion.",
6
6
  "main": "dist/index.js",
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { useEffect, useRef, useState } from 'react'
4
5
  import { useReducedMotion } from 'framer-motion'
5
6
  import { cn } from '../utils'
@@ -27,7 +28,7 @@ export function AnimatedCounter({
27
28
  duration = 400,
28
29
  className,
29
30
  format,
30
- }: AnimatedCounterProps) {
31
+ }: AnimatedCounterProps): React.JSX.Element {
31
32
  const reduced = useReducedMotion()
32
33
  const prevRef = useRef(value)
33
34
  const rafRef = useRef<number | null>(null)
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { useState } from 'react'
4
5
  import { cn } from '../utils'
5
6
 
@@ -55,7 +56,7 @@ export function Avatar({
55
56
  size = 'md',
56
57
  status,
57
58
  className,
58
- }: AvatarProps) {
59
+ }: AvatarProps): React.JSX.Element {
59
60
  const [imgError, setImgError] = useState(false)
60
61
  const s = sizeClasses[size]
61
62
  const initials = fallback ?? deriveInitials(alt)
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { cn } from '../utils'
4
5
  import type { LucideIcon } from 'lucide-react'
5
6
 
@@ -40,7 +41,7 @@ export interface BadgeProps {
40
41
  */
41
42
  export function Badge({
42
43
  children, color = 'gray', icon: Icon, size = 'sm', className,
43
- }: BadgeProps) {
44
+ }: BadgeProps): React.JSX.Element {
44
45
  return (
45
46
  <span className={cn(
46
47
  'inline-flex items-center gap-1 rounded-full font-medium whitespace-nowrap',
@@ -82,7 +83,7 @@ export interface BadgeVariantConfig<T extends string> {
82
83
  * // Usage: <SeverityBadge value="critical" />
83
84
  * ```
84
85
  */
85
- export function createBadgeVariant<T extends string>(config: BadgeVariantConfig<T>) {
86
+ export function createBadgeVariant<T extends string>(config: BadgeVariantConfig<T>): (props: { value: T; className?: string }) => React.JSX.Element {
86
87
  const { colorMap, labelMap, defaultColor = 'gray', defaultSize = 'xs' } = config
87
88
 
88
89
  return function VariantBadge({ value, className }: { value: T; className?: string }) {
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { forwardRef, type ButtonHTMLAttributes } from 'react'
4
5
  import { Loader2 } from 'lucide-react'
5
6
  import { cn } from '../utils'
@@ -40,8 +41,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
40
41
  * @description A themed button with variant, size, and loading support.
41
42
  * Uses CSS custom property tokens for dark/light mode compatibility.
42
43
  */
43
- const Button = forwardRef<HTMLButtonElement, ButtonProps>(
44
- ({ variant = 'primary', size = 'md', loading, disabled, className, children, ...props }, ref) => (
44
+ const Button: React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLButtonElement>> = forwardRef<HTMLButtonElement, ButtonProps>(
45
+ ({ variant = 'primary', size = 'md', loading, disabled, className, children, ...props }, ref): React.JSX.Element => (
45
46
  <button
46
47
  ref={ref}
47
48
  disabled={disabled || loading}
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
4
5
  import { cn } from '../utils'
5
6
 
@@ -37,8 +38,8 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
37
38
  * Use with CardHeader, CardTitle, CardDescription, CardContent, and CardFooter
38
39
  * subcomponents for semantic structure.
39
40
  */
40
- const Card = forwardRef<HTMLDivElement, CardProps>(
41
- ({ variant = 'default', padding = 'md', className, children, ...props }, ref) => (
41
+ const Card: React.ForwardRefExoticComponent<CardProps & React.RefAttributes<HTMLDivElement>> = forwardRef<HTMLDivElement, CardProps>(
42
+ ({ variant = 'default', padding = 'md', className, children, ...props }, ref): React.JSX.Element => (
42
43
  <div
43
44
  ref={ref}
44
45
  className={cn('rounded-2xl', variantClasses[variant], paddingClasses[padding], className)}
@@ -57,8 +58,8 @@ export interface CardSubProps extends HTMLAttributes<HTMLDivElement> {
57
58
  }
58
59
 
59
60
  /** Header section of a Card (flex row for title + actions). */
60
- const CardHeader = forwardRef<HTMLDivElement, CardSubProps>(
61
- ({ className, children, ...props }, ref) => (
61
+ const CardHeader: React.ForwardRefExoticComponent<CardSubProps & React.RefAttributes<HTMLDivElement>> = forwardRef<HTMLDivElement, CardSubProps>(
62
+ ({ className, children, ...props }, ref): React.JSX.Element => (
62
63
  <div
63
64
  ref={ref}
64
65
  className={cn('flex items-start justify-between gap-4', className)}
@@ -71,8 +72,8 @@ const CardHeader = forwardRef<HTMLDivElement, CardSubProps>(
71
72
  CardHeader.displayName = 'CardHeader'
72
73
 
73
74
  /** Title within a CardHeader. */
74
- const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement> & { children: ReactNode }>(
75
- ({ className, children, ...props }, ref) => (
75
+ const CardTitle: React.ForwardRefExoticComponent<HTMLAttributes<HTMLHeadingElement> & { children: ReactNode } & React.RefAttributes<HTMLHeadingElement>> = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement> & { children: ReactNode }>(
76
+ ({ className, children, ...props }, ref): React.JSX.Element => (
76
77
  <h3
77
78
  ref={ref}
78
79
  className={cn('text-base font-semibold text-[hsl(var(--text-primary))]', className)}
@@ -85,8 +86,8 @@ const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingEleme
85
86
  CardTitle.displayName = 'CardTitle'
86
87
 
87
88
  /** Description text within a CardHeader. */
88
- const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement> & { children: ReactNode }>(
89
- ({ className, children, ...props }, ref) => (
89
+ const CardDescription: React.ForwardRefExoticComponent<HTMLAttributes<HTMLParagraphElement> & { children: ReactNode } & React.RefAttributes<HTMLParagraphElement>> = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement> & { children: ReactNode }>(
90
+ ({ className, children, ...props }, ref): React.JSX.Element => (
90
91
  <p
91
92
  ref={ref}
92
93
  className={cn('text-sm text-[hsl(var(--text-secondary))]', className)}
@@ -99,8 +100,8 @@ const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLPara
99
100
  CardDescription.displayName = 'CardDescription'
100
101
 
101
102
  /** Main content area of a Card. */
102
- const CardContent = forwardRef<HTMLDivElement, CardSubProps>(
103
- ({ className, children, ...props }, ref) => (
103
+ const CardContent: React.ForwardRefExoticComponent<CardSubProps & React.RefAttributes<HTMLDivElement>> = forwardRef<HTMLDivElement, CardSubProps>(
104
+ ({ className, children, ...props }, ref): React.JSX.Element => (
104
105
  <div ref={ref} className={cn('mt-4', className)} {...props}>
105
106
  {children}
106
107
  </div>
@@ -109,8 +110,8 @@ const CardContent = forwardRef<HTMLDivElement, CardSubProps>(
109
110
  CardContent.displayName = 'CardContent'
110
111
 
111
112
  /** Footer section of a Card (typically for actions). */
112
- const CardFooter = forwardRef<HTMLDivElement, CardSubProps>(
113
- ({ className, children, ...props }, ref) => (
113
+ const CardFooter: React.ForwardRefExoticComponent<CardSubProps & React.RefAttributes<HTMLDivElement>> = forwardRef<HTMLDivElement, CardSubProps>(
114
+ ({ className, children, ...props }, ref): React.JSX.Element => (
114
115
  <div
115
116
  ref={ref}
116
117
  className={cn(
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { forwardRef, type InputHTMLAttributes } from 'react'
4
5
  import { Check, Minus } from 'lucide-react'
5
6
  import { cn } from '../utils'
@@ -15,8 +16,8 @@ export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement
15
16
  * @description A themed checkbox with indeterminate state support.
16
17
  * Uses CSS custom property tokens for dark/light mode compatibility.
17
18
  */
18
- const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
19
- ({ indeterminate, checked, size = 'md', className, ...props }, ref) => {
19
+ const Checkbox: React.ForwardRefExoticComponent<CheckboxProps & React.RefAttributes<HTMLInputElement>> = forwardRef<HTMLInputElement, CheckboxProps>(
20
+ ({ indeterminate, checked, size = 'md', className, ...props }, ref): React.JSX.Element => {
20
21
  const isChecked = checked || indeterminate
21
22
  const sizeClass = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
22
23
  const iconSize = size === 'sm' ? 'h-2.5 w-2.5' : 'h-3 w-3'
@@ -0,0 +1,414 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
5
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
6
+ import { Copy, Check, Pipette } from 'lucide-react'
7
+ import { cn } from '../utils'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Props for the ColorInput component. */
14
+ export interface ColorInputProps {
15
+ /** Current color value as hex string (e.g. "#ff0000"). */
16
+ value: string
17
+ /** Called when the color changes. */
18
+ onChange: (color: string) => void
19
+ /** Optional label. */
20
+ label?: string
21
+ /** Preset color swatches. */
22
+ presets?: string[]
23
+ /** Show alpha/opacity slider. */
24
+ showAlpha?: boolean
25
+ /** Display format for the text input. Default "hex". */
26
+ format?: 'hex' | 'rgb' | 'hsl'
27
+ /** Additional class name. */
28
+ className?: string
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Color conversion helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
36
+ const clean = hex.replace('#', '')
37
+ const full = clean.length === 3
38
+ ? clean.split('').map(c => c + c).join('')
39
+ : clean
40
+ const num = parseInt(full, 16)
41
+ return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 }
42
+ }
43
+
44
+ function rgbToHex(r: number, g: number, b: number): string {
45
+ return '#' + [r, g, b].map(c => Math.round(c).toString(16).padStart(2, '0')).join('')
46
+ }
47
+
48
+ function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
49
+ const rn = r / 255, gn = g / 255, bn = b / 255
50
+ const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn)
51
+ const l = (max + min) / 2
52
+ if (max === min) return { h: 0, s: 0, l }
53
+ const d = max - min
54
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
55
+ let h = 0
56
+ if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6
57
+ else if (max === gn) h = ((bn - rn) / d + 2) / 6
58
+ else h = ((rn - gn) / d + 4) / 6
59
+ return { h, s, l }
60
+ }
61
+
62
+ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
63
+ if (s === 0) {
64
+ const v = Math.round(l * 255)
65
+ return { r: v, g: v, b: v }
66
+ }
67
+ const hue2rgb = (p: number, q: number, t: number) => {
68
+ const tt = t < 0 ? t + 1 : t > 1 ? t - 1 : t
69
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt
70
+ if (tt < 1 / 2) return q
71
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6
72
+ return p
73
+ }
74
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s
75
+ const p = 2 * l - q
76
+ return {
77
+ r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
78
+ g: Math.round(hue2rgb(p, q, h) * 255),
79
+ b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
80
+ }
81
+ }
82
+
83
+ function formatColor(hex: string, fmt: 'hex' | 'rgb' | 'hsl'): string {
84
+ if (fmt === 'hex') return hex
85
+ const { r, g, b } = hexToRgb(hex)
86
+ if (fmt === 'rgb') return `rgb(${r}, ${g}, ${b})`
87
+ const { h, s, l } = rgbToHsl(r, g, b)
88
+ return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
89
+ }
90
+
91
+ const RECENT_COLORS_KEY = 'ui-kit-recent-colors'
92
+ const MAX_RECENT = 8
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // ColorInput
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * @description A compact color picker input with swatch preview, expandable picker panel
100
+ * featuring hue/saturation area, lightness slider, optional alpha slider, preset swatches,
101
+ * format switching (hex/rgb/hsl), clipboard copy, and recent color history.
102
+ */
103
+ export function ColorInput({
104
+ value,
105
+ onChange,
106
+ label,
107
+ presets,
108
+ showAlpha = false,
109
+ format = 'hex',
110
+ className,
111
+ }: ColorInputProps): React.JSX.Element {
112
+ const prefersReducedMotion = useReducedMotion()
113
+ const [open, setOpen] = useState(false)
114
+ const [copied, setCopied] = useState(false)
115
+ const [textInput, setTextInput] = useState('')
116
+ const [alpha, setAlpha] = useState(1)
117
+ const panelRef = useRef<HTMLDivElement>(null)
118
+ const satAreaRef = useRef<HTMLDivElement>(null)
119
+
120
+ // Recent colors
121
+ const [recentColors, setRecentColors] = useState<string[]>(() => {
122
+ if (typeof window === 'undefined') return []
123
+ try {
124
+ return JSON.parse(localStorage.getItem(RECENT_COLORS_KEY) ?? '[]') as string[]
125
+ } catch { return [] }
126
+ })
127
+
128
+ const addRecent = useCallback((color: string) => {
129
+ setRecentColors(prev => {
130
+ const updated = [color, ...prev.filter(c => c !== color)].slice(0, MAX_RECENT)
131
+ try { localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated)) } catch { /* noop */ }
132
+ return updated
133
+ })
134
+ }, [])
135
+
136
+ // HSL from current value
137
+ const { r, g, b } = useMemo(() => hexToRgb(value), [value])
138
+ const hsl = useMemo(() => rgbToHsl(r, g, b), [r, g, b])
139
+
140
+ // Sync text input
141
+ useEffect(() => {
142
+ setTextInput(formatColor(value, format))
143
+ }, [value, format])
144
+
145
+ // Close on click outside
146
+ useEffect(() => {
147
+ if (!open) return
148
+ const handler = (e: MouseEvent) => {
149
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
150
+ setOpen(false)
151
+ addRecent(value)
152
+ }
153
+ }
154
+ document.addEventListener('mousedown', handler)
155
+ return () => document.removeEventListener('mousedown', handler)
156
+ }, [open, value, addRecent])
157
+
158
+ // Saturation/brightness area interaction
159
+ const handleSatAreaPointer = useCallback(
160
+ (e: React.PointerEvent | PointerEvent) => {
161
+ if (!satAreaRef.current) return
162
+ const rect = satAreaRef.current.getBoundingClientRect()
163
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
164
+ const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
165
+ // x = saturation, y = 1-lightness (top=light, bottom=dark)
166
+ const s = x
167
+ const l = 1 - y
168
+ const adjustedL = 0.05 + l * 0.9 // Keep within visible range
169
+ const rgb = hslToRgb(hsl.h, s, adjustedL)
170
+ onChange(rgbToHex(rgb.r, rgb.g, rgb.b))
171
+ },
172
+ [hsl.h, onChange],
173
+ )
174
+
175
+ const handleSatAreaDown = useCallback(
176
+ (e: React.PointerEvent) => {
177
+ e.preventDefault()
178
+ handleSatAreaPointer(e)
179
+ const move = (ev: PointerEvent) => handleSatAreaPointer(ev)
180
+ const up = () => {
181
+ document.removeEventListener('pointermove', move)
182
+ document.removeEventListener('pointerup', up)
183
+ }
184
+ document.addEventListener('pointermove', move)
185
+ document.addEventListener('pointerup', up)
186
+ },
187
+ [handleSatAreaPointer],
188
+ )
189
+
190
+ // Hue slider
191
+ const handleHueChange = useCallback(
192
+ (e: React.ChangeEvent<HTMLInputElement>) => {
193
+ const h = Number(e.target.value) / 360
194
+ const rgb = hslToRgb(h, hsl.s || 0.5, hsl.l || 0.5)
195
+ onChange(rgbToHex(rgb.r, rgb.g, rgb.b))
196
+ },
197
+ [hsl.s, hsl.l, onChange],
198
+ )
199
+
200
+ // Text input commit
201
+ const handleTextCommit = useCallback(() => {
202
+ const v = textInput.trim()
203
+ // Try hex
204
+ if (/^#?[0-9a-f]{3,6}$/i.test(v)) {
205
+ const hex = v.startsWith('#') ? v : '#' + v
206
+ onChange(hex)
207
+ return
208
+ }
209
+ // Try rgb()
210
+ const rgbMatch = v.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/)
211
+ if (rgbMatch) {
212
+ onChange(rgbToHex(Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])))
213
+ return
214
+ }
215
+ // Try hsl()
216
+ const hslMatch = v.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)/)
217
+ if (hslMatch) {
218
+ const rgb = hslToRgb(Number(hslMatch[1]) / 360, Number(hslMatch[2]) / 100, Number(hslMatch[3]) / 100)
219
+ onChange(rgbToHex(rgb.r, rgb.g, rgb.b))
220
+ return
221
+ }
222
+ // Revert
223
+ setTextInput(formatColor(value, format))
224
+ }, [textInput, value, format, onChange])
225
+
226
+ const handleCopy = useCallback(async () => {
227
+ try {
228
+ await navigator.clipboard.writeText(formatColor(value, format))
229
+ setCopied(true)
230
+ setTimeout(() => setCopied(false), 1500)
231
+ } catch { /* noop */ }
232
+ }, [value, format])
233
+
234
+ // Position for sat/brightness marker
235
+ const markerX = hsl.s * 100
236
+ const markerY = (1 - (hsl.l - 0.05) / 0.9) * 100
237
+
238
+ return (
239
+ <div ref={panelRef} className={cn('relative inline-block', className)}>
240
+ {/* Label */}
241
+ {label && (
242
+ <label className="block text-xs font-medium text-[hsl(var(--text-secondary))] mb-1.5">
243
+ {label}
244
+ </label>
245
+ )}
246
+
247
+ {/* Compact input */}
248
+ <button
249
+ onClick={() => setOpen(o => !o)}
250
+ className={cn(
251
+ 'inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--border-subtle))]',
252
+ 'bg-[hsl(var(--bg-surface))] px-3 py-2 text-sm',
253
+ 'hover:border-[hsl(var(--border-default))] transition-colors',
254
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--brand-primary))]',
255
+ )}
256
+ >
257
+ <span
258
+ className="h-5 w-5 rounded-md border border-[hsl(var(--border-subtle))]"
259
+ style={{ backgroundColor: value }}
260
+ />
261
+ <span className="font-mono text-xs text-[hsl(var(--text-primary))]">
262
+ {formatColor(value, format)}
263
+ </span>
264
+ </button>
265
+
266
+ {/* Expanded picker panel */}
267
+ <AnimatePresence>
268
+ {open && (
269
+ <motion.div
270
+ initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.96, y: -4 }}
271
+ animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1, y: 0 }}
272
+ exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.96, y: -4 }}
273
+ transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15 }}
274
+ className={cn(
275
+ 'absolute z-50 mt-2 w-64 rounded-xl overflow-hidden',
276
+ 'border border-[hsl(var(--border-default))]',
277
+ 'bg-[hsl(var(--bg-elevated))] shadow-xl',
278
+ 'p-3',
279
+ )}
280
+ >
281
+ {/* Saturation/Brightness area */}
282
+ <div
283
+ ref={satAreaRef}
284
+ onPointerDown={handleSatAreaDown}
285
+ className="relative h-36 w-full rounded-lg cursor-crosshair overflow-hidden mb-3"
286
+ style={{
287
+ background: `linear-gradient(to top, #000, transparent),
288
+ linear-gradient(to right, #fff, hsl(${Math.round(hsl.h * 360)}, 100%, 50%))`,
289
+ }}
290
+ >
291
+ {/* Marker */}
292
+ <div
293
+ className="absolute w-4 h-4 rounded-full border-2 border-white shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none"
294
+ style={{
295
+ left: `${markerX}%`,
296
+ top: `${Math.max(0, Math.min(100, markerY))}%`,
297
+ backgroundColor: value,
298
+ }}
299
+ />
300
+ </div>
301
+
302
+ {/* Hue slider */}
303
+ <div className="mb-3">
304
+ <input
305
+ type="range"
306
+ min={0}
307
+ max={360}
308
+ value={Math.round(hsl.h * 360)}
309
+ onChange={handleHueChange}
310
+ className="w-full h-3 rounded-full appearance-none cursor-pointer"
311
+ style={{
312
+ background: 'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)',
313
+ }}
314
+ />
315
+ </div>
316
+
317
+ {/* Alpha slider */}
318
+ {showAlpha && (
319
+ <div className="mb-3">
320
+ <input
321
+ type="range"
322
+ min={0}
323
+ max={100}
324
+ value={Math.round(alpha * 100)}
325
+ onChange={e => setAlpha(Number(e.target.value) / 100)}
326
+ className="w-full h-3 rounded-full appearance-none cursor-pointer"
327
+ style={{
328
+ background: `linear-gradient(to right, transparent, ${value})`,
329
+ }}
330
+ />
331
+ </div>
332
+ )}
333
+
334
+ {/* Text input + copy */}
335
+ <div className="flex items-center gap-2 mb-3">
336
+ <input
337
+ type="text"
338
+ value={textInput}
339
+ onChange={e => setTextInput(e.target.value)}
340
+ onBlur={handleTextCommit}
341
+ onKeyDown={e => { if (e.key === 'Enter') handleTextCommit() }}
342
+ className={cn(
343
+ 'flex-1 rounded-md border border-[hsl(var(--border-subtle))]',
344
+ 'bg-[hsl(var(--bg-surface))] px-2 py-1 text-xs font-mono',
345
+ 'text-[hsl(var(--text-primary))] outline-none',
346
+ 'focus:border-[hsl(var(--brand-primary))] transition-colors',
347
+ )}
348
+ />
349
+ <button
350
+ onClick={handleCopy}
351
+ className={cn(
352
+ 'p-1.5 rounded-md transition-colors',
353
+ 'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-primary))]',
354
+ 'hover:bg-[hsl(var(--bg-surface))]',
355
+ )}
356
+ title="Copy color"
357
+ >
358
+ {copied ? <Check className="h-3.5 w-3.5 text-[hsl(var(--status-ok))]" /> : <Copy className="h-3.5 w-3.5" />}
359
+ </button>
360
+ </div>
361
+
362
+ {/* Presets */}
363
+ {presets && presets.length > 0 && (
364
+ <div className="mb-2">
365
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-[hsl(var(--text-tertiary))] mb-1.5 block">
366
+ Presets
367
+ </span>
368
+ <div className="flex flex-wrap gap-1.5">
369
+ {presets.map(color => (
370
+ <button
371
+ key={color}
372
+ onClick={() => { onChange(color); addRecent(color) }}
373
+ className={cn(
374
+ 'h-6 w-6 rounded-md border transition-all',
375
+ value === color
376
+ ? 'border-[hsl(var(--brand-primary))] ring-2 ring-[hsl(var(--brand-primary)/0.3)] scale-110'
377
+ : 'border-[hsl(var(--border-subtle))] hover:scale-110',
378
+ )}
379
+ style={{ backgroundColor: color }}
380
+ title={color}
381
+ />
382
+ ))}
383
+ </div>
384
+ </div>
385
+ )}
386
+
387
+ {/* Recent colors */}
388
+ {recentColors.length > 0 && (
389
+ <div>
390
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-[hsl(var(--text-tertiary))] mb-1.5 block">
391
+ Recent
392
+ </span>
393
+ <div className="flex flex-wrap gap-1.5">
394
+ {recentColors.map(color => (
395
+ <button
396
+ key={color}
397
+ onClick={() => onChange(color)}
398
+ className={cn(
399
+ 'h-6 w-6 rounded-md border border-[hsl(var(--border-subtle))]',
400
+ 'hover:scale-110 transition-transform',
401
+ )}
402
+ style={{ backgroundColor: color }}
403
+ title={color}
404
+ />
405
+ ))}
406
+ </div>
407
+ </div>
408
+ )}
409
+ </motion.div>
410
+ )}
411
+ </AnimatePresence>
412
+ </div>
413
+ )
414
+ }