@aiready/components 0.1.21 → 0.1.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiready/components",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Unified shared components library (UI, charts, hooks, utilities) for AIReady",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,147 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useCallback, useMemo } from 'react';
4
+
5
+ export interface CodeBlockProps {
6
+ children: React.ReactNode;
7
+ language?: string;
8
+ showCopy?: boolean;
9
+ showHeader?: boolean;
10
+ className?: string;
11
+ }
12
+
13
+ // Dedent helper - removes common leading indentation
14
+ function dedentCode(code: string): string {
15
+ // Normalize tabs to two spaces
16
+ let normalized = code.replace(/\t/g, ' ').replace(/[ \t]+$/gm, '');
17
+
18
+ const lines = normalized.split('\n');
19
+ if (lines.length <= 1) return normalized.trim();
20
+
21
+ // Remove leading/trailing empty lines
22
+ let start = 0;
23
+ while (start < lines.length && lines[start].trim() === '') start++;
24
+ let end = lines.length - 1;
25
+ while (end >= 0 && lines[end].trim() === '') end--;
26
+
27
+ if (start > end) return '';
28
+ const relevantLines = lines.slice(start, end + 1);
29
+
30
+ // Find minimum indent across non-empty lines
31
+ const nonEmpty = relevantLines.filter((l) => l.trim() !== '');
32
+ const minIndent = nonEmpty.reduce((min, line) => {
33
+ const m = line.match(/^\s*/)?.[0].length ?? 0;
34
+ return Math.min(min, m);
35
+ }, Infinity);
36
+
37
+ // Remove common indentation
38
+ const dedented = minIndent === Infinity || minIndent === 0
39
+ ? relevantLines.join('\n')
40
+ : relevantLines.map((l) => (l.startsWith(' '.repeat(minIndent)) ? l.slice(minIndent) : l)).join('\n');
41
+
42
+ return dedented;
43
+ }
44
+
45
+ // Simple Copy Button
46
+ function CopyButton({ code }: { code: string }) {
47
+ const [copied, setCopied] = useState(false);
48
+
49
+ const handleCopy = useCallback(async () => {
50
+ try {
51
+ await navigator.clipboard.writeText(code);
52
+ setCopied(true);
53
+ setTimeout(() => setCopied(false), 2000);
54
+ } catch {
55
+ // Fallback for older browsers
56
+ const textarea = document.createElement('textarea');
57
+ textarea.value = code;
58
+ textarea.style.position = 'fixed';
59
+ textarea.style.opacity = '0';
60
+ document.body.appendChild(textarea);
61
+ textarea.select();
62
+ document.execCommand('copy');
63
+ document.body.removeChild(textarea);
64
+ setCopied(true);
65
+ setTimeout(() => setCopied(false), 2000);
66
+ }
67
+ }, [code]);
68
+
69
+ return (
70
+ <button
71
+ onClick={handleCopy}
72
+ className="rounded-md p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
73
+ title={copied ? 'Copied!' : 'Copy code'}
74
+ >
75
+ {copied ? (
76
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
77
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
78
+ </svg>
79
+ ) : (
80
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
81
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
82
+ </svg>
83
+ )}
84
+ </button>
85
+ );
86
+ }
87
+
88
+ export function CodeBlock({
89
+ children,
90
+ language = 'typescript',
91
+ showCopy = true,
92
+ showHeader = true,
93
+ className = '',
94
+ }: CodeBlockProps) {
95
+ // Get code string from children
96
+ const codeString = useMemo(() => {
97
+ if (typeof children === 'string') {
98
+ return dedentCode(children);
99
+ }
100
+ // Handle template literal children
101
+ try {
102
+ const raw = React.Children.toArray(children)
103
+ .map((c) => (typeof c === 'string' ? c : typeof c === 'number' ? String(c) : ''))
104
+ .join('');
105
+ return dedentCode(raw);
106
+ } catch {
107
+ return '';
108
+ }
109
+ }, [children]);
110
+
111
+ return (
112
+ <div className={`group relative my-4 overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-lg ${className}`}>
113
+ {/* Header bar */}
114
+ {showHeader && (
115
+ <div className="flex items-center justify-between border-b border-slate-700 bg-slate-800/50 px-4 py-2">
116
+ <div className="flex items-center gap-2">
117
+ <div className="flex gap-1.5">
118
+ <div className="h-3 w-3 rounded-full bg-red-500/50" />
119
+ <div className="h-3 w-3 rounded-full bg-amber-500/50" />
120
+ <div className="h-3 w-3 rounded-full bg-emerald-500/50" />
121
+ </div>
122
+ <span className="ml-2 text-xs font-semibold uppercase tracking-wider text-slate-500 font-mono">
123
+ {language}
124
+ </span>
125
+ </div>
126
+ {showCopy && <CopyButton code={codeString} />}
127
+ </div>
128
+ )}
129
+
130
+ {/* Code body */}
131
+ <pre className="overflow-x-auto p-4 text-sm leading-relaxed">
132
+ <code className="font-mono block whitespace-pre text-slate-300">
133
+ {codeString}
134
+ </code>
135
+ </pre>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ // Inline code component
141
+ export function InlineCode({ children, className = '' }: { children: React.ReactNode; className?: string }) {
142
+ return (
143
+ <code className={`rounded-md bg-slate-100 px-1.5 py-0.5 text-sm font-mono text-slate-800 ${className}`}>
144
+ {children}
145
+ </code>
146
+ );
147
+ }
@@ -0,0 +1 @@
1
+ export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock';
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import { cn } from '../utils/cn';
4
+
5
+ export type ScoreRating = 'excellent' | 'good' | 'fair' | 'needs-work' | 'critical';
6
+
7
+ export interface ScoreBarProps {
8
+ score: number;
9
+ maxScore?: number;
10
+ label: string;
11
+ showScore?: boolean;
12
+ size?: 'sm' | 'md' | 'lg';
13
+ className?: string;
14
+ }
15
+
16
+ const ratingConfig: Record<ScoreRating, { color: string; bgColor: string; label: string }> = {
17
+ excellent: { color: 'bg-green-500', bgColor: 'bg-green-100', label: 'Excellent' },
18
+ good: { color: 'bg-emerald-500', bgColor: 'bg-emerald-100', label: 'Good' },
19
+ fair: { color: 'bg-amber-500', bgColor: 'bg-amber-100', label: 'Fair' },
20
+ 'needs-work': { color: 'bg-orange-500', bgColor: 'bg-orange-100', label: 'Needs Work' },
21
+ critical: { color: 'bg-red-500', bgColor: 'bg-red-100', label: 'Critical' },
22
+ };
23
+
24
+ function getRating(score: number): ScoreRating {
25
+ if (score >= 90) return 'excellent';
26
+ if (score >= 75) return 'good';
27
+ if (score >= 60) return 'fair';
28
+ if (score >= 40) return 'needs-work';
29
+ return 'critical';
30
+ }
31
+
32
+ const sizeConfig = {
33
+ sm: { height: 'h-1.5', text: 'text-xs', score: 'text-sm' },
34
+ md: { height: 'h-2', text: 'text-sm', score: 'text-base' },
35
+ lg: { height: 'h-3', text: 'text-base', score: 'text-lg' },
36
+ };
37
+
38
+ export function ScoreBar({
39
+ score,
40
+ maxScore = 100,
41
+ label,
42
+ showScore = true,
43
+ size = 'md',
44
+ className,
45
+ }: ScoreBarProps) {
46
+ const percentage = Math.min(100, Math.max(0, (score / maxScore) * 100));
47
+ const rating = getRating(percentage);
48
+ const config = ratingConfig[rating];
49
+ const sizes = sizeConfig[size];
50
+
51
+ return (
52
+ <div className={cn('space-y-1', className)}>
53
+ <div className="flex items-center justify-between">
54
+ <span className={cn('text-slate-700', sizes.text)}>{label}</span>
55
+ {showScore && (
56
+ <span className={cn('font-bold text-slate-900', sizes.score)}>
57
+ {score}/{maxScore}
58
+ </span>
59
+ )}
60
+ </div>
61
+ <div className={cn('w-full rounded-full bg-slate-200', sizes.height)}>
62
+ <div
63
+ className={cn('rounded-full transition-all duration-500', config.color, sizes.height)}
64
+ style={{ width: `${percentage}%` }}
65
+ />
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export interface ScoreCardProps {
72
+ score: number;
73
+ title?: string;
74
+ breakdown?: Array<{
75
+ label: string;
76
+ score: number;
77
+ weight?: number;
78
+ }>;
79
+ className?: string;
80
+ }
81
+
82
+ export function ScoreCard({ score, title, breakdown, className }: ScoreCardProps) {
83
+ const rating = getRating(score);
84
+ const config = ratingConfig[rating];
85
+
86
+ return (
87
+ <div className={cn('rounded-xl border-2 border-slate-200 bg-white p-6 shadow-lg', className)}>
88
+ <div className="mb-4">
89
+ <div className="text-4xl font-black text-slate-900">{score}/100</div>
90
+ <div className={cn('text-lg font-bold', `text-${rating === 'excellent' ? 'green' : rating === 'good' ? 'emerald' : rating === 'fair' ? 'amber' : rating === 'needs-work' ? 'orange' : 'red'}-600`)}>
91
+ {config.label} Rating
92
+ </div>
93
+ {title && <p className="text-sm text-slate-600 mt-1">{title}</p>}
94
+ </div>
95
+
96
+ {breakdown && breakdown.length > 0 && (
97
+ <div className="space-y-3">
98
+ {breakdown.map((item, index) => (
99
+ <ScoreBar
100
+ key={index}
101
+ score={item.score}
102
+ label={item.label}
103
+ size="sm"
104
+ />
105
+ ))}
106
+ </div>
107
+ )}
108
+
109
+ {breakdown && breakdown.length > 0 && (
110
+ <div className="mt-4 text-xs text-slate-600 bg-slate-50 p-3 rounded-lg">
111
+ <strong>Formula:</strong> {breakdown.map(item =>
112
+ `${item.score}×${item.weight || 1}`
113
+ ).join(' + ')} / 100 = {score}
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1 @@
1
+ export { ScoreBar, ScoreCard, type ScoreBarProps, type ScoreCardProps, type ScoreRating } from './ScoreBar';
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ export interface ErrorDisplayProps {
6
+ title?: string;
7
+ message: string;
8
+ retry?: () => void;
9
+ retryLabel?: string;
10
+ }
11
+
12
+ export function ErrorDisplay({
13
+ title = 'Something went wrong',
14
+ message,
15
+ retry,
16
+ retryLabel = 'Try again',
17
+ }: ErrorDisplayProps) {
18
+ return (
19
+ <div className="flex flex-col items-center justify-center min-h-[200px] gap-4 p-8">
20
+ <div className="rounded-full bg-red-100 p-3">
21
+ <svg
22
+ className="h-6 w-6 text-red-600"
23
+ fill="none"
24
+ viewBox="0 0 24 24"
25
+ strokeWidth="1.5"
26
+ stroke="currentColor"
27
+ >
28
+ <path
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
32
+ />
33
+ </svg>
34
+ </div>
35
+ <div className="text-center">
36
+ <h3 className="text-lg font-semibold text-slate-900">{title}</h3>
37
+ <p className="mt-2 text-sm text-slate-500">{message}</p>
38
+ </div>
39
+ {retry && (
40
+ <button
41
+ onClick={retry}
42
+ className="mt-2 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
43
+ >
44
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
45
+ <path
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
49
+ />
50
+ </svg>
51
+ {retryLabel}
52
+ </button>
53
+ )}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export interface EmptyStateProps {
59
+ title: string;
60
+ description?: string;
61
+ icon?: React.ReactNode;
62
+ action?: {
63
+ label: string;
64
+ onClick: () => void;
65
+ };
66
+ }
67
+
68
+ export function EmptyState({ title, description, icon, action }: EmptyStateProps) {
69
+ return (
70
+ <div className="flex flex-col items-center justify-center min-h-[200px] gap-4 p-8">
71
+ {icon && <div className="rounded-full bg-slate-100 p-3">{icon}</div>}
72
+ <div className="text-center">
73
+ <h3 className="text-lg font-semibold text-slate-900">{title}</h3>
74
+ {description && <p className="mt-2 text-sm text-slate-500">{description}</p>}
75
+ </div>
76
+ {action && (
77
+ <button
78
+ onClick={action.onClick}
79
+ className="mt-2 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
80
+ >
81
+ {action.label}
82
+ </button>
83
+ )}
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ export interface LoadingSpinnerProps {
4
+ size?: 'sm' | 'md' | 'lg';
5
+ className?: string;
6
+ }
7
+
8
+ const sizeClasses = {
9
+ sm: 'h-4 w-4',
10
+ md: 'h-8 w-8',
11
+ lg: 'h-12 w-12',
12
+ };
13
+
14
+ export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
15
+ return (
16
+ <div className={`flex items-center justify-center ${className}`}>
17
+ <div
18
+ className={`${sizeClasses[size]} animate-spin rounded-full border-2 border-slate-300 border-t-blue-600`}
19
+ />
20
+ </div>
21
+ );
22
+ }
23
+
24
+ export interface LoadingOverlayProps {
25
+ message?: string;
26
+ }
27
+
28
+ export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) {
29
+ return (
30
+ <div className="flex flex-col items-center justify-center min-h-[200px] gap-4">
31
+ <LoadingSpinner size="lg" />
32
+ <p className="text-sm text-slate-500 animate-pulse">{message}</p>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,2 @@
1
+ export { LoadingSpinner, LoadingOverlay, type LoadingSpinnerProps, type LoadingOverlayProps } from './LoadingSpinner';
2
+ export { ErrorDisplay, EmptyState, type ErrorDisplayProps, type EmptyStateProps } from './ErrorDisplay';
package/src/index.ts CHANGED
@@ -25,6 +25,30 @@ export { Switch, type SwitchProps } from './components/switch';
25
25
  export { Textarea, type TextareaProps } from './components/textarea';
26
26
  export { Select, type SelectProps, type SelectOption } from './components/select';
27
27
 
28
+ // Code Block
29
+ export { CodeBlock, InlineCode, type CodeBlockProps } from './code-block';
30
+
31
+ // Navigation
32
+ export { Breadcrumb, type BreadcrumbProps, type BreadcrumbItem } from './navigation';
33
+
34
+ // Data Display
35
+ export { ScoreBar, ScoreCard, type ScoreBarProps, type ScoreCardProps, type ScoreRating } from './data-display';
36
+
37
+ // Feedback
38
+ export {
39
+ LoadingSpinner,
40
+ LoadingOverlay,
41
+ ErrorDisplay,
42
+ EmptyState,
43
+ type LoadingSpinnerProps,
44
+ type LoadingOverlayProps,
45
+ type ErrorDisplayProps,
46
+ type EmptyStateProps,
47
+ } from './feedback';
48
+
49
+ // Theme
50
+ export { ThemeProvider, useTheme, type Theme, type EffectiveTheme } from './theme';
51
+
28
52
  // Utils
29
53
  export { cn } from './utils/cn';
30
54
  export * from './utils/colors';
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { cn } from '../utils/cn';
5
+
6
+ export interface BreadcrumbItem {
7
+ label: string;
8
+ href?: string;
9
+ }
10
+
11
+ export interface BreadcrumbProps {
12
+ items: BreadcrumbItem[];
13
+ separator?: React.ReactNode;
14
+ className?: string;
15
+ }
16
+
17
+ const DefaultSeparator = () => (
18
+ <svg
19
+ className="h-4 w-4 text-slate-400"
20
+ fill="none"
21
+ viewBox="0 0 24 24"
22
+ strokeWidth="1.5"
23
+ stroke="currentColor"
24
+ >
25
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
26
+ </svg>
27
+ );
28
+
29
+ export function Breadcrumb({ items, separator, className }: BreadcrumbProps) {
30
+ const Separator = separator || <DefaultSeparator />;
31
+
32
+ return (
33
+ <nav className={cn('flex items-center gap-1 text-sm', className)} aria-label="Breadcrumb">
34
+ <ol className="flex items-center gap-1">
35
+ {items.map((item, index) => {
36
+ const isLast = index === items.length - 1;
37
+
38
+ return (
39
+ <li key={index} className="flex items-center gap-1">
40
+ {index > 0 && (
41
+ <span className="flex-shrink-0">{Separator}</span>
42
+ )}
43
+ {item.href && !isLast ? (
44
+ <a
45
+ href={item.href}
46
+ className="text-slate-600 hover:text-slate-900 transition-colors"
47
+ >
48
+ {item.label}
49
+ </a>
50
+ ) : (
51
+ <span className={isLast ? 'font-medium text-slate-900' : 'text-slate-600'}>
52
+ {item.label}
53
+ </span>
54
+ )}
55
+ </li>
56
+ );
57
+ })}
58
+ </ol>
59
+ </nav>
60
+ );
61
+ }
@@ -0,0 +1 @@
1
+ export { Breadcrumb, type BreadcrumbProps, type BreadcrumbItem } from './Breadcrumb';
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useEffect, useState } from 'react';
4
+
5
+ export type Theme = 'dark' | 'light' | 'system';
6
+ export type EffectiveTheme = 'dark' | 'light';
7
+
8
+ interface ThemeContextValue {
9
+ theme: Theme;
10
+ setTheme: (theme: Theme) => void;
11
+ effectiveTheme: EffectiveTheme;
12
+ }
13
+
14
+ const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
15
+
16
+ const STORAGE_KEY = 'aiready-theme';
17
+
18
+ function getSystemTheme(): EffectiveTheme {
19
+ if (typeof window === 'undefined') return 'light';
20
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
21
+ }
22
+
23
+ function getStoredTheme(): Theme {
24
+ if (typeof window === 'undefined') return 'system';
25
+ try {
26
+ const stored = localStorage.getItem(STORAGE_KEY);
27
+ if (stored === 'dark' || stored === 'light' || stored === 'system') {
28
+ return stored;
29
+ }
30
+ } catch {
31
+ // localStorage not available
32
+ }
33
+ return 'system';
34
+ }
35
+
36
+ interface ThemeProviderProps {
37
+ children: React.ReactNode;
38
+ defaultTheme?: Theme;
39
+ storageKey?: string;
40
+ }
41
+
42
+ export function ThemeProvider({
43
+ children,
44
+ defaultTheme = 'system',
45
+ storageKey = STORAGE_KEY,
46
+ }: ThemeProviderProps) {
47
+ const [theme, setThemeState] = useState<Theme>(defaultTheme);
48
+ const [effectiveTheme, setEffectiveTheme] = useState<EffectiveTheme>('light');
49
+ const [mounted, setMounted] = useState(false);
50
+
51
+ // Initialize theme from storage on mount
52
+ useEffect(() => {
53
+ const storedTheme = getStoredTheme();
54
+ setThemeState(storedTheme);
55
+ setMounted(true);
56
+ }, []);
57
+
58
+ // Update effective theme when theme or system preference changes
59
+ useEffect(() => {
60
+ if (!mounted) return;
61
+
62
+ const updateEffectiveTheme = () => {
63
+ if (theme === 'system') {
64
+ setEffectiveTheme(getSystemTheme());
65
+ } else {
66
+ setEffectiveTheme(theme);
67
+ }
68
+ };
69
+
70
+ updateEffectiveTheme();
71
+
72
+ // Listen for system theme changes
73
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
74
+ const handleChange = () => {
75
+ if (theme === 'system') {
76
+ setEffectiveTheme(getSystemTheme());
77
+ }
78
+ };
79
+
80
+ mediaQuery.addEventListener('change', handleChange);
81
+ return () => mediaQuery.removeEventListener('change', handleChange);
82
+ }, [theme, mounted]);
83
+
84
+ // Apply theme class to document
85
+ useEffect(() => {
86
+ if (!mounted) return;
87
+
88
+ const root = document.documentElement;
89
+ root.classList.remove('light', 'dark');
90
+ root.classList.add(effectiveTheme);
91
+ }, [effectiveTheme, mounted]);
92
+
93
+ const setTheme = (newTheme: Theme) => {
94
+ setThemeState(newTheme);
95
+ try {
96
+ localStorage.setItem(storageKey, newTheme);
97
+ } catch {
98
+ // localStorage not available
99
+ }
100
+ };
101
+
102
+ // Prevent hydration mismatch
103
+ if (!mounted) {
104
+ return (
105
+ <ThemeContext.Provider value={{ theme: defaultTheme, setTheme: () => {}, effectiveTheme: 'light' }}>
106
+ {children}
107
+ </ThemeContext.Provider>
108
+ );
109
+ }
110
+
111
+ return (
112
+ <ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
113
+ {children}
114
+ </ThemeContext.Provider>
115
+ );
116
+ }
117
+
118
+ export function useTheme(): ThemeContextValue {
119
+ const context = useContext(ThemeContext);
120
+ if (context === undefined) {
121
+ throw new Error('useTheme must be used within a ThemeProvider');
122
+ }
123
+ return context;
124
+ }
@@ -0,0 +1 @@
1
+ export { ThemeProvider, useTheme, type Theme, type EffectiveTheme } from './ThemeProvider';