@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
@@ -0,0 +1,213 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { useMemo, useState } from 'react'
5
+ import { cn } from '../utils'
6
+
7
+ export interface DayValue {
8
+ /** Date in YYYY-MM-DD format. */
9
+ date: string
10
+ /** Numeric value for this day. */
11
+ value: number
12
+ }
13
+
14
+ export interface HeatmapCalendarProps {
15
+ /** Array of day values. */
16
+ data: DayValue[]
17
+ /** Start date (YYYY-MM-DD). Defaults to 365 days ago. */
18
+ startDate?: string
19
+ /** End date (YYYY-MM-DD). Defaults to today. */
20
+ endDate?: string
21
+ /** 5 colors from lightest to darkest. Defaults to green scale. */
22
+ colorScale?: string[]
23
+ /** Callback when a day cell is clicked. */
24
+ onDayClick?: (day: DayValue) => void
25
+ /** Show month labels at top. */
26
+ showMonthLabels?: boolean
27
+ /** Show day-of-week labels (Mon, Wed, Fri) on the left. */
28
+ showDayLabels?: boolean
29
+ /** Custom tooltip format function. */
30
+ tooltipFormat?: (day: DayValue) => string
31
+ className?: string
32
+ }
33
+
34
+ const DEFAULT_COLORS = [
35
+ 'bg-[hsl(var(--bg-overlay))]',
36
+ 'bg-[hsl(var(--status-ok))]/20',
37
+ 'bg-[hsl(var(--status-ok))]/40',
38
+ 'bg-[hsl(var(--status-ok))]/65',
39
+ 'bg-[hsl(var(--status-ok))]',
40
+ ]
41
+
42
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
43
+ const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
44
+
45
+ function toDateKey(d: Date): string {
46
+ return d.toISOString().slice(0, 10)
47
+ }
48
+
49
+ function parseDate(s: string): Date {
50
+ const [y, m, d] = s.split('-').map(Number)
51
+ return new Date(y, m - 1, d)
52
+ }
53
+
54
+ /**
55
+ * @description A GitHub-style contribution heatmap calendar with configurable color scale,
56
+ * hover tooltips, month/day labels, and click handlers.
57
+ */
58
+ export function HeatmapCalendar({
59
+ data,
60
+ startDate,
61
+ endDate,
62
+ colorScale = DEFAULT_COLORS,
63
+ onDayClick,
64
+ showMonthLabels = true,
65
+ showDayLabels = true,
66
+ tooltipFormat,
67
+ className,
68
+ }: HeatmapCalendarProps): React.JSX.Element {
69
+ const [hoveredDay, setHoveredDay] = useState<DayValue | null>(null)
70
+
71
+ const { weeks, months, maxValue } = useMemo(() => {
72
+ const end = endDate ? parseDate(endDate) : new Date()
73
+ const start = startDate
74
+ ? parseDate(startDate)
75
+ : new Date(end.getFullYear() - 1, end.getMonth(), end.getDate() + 1)
76
+
77
+ // Build value lookup
78
+ const lookup = new Map<string, number>()
79
+ let maxVal = 0
80
+ for (const d of data) {
81
+ lookup.set(d.date, d.value)
82
+ if (d.value > maxVal) maxVal = d.value
83
+ }
84
+
85
+ // Generate weeks (columns) starting from the start date's week
86
+ const weeks: (DayValue | null)[][] = []
87
+ const monthChanges: { col: number; label: string }[] = []
88
+
89
+ // Align start to the beginning of its week (Sunday)
90
+ const cursor = new Date(start)
91
+ cursor.setDate(cursor.getDate() - cursor.getDay())
92
+
93
+ let prevMonth = -1
94
+
95
+ while (cursor <= end || weeks.length === 0) {
96
+ const week: (DayValue | null)[] = []
97
+ for (let dow = 0; dow < 7; dow++) {
98
+ const key = toDateKey(cursor)
99
+ if (cursor >= start && cursor <= end) {
100
+ week.push({ date: key, value: lookup.get(key) ?? 0 })
101
+ } else {
102
+ week.push(null)
103
+ }
104
+
105
+ // Track month boundaries
106
+ if (cursor.getMonth() !== prevMonth && cursor >= start && cursor <= end) {
107
+ if (dow === 0) {
108
+ monthChanges.push({ col: weeks.length, label: MONTH_NAMES[cursor.getMonth()] })
109
+ }
110
+ prevMonth = cursor.getMonth()
111
+ }
112
+
113
+ cursor.setDate(cursor.getDate() + 1)
114
+ }
115
+ weeks.push(week)
116
+ }
117
+
118
+ return { weeks, months: monthChanges, maxValue: maxVal }
119
+ }, [data, startDate, endDate])
120
+
121
+ const getColorClass = (value: number): string => {
122
+ if (value === 0 || maxValue === 0) return colorScale[0]
123
+ const idx = Math.min(
124
+ colorScale.length - 1,
125
+ Math.ceil((value / maxValue) * (colorScale.length - 1))
126
+ )
127
+ return colorScale[idx]
128
+ }
129
+
130
+ const defaultTooltip = (day: DayValue): string =>
131
+ `${day.date}: ${day.value}`
132
+
133
+ const formatTooltip = tooltipFormat ?? defaultTooltip
134
+
135
+ return (
136
+ <div className={cn('overflow-x-auto', className)}>
137
+ <div className="inline-flex flex-col gap-0.5">
138
+ {/* Month labels */}
139
+ {showMonthLabels && (
140
+ <div className="flex" style={{ marginLeft: showDayLabels ? '2rem' : 0 }}>
141
+ {weeks.map((_, col) => {
142
+ const monthEntry = months.find((m) => m.col === col)
143
+ return (
144
+ <div
145
+ key={`m-${col}`}
146
+ className="text-[10px] text-[hsl(var(--text-tertiary))]"
147
+ style={{ width: 13, minWidth: 13 }}
148
+ >
149
+ {monthEntry?.label ?? ''}
150
+ </div>
151
+ )
152
+ })}
153
+ </div>
154
+ )}
155
+
156
+ {/* Grid rows (7 days) */}
157
+ <div className="flex gap-0">
158
+ {/* Day labels column */}
159
+ {showDayLabels && (
160
+ <div className="flex flex-col gap-[2px] mr-1">
161
+ {DAY_LABELS.map((label, i) => (
162
+ <div
163
+ key={i}
164
+ className="text-[10px] text-[hsl(var(--text-tertiary))] h-[11px] flex items-center justify-end pr-1"
165
+ style={{ width: '1.75rem' }}
166
+ >
167
+ {label}
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )}
172
+
173
+ {/* Week columns */}
174
+ {weeks.map((week, wIdx) => (
175
+ <div key={wIdx} className="flex flex-col gap-[2px]">
176
+ {week.map((day, dIdx) => (
177
+ <div
178
+ key={dIdx}
179
+ className={cn(
180
+ 'w-[11px] h-[11px] rounded-sm',
181
+ day ? getColorClass(day.value) : 'bg-transparent',
182
+ day && onDayClick && 'cursor-pointer',
183
+ day && 'hover:ring-1 hover:ring-[hsl(var(--text-tertiary))]',
184
+ )}
185
+ onClick={day && onDayClick ? () => onDayClick(day) : undefined}
186
+ onMouseEnter={day ? () => setHoveredDay(day) : undefined}
187
+ onMouseLeave={() => setHoveredDay(null)}
188
+ title={day ? formatTooltip(day) : undefined}
189
+ role={day && onDayClick ? 'button' : undefined}
190
+ tabIndex={day && onDayClick ? 0 : undefined}
191
+ onKeyDown={
192
+ day && onDayClick
193
+ ? (e) => { if (e.key === 'Enter' || e.key === ' ') onDayClick(day) }
194
+ : undefined
195
+ }
196
+ />
197
+ ))}
198
+ </div>
199
+ ))}
200
+ </div>
201
+
202
+ {/* Legend */}
203
+ <div className="flex items-center gap-1 mt-1" style={{ marginLeft: showDayLabels ? '2rem' : 0 }}>
204
+ <span className="text-[10px] text-[hsl(var(--text-tertiary))] mr-1">Less</span>
205
+ {colorScale.map((color, i) => (
206
+ <div key={i} className={cn('w-[11px] h-[11px] rounded-sm', color)} />
207
+ ))}
208
+ <span className="text-[10px] text-[hsl(var(--text-tertiary))] ml-1">More</span>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ )
213
+ }
@@ -0,0 +1,243 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
5
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
6
+ import { ArrowUp, Loader2 } from 'lucide-react'
7
+ import { cn } from '../utils'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Props for the InfiniteScroll component. */
14
+ export interface InfiniteScrollProps<T> {
15
+ /** Array of loaded items. */
16
+ items: T[]
17
+ /** Render function for each item. */
18
+ renderItem: (item: T, index: number) => React.JSX.Element
19
+ /** Called when more items should be loaded. */
20
+ loadMore: () => void | Promise<void>
21
+ /** Whether more items are available. */
22
+ hasMore: boolean
23
+ /** Whether a load operation is in progress. */
24
+ isLoading?: boolean
25
+ /** Pixels from bottom to trigger loadMore. Default 200. */
26
+ threshold?: number
27
+ /** Fixed item height for virtualization. If omitted, all items render (no virtualization). */
28
+ itemHeight?: number
29
+ /** Content to display when items array is empty. */
30
+ emptyState?: React.ReactNode
31
+ /** Additional class name for the scroll container. */
32
+ className?: string
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // InfiniteScroll
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * @description A virtualized infinite-scroll list using IntersectionObserver.
41
+ * Supports optional height-based virtualization, loading indicators, scroll-to-top,
42
+ * empty states, and skeleton placeholders. No scroll event listeners used.
43
+ */
44
+ export function InfiniteScroll<T>({
45
+ items,
46
+ renderItem,
47
+ loadMore,
48
+ hasMore,
49
+ isLoading = false,
50
+ threshold = 200,
51
+ itemHeight,
52
+ emptyState,
53
+ className,
54
+ }: InfiniteScrollProps<T>): React.JSX.Element {
55
+ const prefersReducedMotion = useReducedMotion()
56
+ const containerRef = useRef<HTMLDivElement>(null)
57
+ const sentinelRef = useRef<HTMLDivElement>(null)
58
+ const [showScrollTop, setShowScrollTop] = useState(false)
59
+ const loadingRef = useRef(false)
60
+
61
+ // IntersectionObserver for infinite load trigger
62
+ useEffect(() => {
63
+ const sentinel = sentinelRef.current
64
+ if (!sentinel) return
65
+
66
+ const observer = new IntersectionObserver(
67
+ (entries) => {
68
+ const entry = entries[0]
69
+ if (entry?.isIntersecting && hasMore && !isLoading && !loadingRef.current) {
70
+ loadingRef.current = true
71
+ const result = loadMore()
72
+ if (result && typeof result.then === 'function') {
73
+ result.then(() => { loadingRef.current = false }).catch(() => { loadingRef.current = false })
74
+ } else {
75
+ loadingRef.current = false
76
+ }
77
+ }
78
+ },
79
+ {
80
+ root: containerRef.current,
81
+ rootMargin: `0px 0px ${threshold}px 0px`,
82
+ },
83
+ )
84
+
85
+ observer.observe(sentinel)
86
+ return () => observer.disconnect()
87
+ }, [hasMore, isLoading, loadMore, threshold])
88
+
89
+ // Update loadingRef when isLoading changes
90
+ useEffect(() => {
91
+ if (!isLoading) loadingRef.current = false
92
+ }, [isLoading])
93
+
94
+ // Scroll position tracking for scroll-to-top button
95
+ useEffect(() => {
96
+ const container = containerRef.current
97
+ if (!container) return
98
+
99
+ const handler = () => {
100
+ setShowScrollTop(container.scrollTop > 400)
101
+ }
102
+ container.addEventListener('scroll', handler, { passive: true })
103
+ return () => container.removeEventListener('scroll', handler)
104
+ }, [])
105
+
106
+ const scrollToTop = useCallback(() => {
107
+ containerRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'instant' : 'smooth' })
108
+ }, [prefersReducedMotion])
109
+
110
+ // Virtualization
111
+ const [scrollTop, setScrollTop] = useState(0)
112
+ const containerHeight = useRef(0)
113
+
114
+ useEffect(() => {
115
+ if (!itemHeight) return
116
+ const container = containerRef.current
117
+ if (!container) return
118
+
119
+ containerHeight.current = container.clientHeight
120
+ const handler = () => {
121
+ setScrollTop(container.scrollTop)
122
+ containerHeight.current = container.clientHeight
123
+ }
124
+ container.addEventListener('scroll', handler, { passive: true })
125
+ return () => container.removeEventListener('scroll', handler)
126
+ }, [itemHeight])
127
+
128
+ const virtualizedContent = useMemo(() => {
129
+ if (!itemHeight) return null
130
+
131
+ const visibleHeight = containerHeight.current || 600
132
+ const buffer = 5
133
+ const startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer)
134
+ const endIdx = Math.min(
135
+ items.length,
136
+ Math.ceil((scrollTop + visibleHeight) / itemHeight) + buffer,
137
+ )
138
+
139
+ const totalHeight = items.length * itemHeight
140
+ const offsetTop = startIdx * itemHeight
141
+
142
+ return {
143
+ totalHeight,
144
+ offsetTop,
145
+ visibleItems: items.slice(startIdx, endIdx),
146
+ startIdx,
147
+ }
148
+ }, [items, itemHeight, scrollTop])
149
+
150
+ // Empty state
151
+ if (items.length === 0 && !isLoading && !hasMore) {
152
+ return (
153
+ <div className={cn('flex items-center justify-center min-h-[200px]', className)}>
154
+ {emptyState ?? (
155
+ <div className="text-center py-12">
156
+ <p className="text-sm text-[hsl(var(--text-tertiary))]">No items to display.</p>
157
+ </div>
158
+ )}
159
+ </div>
160
+ )
161
+ }
162
+
163
+ return (
164
+ <div
165
+ ref={containerRef}
166
+ className={cn('relative overflow-y-auto', className)}
167
+ >
168
+ {/* Virtualized rendering */}
169
+ {virtualizedContent ? (
170
+ <div style={{ height: virtualizedContent.totalHeight, position: 'relative' }}>
171
+ <div style={{ position: 'absolute', top: virtualizedContent.offsetTop, left: 0, right: 0 }}>
172
+ {virtualizedContent.visibleItems.map((item, i) => (
173
+ <div key={virtualizedContent.startIdx + i} style={{ height: itemHeight }}>
174
+ {renderItem(item, virtualizedContent.startIdx + i)}
175
+ </div>
176
+ ))}
177
+ </div>
178
+ </div>
179
+ ) : (
180
+ /* Non-virtualized rendering */
181
+ items.map((item, index) => (
182
+ <div key={index}>
183
+ {renderItem(item, index)}
184
+ </div>
185
+ ))
186
+ )}
187
+
188
+ {/* Loading indicator */}
189
+ {isLoading && (
190
+ <div className="flex items-center justify-center py-6 gap-2">
191
+ <Loader2 className="h-5 w-5 text-[hsl(var(--brand-primary))] animate-spin" />
192
+ <span className="text-sm text-[hsl(var(--text-tertiary))]">Loading more...</span>
193
+ </div>
194
+ )}
195
+
196
+ {/* Skeleton placeholders while loading with no items yet */}
197
+ {isLoading && items.length === 0 && (
198
+ <div className="space-y-3 p-4">
199
+ {Array.from({ length: 6 }).map((_, i) => (
200
+ <div key={i} className="skeleton-shimmer h-16 rounded-xl" />
201
+ ))}
202
+ </div>
203
+ )}
204
+
205
+ {/* No more items */}
206
+ {!hasMore && items.length > 0 && (
207
+ <div className="py-4 text-center">
208
+ <span className="text-[11px] text-[hsl(var(--text-tertiary))]">
209
+ All {items.length} item{items.length !== 1 ? 's' : ''} loaded
210
+ </span>
211
+ </div>
212
+ )}
213
+
214
+ {/* Sentinel for IntersectionObserver */}
215
+ <div ref={sentinelRef} className="h-px w-full" aria-hidden="true" />
216
+
217
+ {/* Scroll to top button */}
218
+ <AnimatePresence>
219
+ {showScrollTop && (
220
+ <motion.button
221
+ initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.8 }}
222
+ animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1 }}
223
+ exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.8 }}
224
+ transition={{ duration: 0.15 }}
225
+ onClick={scrollToTop}
226
+ className={cn(
227
+ 'sticky bottom-4 left-1/2 -translate-x-1/2 z-10',
228
+ 'inline-flex items-center gap-1.5 rounded-full',
229
+ 'px-3 py-2 text-[11px] font-medium',
230
+ 'bg-[hsl(var(--bg-elevated))] border border-[hsl(var(--border-default))]',
231
+ 'text-[hsl(var(--text-secondary))] shadow-lg',
232
+ 'hover:bg-[hsl(var(--bg-overlay))] hover:text-[hsl(var(--text-primary))] transition-colors',
233
+ 'cursor-pointer',
234
+ )}
235
+ >
236
+ <ArrowUp className="h-3.5 w-3.5" />
237
+ Back to top
238
+ </motion.button>
239
+ )}
240
+ </AnimatePresence>
241
+ </div>
242
+ )
243
+ }
@@ -0,0 +1,198 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { motion, useReducedMotion } from 'framer-motion'
5
+ import { cn } from '../utils'
6
+ import { Plus } from 'lucide-react'
7
+
8
+ export type KanbanBadgeColor =
9
+ | 'brand' | 'blue' | 'green' | 'yellow' | 'red' | 'orange'
10
+ | 'purple' | 'pink' | 'teal' | 'gray'
11
+
12
+ export interface KanbanItem {
13
+ id: string
14
+ title: string
15
+ description?: string
16
+ tags?: { label: string; color: KanbanBadgeColor }[]
17
+ assignee?: { name: string; avatar?: string }
18
+ }
19
+
20
+ export interface KanbanColumnProps {
21
+ /** Column title. */
22
+ title: string
23
+ /** Items to display in this column. */
24
+ items: KanbanItem[]
25
+ /** Optional override for the count badge. */
26
+ count?: number
27
+ /** Column header accent color (CSS value or token). */
28
+ color?: string
29
+ /** Callback when a card is clicked. */
30
+ onItemClick?: (item: KanbanItem) => void
31
+ /** Callback when the add button is clicked. */
32
+ onAddItem?: () => void
33
+ className?: string
34
+ }
35
+
36
+ const TAG_COLOR_MAP: Record<KanbanBadgeColor, string> = {
37
+ brand: 'bg-[hsl(var(--brand-primary))]/15 text-[hsl(var(--brand-primary))]',
38
+ blue: 'bg-[hsl(var(--brand-secondary))]/15 text-[hsl(var(--brand-secondary))]',
39
+ green: 'bg-[hsl(var(--status-ok))]/15 text-[hsl(var(--status-ok))]',
40
+ yellow: 'bg-[hsl(var(--status-warning))]/15 text-[hsl(var(--status-warning))]',
41
+ red: 'bg-[hsl(var(--status-critical))]/15 text-[hsl(var(--status-critical))]',
42
+ orange: 'bg-[hsl(var(--status-warning))]/20 text-[hsl(var(--status-warning))]',
43
+ purple: 'bg-[hsl(270,60%,60%)]/15 text-[hsl(270,60%,65%)]',
44
+ pink: 'bg-[hsl(330,60%,60%)]/15 text-[hsl(330,60%,65%)]',
45
+ teal: 'bg-[hsl(180,60%,40%)]/15 text-[hsl(180,60%,55%)]',
46
+ gray: 'bg-[hsl(var(--bg-overlay))] text-[hsl(var(--text-secondary))]',
47
+ }
48
+
49
+ /**
50
+ * @description A kanban board column with title, count badge, scrollable card list,
51
+ * hover states, staggered Framer Motion entrance, and empty state.
52
+ */
53
+ export function KanbanColumn({
54
+ title,
55
+ items,
56
+ count,
57
+ color,
58
+ onItemClick,
59
+ onAddItem,
60
+ className,
61
+ }: KanbanColumnProps): React.JSX.Element {
62
+ const reduced = useReducedMotion()
63
+ const displayCount = count ?? items.length
64
+
65
+ return (
66
+ <div
67
+ className={cn(
68
+ 'flex flex-col w-72 min-w-[18rem] rounded-2xl',
69
+ 'bg-[hsl(var(--bg-surface))] border border-[hsl(var(--border-subtle))]',
70
+ className,
71
+ )}
72
+ >
73
+ {/* Header */}
74
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[hsl(var(--border-subtle))]">
75
+ <div className="flex items-center gap-2">
76
+ {color && (
77
+ <span
78
+ className="w-2 h-2 rounded-full shrink-0"
79
+ style={{ backgroundColor: color }}
80
+ />
81
+ )}
82
+ <span className="text-sm font-semibold text-[hsl(var(--text-primary))]">
83
+ {title}
84
+ </span>
85
+ <span className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[10px] font-medium tabular-nums bg-[hsl(var(--bg-overlay))] text-[hsl(var(--text-secondary))]">
86
+ {displayCount}
87
+ </span>
88
+ </div>
89
+ {onAddItem && (
90
+ <button
91
+ type="button"
92
+ onClick={onAddItem}
93
+ className={cn(
94
+ 'p-1 rounded-lg cursor-pointer',
95
+ 'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-primary))]',
96
+ 'hover:bg-[hsl(var(--bg-overlay))] transition-colors duration-150',
97
+ )}
98
+ aria-label={`Add item to ${title}`}
99
+ >
100
+ <Plus className="size-4" />
101
+ </button>
102
+ )}
103
+ </div>
104
+
105
+ {/* Card list */}
106
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
107
+ {items.length === 0 ? (
108
+ <div className="flex items-center justify-center py-8 text-xs text-[hsl(var(--text-tertiary))]">
109
+ No items
110
+ </div>
111
+ ) : (
112
+ items.map((item, idx) => (
113
+ <motion.div
114
+ key={item.id}
115
+ initial={reduced ? undefined : { opacity: 0, y: 8 }}
116
+ animate={{ opacity: 1, y: 0 }}
117
+ transition={reduced ? { duration: 0 } : { delay: idx * 0.04, duration: 0.2 }}
118
+ >
119
+ <KanbanCard item={item} onClick={onItemClick} />
120
+ </motion.div>
121
+ ))
122
+ )}
123
+ </div>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ function KanbanCard({
129
+ item,
130
+ onClick,
131
+ }: {
132
+ item: KanbanItem
133
+ onClick?: (item: KanbanItem) => void
134
+ }): React.JSX.Element {
135
+ return (
136
+ <div
137
+ role={onClick ? 'button' : undefined}
138
+ tabIndex={onClick ? 0 : undefined}
139
+ onClick={onClick ? () => onClick(item) : undefined}
140
+ onKeyDown={
141
+ onClick
142
+ ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick(item) }
143
+ : undefined
144
+ }
145
+ className={cn(
146
+ 'rounded-xl p-3 border border-[hsl(var(--border-subtle))]',
147
+ 'bg-[hsl(var(--bg-base))]',
148
+ onClick && 'cursor-pointer hover:bg-[hsl(var(--bg-elevated))] hover:shadow-sm',
149
+ 'transition-all duration-150',
150
+ )}
151
+ >
152
+ <p className="text-sm font-medium text-[hsl(var(--text-primary))] line-clamp-2">
153
+ {item.title}
154
+ </p>
155
+ {item.description && (
156
+ <p className="mt-1 text-xs text-[hsl(var(--text-secondary))] line-clamp-2">
157
+ {item.description}
158
+ </p>
159
+ )}
160
+
161
+ {/* Tags + Assignee row */}
162
+ {(item.tags?.length || item.assignee) && (
163
+ <div className="flex items-center justify-between mt-2.5 gap-2">
164
+ {item.tags && item.tags.length > 0 && (
165
+ <div className="flex flex-wrap gap-1 min-w-0">
166
+ {item.tags.map((tag, i) => (
167
+ <span
168
+ key={i}
169
+ className={cn(
170
+ 'inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap',
171
+ TAG_COLOR_MAP[tag.color],
172
+ )}
173
+ >
174
+ {tag.label}
175
+ </span>
176
+ ))}
177
+ </div>
178
+ )}
179
+ {item.assignee && (
180
+ <div className="shrink-0" title={item.assignee.name}>
181
+ {item.assignee.avatar ? (
182
+ <img
183
+ src={item.assignee.avatar}
184
+ alt={item.assignee.name}
185
+ className="size-6 rounded-full object-cover"
186
+ />
187
+ ) : (
188
+ <div className="size-6 rounded-full bg-[hsl(var(--bg-overlay))] flex items-center justify-center text-[10px] font-semibold text-[hsl(var(--text-secondary))]">
189
+ {item.assignee.name.slice(0, 2).toUpperCase()}
190
+ </div>
191
+ )}
192
+ </div>
193
+ )}
194
+ </div>
195
+ )}
196
+ </div>
197
+ )
198
+ }