@annondeveloper/ui-kit 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/chunk-5OKSXPWK.js +270 -0
  4. package/dist/chunk-5OKSXPWK.js.map +1 -0
  5. package/dist/cli/index.js +430 -0
  6. package/dist/form.d.ts +65 -0
  7. package/dist/form.js +148 -0
  8. package/dist/form.js.map +1 -0
  9. package/dist/index.d.ts +942 -0
  10. package/dist/index.js +2812 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/select-nnBJUO8U.d.ts +26 -0
  13. package/package.json +114 -0
  14. package/src/components/animated-counter.stories.tsx +68 -0
  15. package/src/components/animated-counter.tsx +85 -0
  16. package/src/components/avatar.tsx +106 -0
  17. package/src/components/badge.stories.tsx +70 -0
  18. package/src/components/badge.tsx +97 -0
  19. package/src/components/button.stories.tsx +101 -0
  20. package/src/components/button.tsx +67 -0
  21. package/src/components/card.tsx +128 -0
  22. package/src/components/checkbox.stories.tsx +64 -0
  23. package/src/components/checkbox.tsx +58 -0
  24. package/src/components/confirm-dialog.stories.tsx +96 -0
  25. package/src/components/confirm-dialog.tsx +145 -0
  26. package/src/components/data-table.stories.tsx +125 -0
  27. package/src/components/data-table.tsx +791 -0
  28. package/src/components/dropdown-menu.tsx +111 -0
  29. package/src/components/empty-state.stories.tsx +42 -0
  30. package/src/components/empty-state.tsx +43 -0
  31. package/src/components/filter-pill.stories.tsx +71 -0
  32. package/src/components/filter-pill.tsx +45 -0
  33. package/src/components/form-input.stories.tsx +91 -0
  34. package/src/components/form-input.tsx +77 -0
  35. package/src/components/log-viewer.tsx +212 -0
  36. package/src/components/metric-card.tsx +141 -0
  37. package/src/components/pipeline-stage.tsx +134 -0
  38. package/src/components/popover.tsx +72 -0
  39. package/src/components/port-status-grid.tsx +102 -0
  40. package/src/components/progress.tsx +128 -0
  41. package/src/components/radio-group.tsx +162 -0
  42. package/src/components/select.stories.tsx +52 -0
  43. package/src/components/select.tsx +92 -0
  44. package/src/components/severity-timeline.tsx +125 -0
  45. package/src/components/sheet.tsx +164 -0
  46. package/src/components/skeleton.stories.tsx +64 -0
  47. package/src/components/skeleton.tsx +62 -0
  48. package/src/components/slider.tsx +208 -0
  49. package/src/components/sparkline.tsx +104 -0
  50. package/src/components/status-badge.stories.tsx +84 -0
  51. package/src/components/status-badge.tsx +71 -0
  52. package/src/components/status-pulse.stories.tsx +56 -0
  53. package/src/components/status-pulse.tsx +78 -0
  54. package/src/components/success-checkmark.stories.tsx +67 -0
  55. package/src/components/success-checkmark.tsx +53 -0
  56. package/src/components/tabs.tsx +177 -0
  57. package/src/components/threshold-gauge.tsx +149 -0
  58. package/src/components/time-range-selector.tsx +86 -0
  59. package/src/components/toast.stories.tsx +70 -0
  60. package/src/components/toast.tsx +48 -0
  61. package/src/components/toggle-switch.stories.tsx +66 -0
  62. package/src/components/toggle-switch.tsx +51 -0
  63. package/src/components/tooltip.tsx +62 -0
  64. package/src/components/truncated-text.stories.tsx +56 -0
  65. package/src/components/truncated-text.tsx +80 -0
  66. package/src/components/uptime-tracker.tsx +138 -0
  67. package/src/components/utilization-bar.tsx +103 -0
  68. package/src/theme.css +178 -0
  69. package/src/utils.ts +123 -0
@@ -0,0 +1,141 @@
1
+ 'use client'
2
+
3
+ import { type ElementType } from 'react'
4
+ import { motion, useReducedMotion } from 'framer-motion'
5
+ import { TrendingUp, TrendingDown } from 'lucide-react'
6
+ import { cn } from '../utils'
7
+ import { AnimatedCounter } from './animated-counter'
8
+ import { Sparkline } from './sparkline'
9
+
10
+ export interface MetricCardProps {
11
+ /** Metric display label. */
12
+ label: string
13
+ /** Current numeric value. */
14
+ value: number
15
+ /** Custom formatter for the displayed value (e.g. fmtBytes, fmtBps). */
16
+ format?: (n: number) => string
17
+ /** Previous value for trend calculation. */
18
+ previousValue?: number
19
+ /** Interpret trend direction for coloring. */
20
+ trendDirection?: 'up-good' | 'up-bad' | 'down-good' | 'down-bad'
21
+ /** Lucide icon component to display. */
22
+ icon?: ElementType
23
+ /** Status determines left border accent color. */
24
+ status?: 'ok' | 'warning' | 'critical'
25
+ /** Recent values to render an inline sparkline. */
26
+ sparklineData?: number[]
27
+ className?: string
28
+ }
29
+
30
+ const statusBorder: Record<string, string> = {
31
+ ok: 'border-l-[hsl(var(--status-ok))]',
32
+ warning: 'border-l-[hsl(var(--status-warning))]',
33
+ critical: 'border-l-[hsl(var(--status-critical))]',
34
+ }
35
+
36
+ const trendColors: Record<string, string> = {
37
+ good: 'text-[hsl(var(--status-ok))]',
38
+ bad: 'text-[hsl(var(--status-critical))]',
39
+ }
40
+
41
+ /**
42
+ * @description A dashboard stat tile showing a metric value with animated counter,
43
+ * trend indicator, optional sparkline, and status-colored left border.
44
+ * Designed for monitoring dashboards and overview panels.
45
+ */
46
+ export function MetricCard({
47
+ label,
48
+ value,
49
+ format,
50
+ previousValue,
51
+ trendDirection,
52
+ icon: Icon,
53
+ status,
54
+ sparklineData,
55
+ className,
56
+ }: MetricCardProps) {
57
+ const reduced = useReducedMotion()
58
+
59
+ // Trend calculation
60
+ let trendPct: number | null = null
61
+ let trendUp = false
62
+ let trendColorKey: 'good' | 'bad' | null = null
63
+
64
+ if (previousValue != null && previousValue !== 0) {
65
+ trendPct = ((value - previousValue) / Math.abs(previousValue)) * 100
66
+ trendUp = trendPct >= 0
67
+
68
+ if (trendDirection) {
69
+ const isUp = trendPct >= 0
70
+ if (trendDirection === 'up-good') trendColorKey = isUp ? 'good' : 'bad'
71
+ else if (trendDirection === 'up-bad') trendColorKey = isUp ? 'bad' : 'good'
72
+ else if (trendDirection === 'down-good') trendColorKey = isUp ? 'bad' : 'good'
73
+ else if (trendDirection === 'down-bad') trendColorKey = isUp ? 'good' : 'bad'
74
+ }
75
+ }
76
+
77
+ return (
78
+ <motion.div
79
+ initial={reduced ? false : { opacity: 0, y: 8 }}
80
+ animate={{ opacity: 1, y: 0 }}
81
+ transition={{ duration: 0.2, ease: 'easeOut' }}
82
+ className={cn(
83
+ 'relative rounded-2xl border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))]',
84
+ 'p-5 shadow-sm border-l-[3px]',
85
+ status ? statusBorder[status] : 'border-l-[hsl(var(--border-subtle))]',
86
+ className,
87
+ )}
88
+ >
89
+ <div className="flex items-start justify-between gap-3">
90
+ <div className="min-w-0 flex-1">
91
+ <div className="flex items-center gap-2 mb-1">
92
+ {Icon && (
93
+ <Icon className="size-4 shrink-0 text-[hsl(var(--text-tertiary))]" />
94
+ )}
95
+ <span className="text-[0.75rem] font-medium text-[hsl(var(--text-secondary))] truncate">
96
+ {label}
97
+ </span>
98
+ </div>
99
+
100
+ <div className="flex items-baseline gap-2">
101
+ <span className="text-2xl font-semibold text-[hsl(var(--text-primary))] tabular-nums">
102
+ <AnimatedCounter value={value} format={format} />
103
+ </span>
104
+
105
+ {trendPct != null && (
106
+ <span
107
+ className={cn(
108
+ 'inline-flex items-center gap-0.5 text-[0.6875rem] font-medium tabular-nums',
109
+ trendColorKey ? trendColors[trendColorKey] : 'text-[hsl(var(--text-tertiary))]',
110
+ )}
111
+ >
112
+ {trendUp ? (
113
+ <TrendingUp className="size-3" />
114
+ ) : (
115
+ <TrendingDown className="size-3" />
116
+ )}
117
+ {Math.abs(trendPct).toFixed(1)}%
118
+ </span>
119
+ )}
120
+ </div>
121
+ </div>
122
+
123
+ {sparklineData && sparklineData.length >= 2 && (
124
+ <Sparkline
125
+ data={sparklineData}
126
+ width={72}
127
+ height={28}
128
+ color={
129
+ status === 'critical'
130
+ ? 'hsl(var(--status-critical))'
131
+ : status === 'warning'
132
+ ? 'hsl(var(--status-warning))'
133
+ : 'hsl(var(--brand-primary))'
134
+ }
135
+ fillOpacity={0.15}
136
+ />
137
+ )}
138
+ </div>
139
+ </motion.div>
140
+ )
141
+ }
@@ -0,0 +1,134 @@
1
+ 'use client'
2
+
3
+ import { type ElementType } from 'react'
4
+ import { motion, useReducedMotion } from 'framer-motion'
5
+ import { ChevronRight } from 'lucide-react'
6
+ import { cn } from '../utils'
7
+
8
+ export interface StageInfo {
9
+ /** Stage display name. */
10
+ name: string
11
+ /** Current stage status. */
12
+ status: 'active' | 'idle' | 'error' | 'disabled'
13
+ /** Optional metric to display inside the stage box. */
14
+ metric?: { label: string; value: string }
15
+ /** Lucide icon component for the stage. */
16
+ icon?: ElementType
17
+ }
18
+
19
+ export interface PipelineStageProps {
20
+ /** Ordered array of processing stages. */
21
+ stages: StageInfo[]
22
+ /** Callback when a stage is clicked. */
23
+ onStageClick?: (stage: StageInfo, index: number) => void
24
+ className?: string
25
+ }
26
+
27
+ const statusDot: Record<string, string> = {
28
+ active: 'bg-[hsl(var(--status-ok))]',
29
+ idle: 'bg-[hsl(var(--text-tertiary))]',
30
+ error: 'bg-[hsl(var(--status-critical))]',
31
+ disabled: 'bg-[hsl(var(--text-disabled))]',
32
+ }
33
+
34
+ const statusBorder: Record<string, string> = {
35
+ active: 'border-[hsl(var(--status-ok))]/30',
36
+ idle: 'border-[hsl(var(--border-default))]',
37
+ error: 'border-[hsl(var(--status-critical))]/30',
38
+ disabled: 'border-[hsl(var(--border-subtle))]',
39
+ }
40
+
41
+ const statusBg: Record<string, string> = {
42
+ active: 'bg-[hsl(var(--bg-surface))]',
43
+ idle: 'bg-[hsl(var(--bg-surface))]',
44
+ error: 'bg-[hsl(var(--status-critical))]/5',
45
+ disabled: 'bg-[hsl(var(--bg-elevated))]',
46
+ }
47
+
48
+ /**
49
+ * @description A horizontal data pipeline visualization showing processing stages
50
+ * connected by animated chevron arrows. Each stage displays name, status dot,
51
+ * optional icon, and optional metric. Designed for data pipeline monitoring views.
52
+ */
53
+ export function PipelineStage({
54
+ stages,
55
+ onStageClick,
56
+ className,
57
+ }: PipelineStageProps) {
58
+ const reduced = useReducedMotion()
59
+
60
+ return (
61
+ <div className={cn('flex items-center gap-0 overflow-x-auto', className)}>
62
+ {stages.map((stage, i) => {
63
+ const Icon = stage.icon
64
+ const isDisabled = stage.status === 'disabled'
65
+
66
+ return (
67
+ <div key={`${stage.name}-${i}`} className="flex items-center shrink-0">
68
+ {/* Arrow connector */}
69
+ {i > 0 && (
70
+ <motion.div
71
+ initial={reduced ? false : { opacity: 0, x: -4 }}
72
+ animate={{ opacity: 1, x: 0 }}
73
+ transition={{ duration: 0.15, delay: reduced ? 0 : i * 0.05 }}
74
+ className="px-1"
75
+ >
76
+ <ChevronRight className="size-4 text-[hsl(var(--text-tertiary))]" />
77
+ </motion.div>
78
+ )}
79
+
80
+ {/* Stage box */}
81
+ <motion.button
82
+ type="button"
83
+ initial={reduced ? false : { opacity: 0, y: 6 }}
84
+ animate={{ opacity: 1, y: 0 }}
85
+ transition={{ duration: 0.2, delay: reduced ? 0 : i * 0.06 }}
86
+ onClick={() => onStageClick?.(stage, i)}
87
+ disabled={isDisabled && !onStageClick}
88
+ className={cn(
89
+ 'flex flex-col items-start gap-1.5 px-4 py-3 rounded-xl border',
90
+ 'transition-all min-w-[120px]',
91
+ statusBorder[stage.status],
92
+ statusBg[stage.status],
93
+ onStageClick && !isDisabled && 'cursor-pointer hover:bg-[hsl(var(--bg-elevated))]',
94
+ isDisabled && 'opacity-50',
95
+ )}
96
+ >
97
+ {/* Header row: icon + name + status dot */}
98
+ <div className="flex items-center gap-2 w-full">
99
+ {Icon && (
100
+ <Icon className={cn('size-3.5 shrink-0', isDisabled ? 'text-[hsl(var(--text-disabled))]' : 'text-[hsl(var(--text-secondary))]')} />
101
+ )}
102
+ <span className={cn(
103
+ 'text-[0.8125rem] font-medium truncate',
104
+ isDisabled ? 'text-[hsl(var(--text-disabled))]' : 'text-[hsl(var(--text-primary))]',
105
+ )}>
106
+ {stage.name}
107
+ </span>
108
+ <span
109
+ className={cn(
110
+ 'size-2 rounded-full shrink-0 ml-auto',
111
+ statusDot[stage.status],
112
+ stage.status === 'active' && 'animate-pulse',
113
+ )}
114
+ />
115
+ </div>
116
+
117
+ {/* Metric */}
118
+ {stage.metric && (
119
+ <div className="flex items-baseline gap-1">
120
+ <span className="text-[0.875rem] font-semibold text-[hsl(var(--text-primary))] tabular-nums">
121
+ {stage.metric.value}
122
+ </span>
123
+ <span className="text-[0.625rem] text-[hsl(var(--text-tertiary))]">
124
+ {stage.metric.label}
125
+ </span>
126
+ </div>
127
+ )}
128
+ </motion.button>
129
+ </div>
130
+ )
131
+ })}
132
+ </div>
133
+ )
134
+ }
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { type ReactNode } from 'react'
4
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
5
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
6
+ import { cn } from '../utils'
7
+
8
+ export interface PopoverProps {
9
+ /** Trigger element that opens the popover. */
10
+ trigger: ReactNode
11
+ /** Popover content. */
12
+ children: ReactNode
13
+ /** Side of the trigger to display the popover. */
14
+ side?: 'top' | 'right' | 'bottom' | 'left'
15
+ /** Alignment of the popover relative to the trigger. */
16
+ align?: 'start' | 'center' | 'end'
17
+ /** Additional class name for the content container. */
18
+ className?: string
19
+ }
20
+
21
+ const contentVariants = {
22
+ hidden: { opacity: 0, scale: 0.96, y: -2 },
23
+ visible: { opacity: 1, scale: 1, y: 0 },
24
+ exit: { opacity: 0, scale: 0.96, y: -2 },
25
+ }
26
+
27
+ /**
28
+ * @description A popover wrapper built on Radix Popover with Framer Motion entry animation.
29
+ * Closes on outside click. Includes an arrow pointer.
30
+ */
31
+ export function Popover({ trigger, children, side = 'bottom', align = 'center', className }: PopoverProps) {
32
+ const prefersReducedMotion = useReducedMotion()
33
+
34
+ return (
35
+ <PopoverPrimitive.Root>
36
+ <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
37
+
38
+ <AnimatePresence>
39
+ <PopoverPrimitive.Portal>
40
+ <PopoverPrimitive.Content
41
+ side={side}
42
+ align={align}
43
+ sideOffset={8}
44
+ asChild
45
+ >
46
+ <motion.div
47
+ initial="hidden"
48
+ animate="visible"
49
+ exit="exit"
50
+ variants={contentVariants}
51
+ transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
52
+ className={cn(
53
+ 'z-50 rounded-xl p-4',
54
+ 'border border-[hsl(var(--border-default))]',
55
+ 'bg-[hsl(var(--bg-elevated))] shadow-xl',
56
+ 'focus:outline-none',
57
+ className,
58
+ )}
59
+ >
60
+ {children}
61
+ <PopoverPrimitive.Arrow
62
+ className="fill-[hsl(var(--bg-elevated))]"
63
+ width={12}
64
+ height={6}
65
+ />
66
+ </motion.div>
67
+ </PopoverPrimitive.Content>
68
+ </PopoverPrimitive.Portal>
69
+ </AnimatePresence>
70
+ </PopoverPrimitive.Root>
71
+ )
72
+ }
@@ -0,0 +1,102 @@
1
+ 'use client'
2
+
3
+ import { motion, useReducedMotion } from 'framer-motion'
4
+ import { cn } from '../utils'
5
+
6
+ export interface PortStatus {
7
+ /** Unique port identifier. */
8
+ id: string
9
+ /** Display label (e.g. "Gi1/0/1"). */
10
+ label: string
11
+ /** Port operational status. */
12
+ status: 'up' | 'down' | 'disabled' | 'error'
13
+ /** Link speed display string (e.g. "10G", "1G"). */
14
+ speed?: string
15
+ /** Port utilization 0-100 percentage. */
16
+ utilization?: number
17
+ }
18
+
19
+ export interface PortStatusGridProps {
20
+ /** Array of port status objects. */
21
+ ports: PortStatus[]
22
+ /** Number of columns. Defaults to auto-fit. */
23
+ columns?: number
24
+ /** Dot/rectangle size. */
25
+ size?: 'sm' | 'md'
26
+ /** Callback when a port is clicked. */
27
+ onPortClick?: (port: PortStatus) => void
28
+ className?: string
29
+ }
30
+
31
+ const statusColor: Record<string, string> = {
32
+ up: 'bg-[hsl(var(--status-ok))]',
33
+ down: 'bg-[hsl(var(--status-critical))]',
34
+ disabled: 'bg-[hsl(var(--text-disabled))]',
35
+ error: 'bg-[hsl(var(--status-warning))]',
36
+ }
37
+
38
+ const statusHover: Record<string, string> = {
39
+ up: 'hover:ring-[hsl(var(--status-ok))]/40',
40
+ down: 'hover:ring-[hsl(var(--status-critical))]/40',
41
+ disabled: 'hover:ring-[hsl(var(--text-disabled))]/40',
42
+ error: 'hover:ring-[hsl(var(--status-warning))]/40',
43
+ }
44
+
45
+ const sizeClasses = {
46
+ sm: 'size-3 rounded-[2px]',
47
+ md: 'size-5 rounded-[3px]',
48
+ }
49
+
50
+ /**
51
+ * @description A grid of small colored indicators representing network ports or
52
+ * interfaces. Each port is colored by operational status with hover tooltips showing
53
+ * label, speed, and utilization. Designed for switch/router faceplate visualizations.
54
+ */
55
+ export function PortStatusGrid({
56
+ ports,
57
+ columns,
58
+ size = 'sm',
59
+ onPortClick,
60
+ className,
61
+ }: PortStatusGridProps) {
62
+ const reduced = useReducedMotion()
63
+
64
+ const gridStyle = columns
65
+ ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }
66
+ : { gridTemplateColumns: `repeat(auto-fill, minmax(${size === 'sm' ? '12px' : '20px'}, 1fr))` }
67
+
68
+ return (
69
+ <div
70
+ className={cn('grid gap-1', className)}
71
+ style={gridStyle}
72
+ role="grid"
73
+ aria-label="Port status grid"
74
+ >
75
+ {ports.map((port, i) => {
76
+ const tooltipParts = [port.label]
77
+ if (port.speed) tooltipParts.push(port.speed)
78
+ if (port.utilization != null) tooltipParts.push(`${port.utilization.toFixed(0)}%`)
79
+ tooltipParts.push(port.status)
80
+
81
+ return (
82
+ <motion.button
83
+ key={port.id}
84
+ type="button"
85
+ initial={reduced ? false : { opacity: 0, scale: 0.5 }}
86
+ animate={{ opacity: 1, scale: 1 }}
87
+ transition={{ duration: 0.1, delay: reduced ? 0 : Math.min(i * 0.008, 0.4) }}
88
+ onClick={() => onPortClick?.(port)}
89
+ title={tooltipParts.join(' \u00b7 ')}
90
+ aria-label={`${port.label}: ${port.status}`}
91
+ className={cn(
92
+ 'transition-all cursor-pointer ring-0 hover:ring-2',
93
+ sizeClasses[size],
94
+ statusColor[port.status] ?? statusColor.disabled,
95
+ statusHover[port.status] ?? statusHover.disabled,
96
+ )}
97
+ />
98
+ )
99
+ })}
100
+ </div>
101
+ )
102
+ }
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import { motion, useReducedMotion } from 'framer-motion'
4
+ import { cn } from '../utils'
5
+
6
+ export interface ProgressProps {
7
+ /** Current progress value (0-100 by default). */
8
+ value: number
9
+ /** Maximum value. */
10
+ max?: number
11
+ /** Optional label displayed above the bar. */
12
+ label?: string
13
+ /** Show the current percentage value. */
14
+ showValue?: boolean
15
+ /** Color variant. */
16
+ variant?: 'default' | 'success' | 'warning' | 'danger'
17
+ /** Height preset. */
18
+ size?: 'sm' | 'md' | 'lg'
19
+ /** Animate the fill width on value changes. */
20
+ animated?: boolean
21
+ /** Show an indeterminate shimmer animation (ignores value). */
22
+ indeterminate?: boolean
23
+ /** Additional class name for the root element. */
24
+ className?: string
25
+ }
26
+
27
+ const variantColors: Record<NonNullable<ProgressProps['variant']>, string> = {
28
+ default: 'bg-[hsl(var(--brand-primary))]',
29
+ success: 'bg-[hsl(var(--status-ok))]',
30
+ warning: 'bg-[hsl(var(--status-warning))]',
31
+ danger: 'bg-[hsl(var(--status-critical))]',
32
+ }
33
+
34
+ const sizeClasses: Record<NonNullable<ProgressProps['size']>, string> = {
35
+ sm: 'h-1.5',
36
+ md: 'h-2.5',
37
+ lg: 'h-4',
38
+ }
39
+
40
+ /**
41
+ * @description A progress bar with optional label, animated fill, and indeterminate mode.
42
+ * Supports multiple color variants and size presets.
43
+ */
44
+ export function Progress({
45
+ value,
46
+ max = 100,
47
+ label,
48
+ showValue = false,
49
+ variant = 'default',
50
+ size = 'md',
51
+ animated = true,
52
+ indeterminate = false,
53
+ className,
54
+ }: ProgressProps) {
55
+ const prefersReducedMotion = useReducedMotion()
56
+ const pct = Math.min(100, Math.max(0, (value / max) * 100))
57
+
58
+ return (
59
+ <div className={cn('w-full', className)}>
60
+ {/* Label + value row */}
61
+ {(label || showValue) && (
62
+ <div className="flex items-center justify-between mb-1.5">
63
+ {label && (
64
+ <span className="text-xs font-medium text-[hsl(var(--text-secondary))]">{label}</span>
65
+ )}
66
+ {showValue && !indeterminate && (
67
+ <span className="text-xs font-medium tabular-nums text-[hsl(var(--text-secondary))]">
68
+ {Math.round(pct)}%
69
+ </span>
70
+ )}
71
+ </div>
72
+ )}
73
+
74
+ {/* Track */}
75
+ <div
76
+ role="progressbar"
77
+ aria-valuenow={indeterminate ? undefined : pct}
78
+ aria-valuemin={0}
79
+ aria-valuemax={100}
80
+ aria-label={label}
81
+ className={cn(
82
+ 'w-full overflow-hidden rounded-full bg-[hsl(var(--bg-overlay))]',
83
+ sizeClasses[size],
84
+ )}
85
+ >
86
+ {indeterminate ? (
87
+ <div
88
+ className={cn(
89
+ 'h-full w-1/3 rounded-full',
90
+ variantColors[variant],
91
+ prefersReducedMotion ? '' : 'animate-shimmer',
92
+ )}
93
+ style={
94
+ prefersReducedMotion
95
+ ? { width: '33%' }
96
+ : {
97
+ animation: 'indeterminate-slide 1.5s ease-in-out infinite',
98
+ }
99
+ }
100
+ />
101
+ ) : animated && !prefersReducedMotion ? (
102
+ <motion.div
103
+ className={cn('h-full rounded-full', variantColors[variant])}
104
+ initial={{ width: 0 }}
105
+ animate={{ width: `${pct}%` }}
106
+ transition={{ type: 'spring', stiffness: 100, damping: 20 }}
107
+ />
108
+ ) : (
109
+ <div
110
+ className={cn('h-full rounded-full transition-[width] duration-300', variantColors[variant])}
111
+ style={{ width: `${pct}%` }}
112
+ />
113
+ )}
114
+ </div>
115
+
116
+ {/* Indeterminate keyframes injected as inline style */}
117
+ {indeterminate && !prefersReducedMotion && (
118
+ <style>{`
119
+ @keyframes indeterminate-slide {
120
+ 0% { transform: translateX(-100%); }
121
+ 50% { transform: translateX(200%); }
122
+ 100% { transform: translateX(-100%); }
123
+ }
124
+ `}</style>
125
+ )}
126
+ </div>
127
+ )
128
+ }