@annondeveloper/ui-kit 0.1.0 → 0.2.1

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 (67) hide show
  1. package/README.md +1463 -127
  2. package/dist/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
  3. package/dist/chunk-2DWZVHZS.js.map +1 -0
  4. package/dist/form.d.ts +6 -6
  5. package/dist/form.js +2 -3
  6. package/dist/form.js.map +1 -1
  7. package/dist/index.d.ts +510 -52
  8. package/dist/index.js +2996 -15
  9. package/dist/index.js.map +1 -1
  10. package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
  11. package/package.json +24 -26
  12. package/src/components/animated-counter.tsx +8 -5
  13. package/src/components/avatar.tsx +2 -1
  14. package/src/components/badge.tsx +3 -2
  15. package/src/components/button.tsx +3 -2
  16. package/src/components/card.tsx +13 -12
  17. package/src/components/checkbox.tsx +3 -2
  18. package/src/components/color-input.tsx +427 -0
  19. package/src/components/command-bar.tsx +435 -0
  20. package/src/components/confidence-bar.tsx +115 -0
  21. package/src/components/confirm-dialog.tsx +2 -1
  22. package/src/components/copy-block.tsx +224 -0
  23. package/src/components/data-table.tsx +9 -13
  24. package/src/components/diff-viewer.tsx +340 -0
  25. package/src/components/dropdown-menu.tsx +2 -1
  26. package/src/components/empty-state.tsx +2 -1
  27. package/src/components/filter-pill.tsx +2 -1
  28. package/src/components/form-input.tsx +5 -4
  29. package/src/components/heatmap-calendar.tsx +218 -0
  30. package/src/components/infinite-scroll.tsx +248 -0
  31. package/src/components/kanban-column.tsx +198 -0
  32. package/src/components/live-feed.tsx +222 -0
  33. package/src/components/log-viewer.tsx +4 -1
  34. package/src/components/metric-card.tsx +2 -1
  35. package/src/components/notification-stack.tsx +233 -0
  36. package/src/components/pipeline-stage.tsx +2 -1
  37. package/src/components/popover.tsx +2 -1
  38. package/src/components/port-status-grid.tsx +2 -1
  39. package/src/components/progress.tsx +2 -1
  40. package/src/components/radio-group.tsx +2 -1
  41. package/src/components/realtime-value.tsx +283 -0
  42. package/src/components/select.tsx +2 -1
  43. package/src/components/severity-timeline.tsx +2 -1
  44. package/src/components/sheet.tsx +2 -1
  45. package/src/components/skeleton.tsx +4 -3
  46. package/src/components/slider.tsx +2 -1
  47. package/src/components/smart-table.tsx +383 -0
  48. package/src/components/sortable-list.tsx +272 -0
  49. package/src/components/sparkline.tsx +2 -1
  50. package/src/components/status-badge.tsx +2 -1
  51. package/src/components/status-pulse.tsx +2 -1
  52. package/src/components/step-wizard.tsx +380 -0
  53. package/src/components/streaming-text.tsx +160 -0
  54. package/src/components/success-checkmark.tsx +2 -1
  55. package/src/components/tabs.tsx +2 -1
  56. package/src/components/threshold-gauge.tsx +2 -1
  57. package/src/components/time-range-selector.tsx +2 -1
  58. package/src/components/toast.tsx +2 -1
  59. package/src/components/toggle-switch.tsx +2 -1
  60. package/src/components/tooltip.tsx +2 -1
  61. package/src/components/truncated-text.tsx +2 -1
  62. package/src/components/typing-indicator.tsx +123 -0
  63. package/src/components/uptime-tracker.tsx +2 -1
  64. package/src/components/utilization-bar.tsx +2 -1
  65. package/src/theme.css +6 -0
  66. package/src/utils.ts +1 -1
  67. package/dist/chunk-5OKSXPWK.js.map +0 -1
@@ -0,0 +1,435 @@
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 { Search, Clock, Command, CornerDownLeft } from 'lucide-react'
7
+ import type { LucideIcon } from 'lucide-react'
8
+ import { cn } from '../utils'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** A single command item in the palette. */
15
+ export interface CommandItem {
16
+ /** Unique identifier. */
17
+ id: string
18
+ /** Display label. */
19
+ label: string
20
+ /** Optional description shown below the label. */
21
+ description?: string
22
+ /** Optional icon component. */
23
+ icon?: LucideIcon
24
+ /** Keyboard shortcut display string (e.g. "Cmd+K"). */
25
+ shortcut?: string
26
+ /** Group name for sectioning items. */
27
+ group?: string
28
+ /** Callback when item is selected. */
29
+ onSelect: () => void
30
+ /** Additional search terms that match this item. */
31
+ keywords?: string[]
32
+ }
33
+
34
+ /** Props for the CommandBar component. */
35
+ export interface CommandBarProps {
36
+ /** Array of command items to display and search. */
37
+ items: CommandItem[]
38
+ /** Placeholder text for the search input. */
39
+ placeholder?: string
40
+ /** Hotkey letter (combined with Cmd/Ctrl). Default "k". */
41
+ hotkey?: string
42
+ /** Async search function for remote results. */
43
+ onSearch?: (query: string) => Promise<CommandItem[]>
44
+ /** localStorage key for persisting recent selections. */
45
+ recentKey?: string
46
+ /** Maximum number of recent items to store. Default 5. */
47
+ maxRecent?: number
48
+ /** Additional class name for the dialog. */
49
+ className?: string
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Fuzzy scoring
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function fuzzyScore(query: string, target: string): number {
57
+ const q = query.toLowerCase()
58
+ const t = target.toLowerCase()
59
+ if (t === q) return 100
60
+ if (t.startsWith(q)) return 80
61
+ if (t.includes(q)) return 60
62
+ // Fuzzy subsequence match
63
+ let qi = 0
64
+ let score = 0
65
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
66
+ if (t[ti] === q[qi]) {
67
+ score += 10
68
+ qi++
69
+ }
70
+ }
71
+ return qi === q.length ? score : 0
72
+ }
73
+
74
+ function scoreItem(query: string, item: CommandItem): number {
75
+ if (!query) return 0
76
+ let best = fuzzyScore(query, item.label)
77
+ if (item.description) best = Math.max(best, fuzzyScore(query, item.description) * 0.8)
78
+ if (item.keywords) {
79
+ for (const kw of item.keywords) {
80
+ best = Math.max(best, fuzzyScore(query, kw) * 0.9)
81
+ }
82
+ }
83
+ return best
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // CommandBar
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * @description A universal command palette activated by Cmd+K (Mac) or Ctrl+K (Win).
92
+ * Features fuzzy search, grouped items, recent selections (localStorage),
93
+ * async search support, keyboard navigation, and Framer Motion animations.
94
+ * Fully configurable and not hardcoded to any app.
95
+ */
96
+ export function CommandBar({
97
+ items,
98
+ placeholder = 'Type a command\u2026',
99
+ hotkey = 'k',
100
+ onSearch,
101
+ recentKey = 'ui-kit-command-recent',
102
+ maxRecent = 5,
103
+ className,
104
+ }: CommandBarProps): React.JSX.Element {
105
+ const prefersReducedMotion = useReducedMotion()
106
+ const [open, setOpen] = useState(false)
107
+ const [query, setQuery] = useState('')
108
+ const [activeIndex, setActiveIndex] = useState(0)
109
+ const [asyncResults, setAsyncResults] = useState<CommandItem[]>([])
110
+ const [isSearching, setIsSearching] = useState(false)
111
+ const inputRef = useRef<HTMLInputElement>(null)
112
+ const listRef = useRef<HTMLDivElement>(null)
113
+
114
+ // Recent items from localStorage
115
+ const [recentIds, setRecentIds] = useState<string[]>(() => {
116
+ if (typeof window === 'undefined') return []
117
+ try {
118
+ const raw = JSON.parse(localStorage.getItem(recentKey) ?? '[]')
119
+ return Array.isArray(raw) ? raw.filter((x: unknown) => typeof x === 'string' && x.length < 256).slice(0, maxRecent) : []
120
+ } catch {
121
+ return []
122
+ }
123
+ })
124
+
125
+ const saveRecent = useCallback(
126
+ (id: string) => {
127
+ const updated = [id, ...recentIds.filter(r => r !== id)].slice(0, maxRecent)
128
+ setRecentIds(updated)
129
+ try {
130
+ localStorage.setItem(recentKey, JSON.stringify(updated))
131
+ } catch {
132
+ // localStorage might be full
133
+ }
134
+ },
135
+ [recentIds, recentKey, maxRecent],
136
+ )
137
+
138
+ // Hotkey listener
139
+ useEffect(() => {
140
+ const handler = (e: KeyboardEvent) => {
141
+ if ((e.metaKey || e.ctrlKey) && e.key === hotkey) {
142
+ e.preventDefault()
143
+ setOpen(o => !o)
144
+ }
145
+ if (e.key === 'Escape' && open) {
146
+ setOpen(false)
147
+ }
148
+ }
149
+ document.addEventListener('keydown', handler)
150
+ return () => document.removeEventListener('keydown', handler)
151
+ }, [hotkey, open])
152
+
153
+ // Focus input when opened
154
+ useEffect(() => {
155
+ if (open) {
156
+ setQuery('')
157
+ setActiveIndex(0)
158
+ setAsyncResults([])
159
+ requestAnimationFrame(() => inputRef.current?.focus())
160
+ }
161
+ }, [open])
162
+
163
+ // Prevent body scroll when open
164
+ useEffect(() => {
165
+ if (open) {
166
+ const prev = document.body.style.overflow
167
+ document.body.style.overflow = 'hidden'
168
+ return () => { document.body.style.overflow = prev }
169
+ }
170
+ }, [open])
171
+
172
+ // Async search debounce
173
+ useEffect(() => {
174
+ if (!onSearch || !query) {
175
+ setAsyncResults([])
176
+ return
177
+ }
178
+ setIsSearching(true)
179
+ const timer = setTimeout(async () => {
180
+ try {
181
+ const results = await onSearch(query)
182
+ setAsyncResults(results)
183
+ } catch {
184
+ setAsyncResults([])
185
+ } finally {
186
+ setIsSearching(false)
187
+ }
188
+ }, 200)
189
+ return () => clearTimeout(timer)
190
+ }, [query, onSearch])
191
+
192
+ // Build display list
193
+ const displayItems = useMemo(() => {
194
+ const allItems = [...items, ...asyncResults]
195
+
196
+ if (!query) {
197
+ // Show recent items first, then all items
198
+ const recentItems = recentIds
199
+ .map(id => allItems.find(i => i.id === id))
200
+ .filter((i): i is CommandItem => i !== undefined)
201
+ const rest = allItems.filter(i => !recentIds.includes(i.id))
202
+ return [
203
+ ...recentItems.map(i => ({ ...i, group: 'Recent' })),
204
+ ...rest,
205
+ ]
206
+ }
207
+
208
+ // Score and sort
209
+ return allItems
210
+ .map(item => ({ item, score: scoreItem(query, item) }))
211
+ .filter(({ score }) => score > 0)
212
+ .sort((a, b) => b.score - a.score)
213
+ .map(({ item }) => item)
214
+ }, [items, asyncResults, query, recentIds])
215
+
216
+ // Group items
217
+ const groups = useMemo(() => {
218
+ const grouped = new Map<string, CommandItem[]>()
219
+ for (const item of displayItems) {
220
+ const group = item.group ?? ''
221
+ const arr = grouped.get(group)
222
+ if (arr) arr.push(item)
223
+ else grouped.set(group, [item])
224
+ }
225
+ return grouped
226
+ }, [displayItems])
227
+
228
+ // Flatten for keyboard nav
229
+ const flatItems = displayItems
230
+
231
+ // Keep activeIndex in bounds
232
+ useEffect(() => {
233
+ setActiveIndex(0)
234
+ }, [query])
235
+
236
+ const handleSelect = useCallback(
237
+ (item: CommandItem) => {
238
+ saveRecent(item.id)
239
+ setOpen(false)
240
+ item.onSelect()
241
+ },
242
+ [saveRecent],
243
+ )
244
+
245
+ // Keyboard navigation
246
+ const handleKeyDown = useCallback(
247
+ (e: React.KeyboardEvent) => {
248
+ if (e.key === 'ArrowDown') {
249
+ e.preventDefault()
250
+ setActiveIndex(i => Math.min(i + 1, flatItems.length - 1))
251
+ } else if (e.key === 'ArrowUp') {
252
+ e.preventDefault()
253
+ setActiveIndex(i => Math.max(i - 1, 0))
254
+ } else if (e.key === 'Enter') {
255
+ e.preventDefault()
256
+ const item = flatItems[activeIndex]
257
+ if (item) handleSelect(item)
258
+ }
259
+ },
260
+ [flatItems, activeIndex, handleSelect],
261
+ )
262
+
263
+ // Scroll active item into view
264
+ useEffect(() => {
265
+ if (!listRef.current) return
266
+ const active = listRef.current.querySelector('[data-active="true"]')
267
+ active?.scrollIntoView({ block: 'nearest' })
268
+ }, [activeIndex])
269
+
270
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone/.test(navigator.userAgent ?? '')
271
+
272
+ return (
273
+ <AnimatePresence>
274
+ {open && (
275
+ <div className="fixed inset-0 z-50">
276
+ {/* Backdrop */}
277
+ <motion.div
278
+ initial={{ opacity: 0 }}
279
+ animate={{ opacity: 1 }}
280
+ exit={{ opacity: 0 }}
281
+ transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15 }}
282
+ className="absolute inset-0 bg-[hsl(var(--bg-base)/0.6)] backdrop-blur-sm"
283
+ onClick={() => setOpen(false)}
284
+ />
285
+
286
+ {/* Dialog */}
287
+ <motion.div
288
+ initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.96, y: -20 }}
289
+ animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1, y: 0 }}
290
+ exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.96, y: -20 }}
291
+ transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
292
+ className={cn(
293
+ 'absolute left-1/2 top-[15%] -translate-x-1/2',
294
+ 'w-full max-w-lg rounded-2xl overflow-hidden',
295
+ 'border border-[hsl(var(--border-default))]',
296
+ 'bg-[hsl(var(--bg-elevated))] shadow-2xl',
297
+ 'flex flex-col max-h-[70vh]',
298
+ className,
299
+ )}
300
+ onKeyDown={handleKeyDown}
301
+ >
302
+ {/* Search input */}
303
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-[hsl(var(--border-subtle)/0.5)]">
304
+ <Search className="h-5 w-5 text-[hsl(var(--text-tertiary))] shrink-0" />
305
+ <input
306
+ ref={inputRef}
307
+ type="text"
308
+ value={query}
309
+ onChange={e => setQuery(e.target.value)}
310
+ placeholder={placeholder}
311
+ className="flex-1 bg-transparent text-[hsl(var(--text-primary))] text-sm placeholder:text-[hsl(var(--text-tertiary))] outline-none"
312
+ />
313
+ <kbd className="hidden sm:inline-flex items-center gap-1 rounded-md border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--text-tertiary))] font-mono">
314
+ Esc
315
+ </kbd>
316
+ </div>
317
+
318
+ {/* Results list */}
319
+ <div ref={listRef} className="flex-1 overflow-y-auto py-2">
320
+ {flatItems.length === 0 && !isSearching && (
321
+ <div className="px-4 py-8 text-center text-sm text-[hsl(var(--text-tertiary))]">
322
+ {query ? 'No results found.' : 'No commands available.'}
323
+ </div>
324
+ )}
325
+
326
+ {isSearching && flatItems.length === 0 && (
327
+ <div className="px-4 py-8 flex items-center justify-center gap-2 text-sm text-[hsl(var(--text-tertiary))]">
328
+ <div className="h-4 w-4 rounded-full border-2 border-[hsl(var(--brand-primary))] border-t-transparent animate-spin" />
329
+ Searching...
330
+ </div>
331
+ )}
332
+
333
+ {[...groups.entries()].map(([groupName, groupItems]) => {
334
+ return (
335
+ <div key={groupName || '__ungrouped'}>
336
+ {groupName && (
337
+ <div className="px-4 pt-2 pb-1">
338
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-[hsl(var(--text-tertiary))]">
339
+ {groupName}
340
+ </span>
341
+ </div>
342
+ )}
343
+ {groupItems.map(item => {
344
+ const globalIdx = flatItems.indexOf(item)
345
+ const isActive = globalIdx === activeIndex
346
+ const Icon = item.icon
347
+ const isRecent = item.group === 'Recent'
348
+ return (
349
+ <button
350
+ key={item.id}
351
+ data-active={isActive}
352
+ onClick={() => handleSelect(item)}
353
+ onMouseEnter={() => setActiveIndex(globalIdx)}
354
+ className={cn(
355
+ 'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
356
+ isActive
357
+ ? 'bg-[hsl(var(--brand-primary)/0.1)]'
358
+ : 'hover:bg-[hsl(var(--bg-surface)/0.5)]',
359
+ )}
360
+ >
361
+ {Icon ? (
362
+ <Icon className={cn(
363
+ 'h-4 w-4 shrink-0',
364
+ isActive ? 'text-[hsl(var(--brand-primary))]' : 'text-[hsl(var(--text-tertiary))]',
365
+ )} />
366
+ ) : isRecent ? (
367
+ <Clock className={cn(
368
+ 'h-4 w-4 shrink-0',
369
+ isActive ? 'text-[hsl(var(--brand-primary))]' : 'text-[hsl(var(--text-tertiary))]',
370
+ )} />
371
+ ) : (
372
+ <div className="h-4 w-4 shrink-0" />
373
+ )}
374
+
375
+ <div className="flex-1 min-w-0">
376
+ <div className={cn(
377
+ 'text-sm font-medium truncate',
378
+ isActive ? 'text-[hsl(var(--text-primary))]' : 'text-[hsl(var(--text-primary))]',
379
+ )}>
380
+ {item.label}
381
+ </div>
382
+ {item.description && (
383
+ <div className="text-[11px] text-[hsl(var(--text-tertiary))] truncate mt-0.5">
384
+ {item.description}
385
+ </div>
386
+ )}
387
+ </div>
388
+
389
+ {item.shortcut && (
390
+ <kbd className="flex items-center gap-0.5 rounded-md border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--text-tertiary))] font-mono shrink-0">
391
+ {item.shortcut}
392
+ </kbd>
393
+ )}
394
+
395
+ {isActive && (
396
+ <CornerDownLeft className="h-3.5 w-3.5 text-[hsl(var(--text-tertiary))] shrink-0" />
397
+ )}
398
+ </button>
399
+ )
400
+ })}
401
+ </div>
402
+ )
403
+ })}
404
+ </div>
405
+
406
+ {/* Footer */}
407
+ <div className="flex items-center gap-4 px-4 py-2 border-t border-[hsl(var(--border-subtle)/0.5)] text-[10px] text-[hsl(var(--text-tertiary))]">
408
+ <span className="inline-flex items-center gap-1">
409
+ <kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">&uarr;&darr;</kbd>
410
+ Navigate
411
+ </span>
412
+ <span className="inline-flex items-center gap-1">
413
+ <kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">&crarr;</kbd>
414
+ Select
415
+ </span>
416
+ <span className="inline-flex items-center gap-1">
417
+ <kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">Esc</kbd>
418
+ Close
419
+ </span>
420
+ <span className="ml-auto inline-flex items-center gap-1">
421
+ <kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">
422
+ {isMac ? '\u2318' : 'Ctrl+'}
423
+ </kbd>
424
+ <kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono uppercase">
425
+ {hotkey}
426
+ </kbd>
427
+ Toggle
428
+ </span>
429
+ </div>
430
+ </motion.div>
431
+ </div>
432
+ )}
433
+ </AnimatePresence>
434
+ )
435
+ }
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { useState } from 'react'
5
+ import { motion, useReducedMotion } from 'framer-motion'
6
+ import { cn } from '../utils'
7
+
8
+ export interface ConfidenceBarProps {
9
+ /** Confidence value between 0 and 1 (probability). */
10
+ value: number
11
+ /** Optional label displayed before the bar. */
12
+ label?: string
13
+ /** Show percentage text beside the bar. */
14
+ showPercentage?: boolean
15
+ /** Thresholds for color zones. */
16
+ thresholds?: { low: number; medium: number }
17
+ /** Size preset. */
18
+ size?: 'sm' | 'md'
19
+ className?: string
20
+ }
21
+
22
+ const SIZE_CLASSES = {
23
+ sm: 'h-1.5',
24
+ md: 'h-2.5',
25
+ } as const
26
+
27
+ function getBarColor(value: number, thresholds: { low: number; medium: number }): string {
28
+ if (value < thresholds.low) return 'bg-[hsl(var(--status-critical))]'
29
+ if (value < thresholds.medium) return 'bg-[hsl(var(--status-warning))]'
30
+ return 'bg-[hsl(var(--status-ok))]'
31
+ }
32
+
33
+ function getTextColor(value: number, thresholds: { low: number; medium: number }): string {
34
+ if (value < thresholds.low) return 'text-[hsl(var(--status-critical))]'
35
+ if (value < thresholds.medium) return 'text-[hsl(var(--status-warning))]'
36
+ return 'text-[hsl(var(--status-ok))]'
37
+ }
38
+
39
+ /**
40
+ * @description A horizontal confidence/probability bar colored by threshold zones:
41
+ * red (<low), yellow (low-medium), green (>medium). Animates fill on mount.
42
+ */
43
+ export function ConfidenceBar({
44
+ value,
45
+ label,
46
+ showPercentage = true,
47
+ thresholds = { low: 0.3, medium: 0.7 },
48
+ size = 'md',
49
+ className,
50
+ }: ConfidenceBarProps): React.JSX.Element {
51
+ const reduced = useReducedMotion()
52
+ const [hovered, setHovered] = useState(false)
53
+ const clamped = Math.min(1, Math.max(0, value))
54
+ const pct = clamped * 100
55
+ const barColor = getBarColor(clamped, thresholds)
56
+ const textColor = getTextColor(clamped, thresholds)
57
+
58
+ return (
59
+ <div
60
+ className={cn('w-full', className)}
61
+ onMouseEnter={() => setHovered(true)}
62
+ onMouseLeave={() => setHovered(false)}
63
+ >
64
+ {/* Label + percentage row */}
65
+ {(label || showPercentage) && (
66
+ <div className="flex items-center justify-between mb-1">
67
+ {label && (
68
+ <span className="text-xs font-medium text-[hsl(var(--text-secondary))]">
69
+ {label}
70
+ </span>
71
+ )}
72
+ {showPercentage && (
73
+ <span className={cn('text-xs font-medium tabular-nums', textColor)}>
74
+ {pct.toFixed(1)}%
75
+ </span>
76
+ )}
77
+ </div>
78
+ )}
79
+
80
+ {/* Track */}
81
+ <div
82
+ className={cn(
83
+ 'relative w-full overflow-hidden rounded-full bg-[hsl(var(--bg-overlay))]',
84
+ SIZE_CLASSES[size],
85
+ )}
86
+ role="meter"
87
+ aria-valuenow={pct}
88
+ aria-valuemin={0}
89
+ aria-valuemax={100}
90
+ aria-label={label ?? `Confidence: ${pct.toFixed(1)}%`}
91
+ >
92
+ {reduced ? (
93
+ <div
94
+ className={cn('h-full rounded-full', barColor)}
95
+ style={{ width: `${pct}%` }}
96
+ />
97
+ ) : (
98
+ <motion.div
99
+ className={cn('h-full rounded-full', barColor)}
100
+ initial={{ width: 0 }}
101
+ animate={{ width: `${pct}%` }}
102
+ transition={{ type: 'spring', stiffness: 80, damping: 20 }}
103
+ />
104
+ )}
105
+ </div>
106
+
107
+ {/* Tooltip on hover showing exact value */}
108
+ {hovered && (
109
+ <div className="mt-1 text-[10px] text-[hsl(var(--text-tertiary))] tabular-nums">
110
+ {clamped.toFixed(4)} ({pct.toFixed(2)}%)
111
+ </div>
112
+ )}
113
+ </div>
114
+ )
115
+ }
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import * as AlertDialog from '@radix-ui/react-alert-dialog'
4
5
  import { motion, AnimatePresence } from 'framer-motion'
5
6
  import { AlertTriangle, Loader2 } from 'lucide-react'
@@ -55,7 +56,7 @@ export function ConfirmDialog({
55
56
  variant = 'danger',
56
57
  loading = false,
57
58
  onConfirm,
58
- }: ConfirmDialogProps) {
59
+ }: ConfirmDialogProps): React.JSX.Element {
59
60
  const styles = variantStyles[variant]
60
61
 
61
62
  return (