@khal-os/ui 1.0.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 (51) hide show
  1. package/package.json +41 -0
  2. package/src/components/ContextMenu.tsx +130 -0
  3. package/src/components/avatar.tsx +71 -0
  4. package/src/components/badge.tsx +39 -0
  5. package/src/components/button.tsx +102 -0
  6. package/src/components/command.tsx +165 -0
  7. package/src/components/cost-counter.tsx +75 -0
  8. package/src/components/data-row.tsx +97 -0
  9. package/src/components/dropdown-menu.tsx +233 -0
  10. package/src/components/glass-card.tsx +74 -0
  11. package/src/components/input.tsx +48 -0
  12. package/src/components/khal-logo.tsx +73 -0
  13. package/src/components/live-feed.tsx +109 -0
  14. package/src/components/mesh-gradient.tsx +57 -0
  15. package/src/components/metric-display.tsx +93 -0
  16. package/src/components/note.tsx +55 -0
  17. package/src/components/number-flow.tsx +25 -0
  18. package/src/components/pill-badge.tsx +65 -0
  19. package/src/components/progress-bar.tsx +70 -0
  20. package/src/components/section-card.tsx +76 -0
  21. package/src/components/separator.tsx +25 -0
  22. package/src/components/spinner.tsx +42 -0
  23. package/src/components/status-dot.tsx +90 -0
  24. package/src/components/switch.tsx +36 -0
  25. package/src/components/theme-provider.tsx +58 -0
  26. package/src/components/theme-switcher.tsx +59 -0
  27. package/src/components/ticker-bar.tsx +41 -0
  28. package/src/components/tooltip.tsx +62 -0
  29. package/src/components/window-minimized-context.tsx +29 -0
  30. package/src/hooks/useReducedMotion.ts +21 -0
  31. package/src/index.ts +58 -0
  32. package/src/lib/animations.ts +50 -0
  33. package/src/primitives/collapsible-sidebar.tsx +226 -0
  34. package/src/primitives/dialog.tsx +76 -0
  35. package/src/primitives/empty-state.tsx +43 -0
  36. package/src/primitives/index.ts +22 -0
  37. package/src/primitives/list-view.tsx +155 -0
  38. package/src/primitives/property-panel.tsx +108 -0
  39. package/src/primitives/section-header.tsx +19 -0
  40. package/src/primitives/sidebar-nav.tsx +110 -0
  41. package/src/primitives/split-pane.tsx +146 -0
  42. package/src/primitives/status-badge.tsx +10 -0
  43. package/src/primitives/status-bar.tsx +100 -0
  44. package/src/primitives/toolbar.tsx +152 -0
  45. package/src/server.ts +4 -0
  46. package/src/stores/notification-store.ts +271 -0
  47. package/src/stores/theme-store.ts +33 -0
  48. package/src/tokens/lp-tokens.ts +36 -0
  49. package/src/utils.ts +6 -0
  50. package/tokens.css +295 -0
  51. package/tsconfig.json +17 -0
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@khal-os/ui",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "scripts": {
6
+ "build": "echo 'build placeholder'",
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "biome check ."
9
+ },
10
+ "main": "./src/index.ts",
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./server": "./src/server.ts",
14
+ "./tokens.css": "./tokens.css"
15
+ },
16
+ "peerDependencies": {
17
+ "react": ">=19",
18
+ "next-themes": ">=0.4",
19
+ "zustand": ">=4"
20
+ },
21
+ "dependencies": {
22
+ "@number-flow/react": "^0.6.0",
23
+ "@paper-design/shaders-react": "^0.0.72",
24
+ "@radix-ui/react-context-menu": "^2.2.16",
25
+ "@radix-ui/react-dialog": "^1.1.15",
26
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
27
+ "@radix-ui/react-separator": "^1.1.8",
28
+ "@radix-ui/react-slot": "^1.2.4",
29
+ "@radix-ui/react-switch": "^1.2.6",
30
+ "@radix-ui/react-tooltip": "^1.2.8",
31
+ "class-variance-authority": "^0.7.1",
32
+ "clsx": "^2.1.1",
33
+ "cmdk": "^1.1.1",
34
+ "lucide-react": "^0.563.0",
35
+ "motion": "^12.38.0",
36
+ "tailwind-merge": "^3.4.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
4
+ import * as React from 'react';
5
+ import { cn } from '../utils';
6
+
7
+ const ContextMenu = ContextMenuPrimitive.Root;
8
+ const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
9
+ const ContextMenuGroup = ContextMenuPrimitive.Group;
10
+ const ContextMenuPortal = ContextMenuPrimitive.Portal;
11
+ const ContextMenuSub = ContextMenuPrimitive.Sub;
12
+ const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
13
+
14
+ const ContextMenuSubTrigger = React.forwardRef<
15
+ React.ComponentRef<typeof ContextMenuPrimitive.SubTrigger>,
16
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
17
+ inset?: boolean;
18
+ }
19
+ >(({ className, inset, children, ...props }, ref) => (
20
+ <ContextMenuPrimitive.SubTrigger
21
+ ref={ref}
22
+ className={cn(
23
+ 'flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-copy-13 outline-none select-none',
24
+ 'focus:bg-[var(--khal-menu-hover)]',
25
+ 'data-[state=open]:bg-[var(--khal-menu-hover)]',
26
+ inset && 'pl-8',
27
+ className
28
+ )}
29
+ style={{ color: 'var(--khal-text-primary)' }}
30
+ {...props}
31
+ >
32
+ {children}
33
+ </ContextMenuPrimitive.SubTrigger>
34
+ ));
35
+ ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
36
+
37
+ const ContextMenuSubContent = React.forwardRef<
38
+ React.ComponentRef<typeof ContextMenuPrimitive.SubContent>,
39
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
40
+ >(({ className, ...props }, ref) => (
41
+ <ContextMenuPrimitive.SubContent
42
+ ref={ref}
43
+ className={cn(
44
+ 'z-[9999] min-w-[8rem] overflow-hidden rounded-xl p-1',
45
+ 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
46
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
47
+ className
48
+ )}
49
+ style={{
50
+ background: 'var(--khal-menu-bg)',
51
+ border: '1px solid var(--khal-menu-border)',
52
+ boxShadow: 'var(--khal-menu-shadow)',
53
+ color: 'var(--khal-text-primary)',
54
+ }}
55
+ {...props}
56
+ />
57
+ ));
58
+ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
59
+
60
+ const ContextMenuContent = React.forwardRef<
61
+ React.ComponentRef<typeof ContextMenuPrimitive.Content>,
62
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
63
+ >(({ className, ...props }, ref) => (
64
+ <ContextMenuPrimitive.Portal>
65
+ <ContextMenuPrimitive.Content
66
+ ref={ref}
67
+ className={cn(
68
+ 'z-[9999] min-w-[8rem] overflow-hidden rounded-xl p-1',
69
+ 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
70
+ className
71
+ )}
72
+ style={{
73
+ background: 'var(--khal-menu-bg)',
74
+ border: '1px solid var(--khal-menu-border)',
75
+ boxShadow: 'var(--khal-menu-shadow)',
76
+ color: 'var(--khal-text-primary)',
77
+ }}
78
+ {...props}
79
+ />
80
+ </ContextMenuPrimitive.Portal>
81
+ ));
82
+ ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
83
+
84
+ const ContextMenuItem = React.forwardRef<
85
+ React.ComponentRef<typeof ContextMenuPrimitive.Item>,
86
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
87
+ inset?: boolean;
88
+ }
89
+ >(({ className, inset, ...props }, ref) => (
90
+ <ContextMenuPrimitive.Item
91
+ ref={ref}
92
+ className={cn(
93
+ 'relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-copy-13 outline-none select-none transition-colors',
94
+ 'focus:bg-[var(--khal-menu-hover)]',
95
+ 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
96
+ inset && 'pl-8',
97
+ className
98
+ )}
99
+ style={{ color: 'var(--khal-text-primary)' }}
100
+ {...props}
101
+ />
102
+ ));
103
+ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
104
+
105
+ const ContextMenuSeparator = React.forwardRef<
106
+ React.ComponentRef<typeof ContextMenuPrimitive.Separator>,
107
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
108
+ >(({ className, ...props }, ref) => (
109
+ <ContextMenuPrimitive.Separator
110
+ ref={ref}
111
+ className={cn('-mx-1 my-1 h-px', className)}
112
+ style={{ background: 'var(--khal-border-default)' }}
113
+ {...props}
114
+ />
115
+ ));
116
+ ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
117
+
118
+ export {
119
+ ContextMenu,
120
+ ContextMenuContent,
121
+ ContextMenuGroup,
122
+ ContextMenuItem,
123
+ ContextMenuPortal,
124
+ ContextMenuRadioGroup,
125
+ ContextMenuSeparator,
126
+ ContextMenuSub,
127
+ ContextMenuSubContent,
128
+ ContextMenuSubTrigger,
129
+ ContextMenuTrigger,
130
+ };
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../utils';
5
+ import { StatusDot } from './status-dot';
6
+
7
+ const sizeMap = { sm: 24, md: 32, lg: 40 } as const;
8
+ const fontSizeMap = { sm: '10px', md: '12px', lg: '14px' } as const;
9
+
10
+ const statusColorMap: Record<string, string> = {
11
+ online: 'var(--khal-status-live)',
12
+ idle: 'var(--khal-status-warning)',
13
+ away: 'var(--khal-status-idle)',
14
+ };
15
+
16
+ interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
17
+ name: string;
18
+ size?: keyof typeof sizeMap;
19
+ status?: 'online' | 'idle' | 'away' | null;
20
+ src?: string;
21
+ }
22
+
23
+ function getInitials(name: string): string {
24
+ const parts = name.trim().split(/\s+/);
25
+ if (parts.length >= 2) {
26
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
27
+ }
28
+ return name.charAt(0).toUpperCase();
29
+ }
30
+
31
+ const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
32
+ ({ name, size = 'md', status, src, className, style, ...props }, ref) => {
33
+ const px = sizeMap[size];
34
+ const [imgError, setImgError] = React.useState(false);
35
+ const showImage = src && !imgError;
36
+
37
+ return (
38
+ <div
39
+ ref={ref}
40
+ className={cn('relative inline-flex shrink-0', className)}
41
+ style={{ width: px, height: px, ...style }}
42
+ {...props}
43
+ >
44
+ {showImage ? (
45
+ <img
46
+ src={src}
47
+ alt={name}
48
+ className="h-full w-full rounded-full object-cover"
49
+ onError={() => setImgError(true)}
50
+ />
51
+ ) : (
52
+ <div
53
+ className="flex h-full w-full select-none items-center justify-center rounded-full border font-medium [background:var(--khal-surface-raised)] [border-color:var(--khal-border-subtle)]"
54
+ style={{ fontSize: fontSizeMap[size] }}
55
+ >
56
+ {getInitials(name)}
57
+ </div>
58
+ )}
59
+ {status && (
60
+ <div className="absolute -bottom-px -right-px">
61
+ <StatusDot color={statusColorMap[status]} size="sm" label={status} pulse={status === 'online'} />
62
+ </div>
63
+ )}
64
+ </div>
65
+ );
66
+ }
67
+ );
68
+ Avatar.displayName = 'Avatar';
69
+
70
+ export type { AvatarProps };
71
+ export { Avatar };
@@ -0,0 +1,39 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import * as React from 'react';
3
+ import { cn } from '../utils';
4
+
5
+ const badgeVariants = cva(
6
+ 'inline-flex items-center rounded-full border font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-700 focus:ring-offset-2',
7
+ {
8
+ variants: {
9
+ variant: {
10
+ gray: 'border-gray-alpha-400 bg-gray-100 text-gray-900',
11
+ blue: 'border-blue-300 bg-blue-100 text-blue-900',
12
+ green: 'border-green-300 bg-green-100 text-green-900',
13
+ amber: 'border-amber-300 bg-amber-100 text-amber-900',
14
+ red: 'border-red-300 bg-red-100 text-red-900',
15
+ purple: 'border-purple-300 bg-purple-100 text-purple-900',
16
+ pink: 'border-pink-300 bg-pink-100 text-pink-900',
17
+ teal: 'border-teal-300 bg-teal-100 text-teal-900',
18
+ },
19
+ size: {
20
+ sm: 'px-2 py-0 text-[11px] leading-[18px]',
21
+ md: 'px-2.5 py-0.5 text-[12px] leading-[18px]',
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ variant: 'gray',
26
+ size: 'md',
27
+ },
28
+ }
29
+ );
30
+
31
+ interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {
32
+ contrast?: 'low' | 'high';
33
+ }
34
+
35
+ function Badge({ className, variant, size, contrast, ...props }: BadgeProps) {
36
+ return <span className={cn(badgeVariants({ variant, size }), className)} {...props} />;
37
+ }
38
+
39
+ export { Badge, badgeVariants };
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { Slot } from '@radix-ui/react-slot';
4
+ import { cva, type VariantProps } from 'class-variance-authority';
5
+ import * as React from 'react';
6
+ import { cn } from '../utils';
7
+ import { Spinner } from './spinner';
8
+
9
+ const buttonVariants = cva(
10
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--khal-radius-button,10px)] text-copy-13 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--khal-accent-primary)] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer',
11
+ {
12
+ variants: {
13
+ variant: {
14
+ default: 'bg-gray-1000 text-white [color:white] dark:text-black dark:[color:black] hover:bg-gray-900',
15
+ secondary: 'bg-background-100 text-gray-1000 border border-gray-alpha-400 hover:bg-gray-alpha-100',
16
+ tertiary: 'bg-transparent text-gray-1000 hover:bg-gray-alpha-200',
17
+ error: 'bg-red-700 text-white [color:white] hover:bg-red-600',
18
+ warning: 'bg-amber-700 text-white [color:white] hover:bg-amber-600',
19
+ ghost: 'hover:bg-gray-alpha-200 text-gray-1000',
20
+ link: 'text-blue-700 underline-offset-4 hover:underline',
21
+ },
22
+ size: {
23
+ small: 'h-8 px-3 text-copy-13',
24
+ medium: 'h-9 px-4 text-copy-13',
25
+ large: 'h-10 px-5 text-copy-14',
26
+ icon: 'h-9 w-9',
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: 'default',
31
+ size: 'medium',
32
+ },
33
+ }
34
+ );
35
+
36
+ type ButtonVariantProps = VariantProps<typeof buttonVariants>;
37
+
38
+ interface ButtonProps
39
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'prefix'>,
40
+ ButtonVariantProps {
41
+ asChild?: boolean;
42
+ loading?: boolean;
43
+ prefix?: React.ReactNode;
44
+ suffix?: React.ReactNode;
45
+ /** HTML button type attribute (named typeName for compat with geistcn API) */
46
+ typeName?: 'submit' | 'button' | 'reset';
47
+ /** Visual type — maps to variant for compat */
48
+ type?: 'shadow' | 'invert' | 'unstyled';
49
+ }
50
+
51
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
52
+ (
53
+ {
54
+ className,
55
+ variant,
56
+ size,
57
+ asChild = false,
58
+ loading = false,
59
+ prefix,
60
+ suffix,
61
+ typeName,
62
+ type,
63
+ disabled,
64
+ children,
65
+ ...props
66
+ },
67
+ ref
68
+ ) => {
69
+ // Map geistcn "type" prop to variant if variant not explicitly set
70
+ let resolvedVariant = variant;
71
+ if (!resolvedVariant && type) {
72
+ if (type === 'invert') resolvedVariant = 'default';
73
+ else if (type === 'shadow') resolvedVariant = 'secondary';
74
+ else if (type === 'unstyled') resolvedVariant = 'ghost';
75
+ }
76
+
77
+ const Comp = asChild ? Slot : 'button';
78
+ return (
79
+ <Comp
80
+ className={cn(buttonVariants({ variant: resolvedVariant, size, className }))}
81
+ ref={ref}
82
+ type={typeName ?? 'button'}
83
+ disabled={disabled || loading}
84
+ {...props}
85
+ >
86
+ {loading ? (
87
+ <Spinner size="sm" />
88
+ ) : (
89
+ <>
90
+ {prefix && <span className="inline-flex shrink-0">{prefix}</span>}
91
+ {children}
92
+ {suffix && <span className="inline-flex shrink-0">{suffix}</span>}
93
+ </>
94
+ )}
95
+ </Comp>
96
+ );
97
+ }
98
+ );
99
+ Button.displayName = 'Button';
100
+
101
+ export type { ButtonProps };
102
+ export { Button, buttonVariants };
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+
3
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
4
+ import { Command as CommandPrimitive } from 'cmdk';
5
+ import { Search } from 'lucide-react';
6
+ import * as React from 'react';
7
+ import { cn } from '../utils';
8
+
9
+ const Command = React.forwardRef<
10
+ React.ComponentRef<typeof CommandPrimitive>,
11
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
12
+ >(({ className, ...props }, ref) => (
13
+ <CommandPrimitive
14
+ ref={ref}
15
+ className={cn('flex h-full w-full flex-col overflow-hidden rounded-xl bg-background-100 text-gray-1000', className)}
16
+ {...props}
17
+ />
18
+ ));
19
+ Command.displayName = CommandPrimitive.displayName;
20
+
21
+ function CommandDialog({
22
+ children,
23
+ open,
24
+ onOpenChange,
25
+ ...props
26
+ }: React.ComponentProps<typeof DialogPrimitive.Root> & React.ComponentPropsWithoutRef<typeof CommandPrimitive>) {
27
+ return (
28
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
29
+ <DialogPrimitive.Portal>
30
+ <DialogPrimitive.Overlay
31
+ className="fixed inset-0 z-[9999] bg-black/40 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0"
32
+ cmdk-overlay=""
33
+ />
34
+ <DialogPrimitive.Content
35
+ className="fixed left-[50%] top-[20%] z-[9999] w-full max-w-lg translate-x-[-50%] overflow-hidden rounded-xl shadow-lg"
36
+ style={{
37
+ background: 'var(--khal-menu-bg)',
38
+ border: '1px solid var(--khal-menu-border)',
39
+ boxShadow: 'var(--khal-menu-shadow)',
40
+ backdropFilter: 'blur(24px)',
41
+ WebkitBackdropFilter: 'blur(24px)',
42
+ }}
43
+ cmdk-dialog=""
44
+ aria-describedby={undefined}
45
+ >
46
+ <DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
47
+ <Command
48
+ className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-gray-700 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3"
49
+ {...props}
50
+ >
51
+ {children}
52
+ </Command>
53
+ </DialogPrimitive.Content>
54
+ </DialogPrimitive.Portal>
55
+ </DialogPrimitive.Root>
56
+ );
57
+ }
58
+
59
+ const CommandInput = React.forwardRef<
60
+ React.ComponentRef<typeof CommandPrimitive.Input>,
61
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
62
+ >(({ className, ...props }, ref) => (
63
+ <div
64
+ className="flex items-center px-3"
65
+ style={{ borderBottom: '1px solid var(--khal-border-default)' }}
66
+ cmdk-input-wrapper=""
67
+ >
68
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
69
+ <CommandPrimitive.Input
70
+ ref={ref}
71
+ className={cn(
72
+ 'flex h-10 w-full rounded-md bg-transparent py-3 text-copy-13 outline-none',
73
+ 'placeholder:text-gray-700',
74
+ 'disabled:cursor-not-allowed disabled:opacity-50',
75
+ className
76
+ )}
77
+ {...props}
78
+ />
79
+ </div>
80
+ ));
81
+ CommandInput.displayName = CommandPrimitive.Input.displayName;
82
+
83
+ const CommandList = React.forwardRef<
84
+ React.ComponentRef<typeof CommandPrimitive.List>,
85
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
86
+ >(({ className, ...props }, ref) => (
87
+ <CommandPrimitive.List
88
+ ref={ref}
89
+ className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
90
+ {...props}
91
+ />
92
+ ));
93
+ CommandList.displayName = CommandPrimitive.List.displayName;
94
+
95
+ const CommandEmpty = React.forwardRef<
96
+ React.ComponentRef<typeof CommandPrimitive.Empty>,
97
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
98
+ >((props, ref) => (
99
+ <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-copy-13 text-gray-700" {...props} />
100
+ ));
101
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
102
+
103
+ const CommandGroup = React.forwardRef<
104
+ React.ComponentRef<typeof CommandPrimitive.Group>,
105
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
106
+ >(({ className, ...props }, ref) => (
107
+ <CommandPrimitive.Group
108
+ ref={ref}
109
+ className={cn(
110
+ 'overflow-hidden p-1 text-gray-1000',
111
+ '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-label-12 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-gray-700',
112
+ className
113
+ )}
114
+ {...props}
115
+ />
116
+ ));
117
+ CommandGroup.displayName = CommandPrimitive.Group.displayName;
118
+
119
+ const CommandSeparator = React.forwardRef<
120
+ React.ComponentRef<typeof CommandPrimitive.Separator>,
121
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
122
+ >(({ className, ...props }, ref) => (
123
+ <CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-gray-alpha-400', className)} {...props} />
124
+ ));
125
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
126
+
127
+ const CommandItem = React.forwardRef<
128
+ React.ComponentRef<typeof CommandPrimitive.Item>,
129
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {
130
+ prefix?: React.ReactNode;
131
+ callback?: () => void;
132
+ }
133
+ >(({ className, prefix, callback, onSelect, children, ...props }, ref) => (
134
+ <CommandPrimitive.Item
135
+ ref={ref}
136
+ onSelect={onSelect ?? callback}
137
+ className={cn(
138
+ 'relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-copy-13 outline-none select-none',
139
+ 'data-[selected=true]:text-gray-1000',
140
+ 'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
141
+ className
142
+ )}
143
+ {...props}
144
+ >
145
+ {prefix && <span className="inline-flex shrink-0">{prefix}</span>}
146
+ {children}
147
+ </CommandPrimitive.Item>
148
+ ));
149
+ CommandItem.displayName = CommandPrimitive.Item.displayName;
150
+
151
+ const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
152
+ return <span className={cn('ml-auto text-label-12 tracking-widest text-gray-700', className)} {...props} />;
153
+ };
154
+
155
+ export {
156
+ Command,
157
+ CommandDialog,
158
+ CommandEmpty,
159
+ CommandGroup,
160
+ CommandInput,
161
+ CommandItem,
162
+ CommandList,
163
+ CommandSeparator,
164
+ CommandShortcut,
165
+ };
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { motion, useInView } from 'motion/react';
4
+ import { useCallback, useRef, useState } from 'react';
5
+ import { cn } from '../utils';
6
+ import { NumberFlow } from './number-flow';
7
+
8
+ interface CostCounterProps {
9
+ /** The target numeric value to animate to */
10
+ value: number;
11
+ /** Text suffix after the number (e.g. "%", "pts", "k") */
12
+ suffix?: string;
13
+ /** Text prefix before the number (e.g. "$", "+") */
14
+ prefix?: string;
15
+ /** Primary label below the number */
16
+ label: string;
17
+ /** Secondary description text */
18
+ description?: string;
19
+ /** Budget bar target percentage (0-100). Omit to hide the bar. */
20
+ budget?: number;
21
+ /** Budget bar color — defaults to accent-warm */
22
+ budgetColor?: string;
23
+ className?: string;
24
+ }
25
+
26
+ function CostCounter({ value, suffix, prefix, label, description, budget, budgetColor, className }: CostCounterProps) {
27
+ const [displayed, setDisplayed] = useState(0);
28
+ const triggered = useRef(false);
29
+ const barRef = useRef<HTMLDivElement>(null);
30
+ const barInView = useInView(barRef, { once: true, amount: 0.6 });
31
+
32
+ const handleViewport = useCallback(() => {
33
+ if (!triggered.current) {
34
+ triggered.current = true;
35
+ setDisplayed(value);
36
+ }
37
+ }, [value]);
38
+
39
+ return (
40
+ <motion.div
41
+ onViewportEnter={handleViewport}
42
+ viewport={{ once: true, amount: 0.5 }}
43
+ className={cn('flex flex-col gap-3', className)}
44
+ >
45
+ <div
46
+ className="flex items-baseline tabular-nums font-semibold tracking-tight leading-none"
47
+ style={{ fontFamily: 'var(--font-display, var(--font-geist-sans))' }}
48
+ >
49
+ {prefix && <span className="text-[0.7em]">{prefix}</span>}
50
+ <NumberFlow value={displayed} />
51
+ {suffix && <span className="text-[0.65em] ml-0.5 opacity-70">{suffix}</span>}
52
+ </div>
53
+
54
+ {budget != null && (
55
+ <div ref={barRef} className="w-full h-2 rounded-full overflow-hidden bg-[var(--ds-gray-alpha-200)]">
56
+ <motion.div
57
+ initial={{ width: 0 }}
58
+ animate={barInView ? { width: `${Math.min(budget, 100)}%` } : { width: 0 }}
59
+ transition={{ duration: 1, ease: [0.22, 1, 0.36, 1], delay: 0.15 }}
60
+ className="h-full rounded-full"
61
+ style={{ backgroundColor: budgetColor ?? 'var(--ds-accent-warm)' }}
62
+ />
63
+ </div>
64
+ )}
65
+
66
+ <div className="flex flex-col gap-0.5">
67
+ <span className="text-sm font-medium leading-5">{label}</span>
68
+ {description && <span className="text-xs opacity-50 leading-4">{description}</span>}
69
+ </div>
70
+ </motion.div>
71
+ );
72
+ }
73
+
74
+ export type { CostCounterProps };
75
+ export { CostCounter };