@shohojdhara/atomix 0.5.2 → 0.5.5
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/atomix.config.ts +33 -33
- package/dist/atomix.css +3213 -159
- package/dist/atomix.css.map +1 -1
- package/dist/atomix.min.css +5 -5
- package/dist/atomix.min.css.map +1 -1
- package/dist/config.d.ts +187 -112
- package/dist/config.js +2 -47
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1958 -900
- package/dist/index.esm.js +2279 -382
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2332 -413
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +1 -1
- package/dist/theme.d.ts +1390 -276
- package/dist/theme.js +2125 -621
- package/dist/theme.js.map +1 -1
- package/package.json +1 -1
- package/scripts/cli/internal/config-loader.js +30 -20
- package/src/lib/config/index.ts +38 -362
- package/src/lib/config/loader.ts +422 -0
- package/src/lib/config/public-api.ts +43 -0
- package/src/lib/config/types.ts +389 -0
- package/src/lib/config/validator.ts +305 -0
- package/src/lib/theme/adapters/index.ts +1 -1
- package/src/lib/theme/adapters/themeAdapter.ts +358 -229
- package/src/lib/theme/components/ThemeToggle.tsx +276 -0
- package/src/lib/theme/config/configLoader.ts +351 -0
- package/src/lib/theme/config/loader.ts +221 -0
- package/src/lib/theme/core/createTheme.ts +126 -50
- package/src/lib/theme/core/createThemeObject.ts +7 -4
- package/src/lib/theme/hooks/useThemeSwitcher.ts +164 -0
- package/src/lib/theme/index.ts +322 -38
- package/src/lib/theme/runtime/ThemeProvider.tsx +44 -10
- package/src/lib/theme/runtime/__tests__/ThemeProvider.test.tsx +44 -393
- package/src/lib/theme/runtime/useTheme.ts +1 -0
- package/src/lib/theme/tokens/tokens.ts +101 -1
- package/src/lib/theme/types.ts +91 -0
- package/src/lib/theme/utils/performanceMonitor.ts +315 -0
- package/src/lib/theme/utils/responsive.ts +280 -0
- package/src/lib/theme/utils/themeUtils.ts +531 -117
- package/src/styles/01-settings/_settings.background.scss +34 -5
- package/src/styles/02-tools/_tools.background.scss +330 -52
- package/src/styles/05-objects/_objects.masonry-grid.scss +3 -3
- package/src/styles/06-components/_components.accordion.scss +2 -2
- package/src/styles/06-components/_components.badge.scss +1 -1
- package/src/styles/06-components/_components.button.scss +2 -2
- package/src/styles/06-components/_components.callout.scss +2 -2
- package/src/styles/06-components/_components.card.scss +1 -1
- package/src/styles/06-components/_components.dropdown.scss +1 -1
- package/src/styles/06-components/_components.dynamic-background.scss +69 -0
- package/src/styles/06-components/_components.edge-panel.scss +2 -2
- package/src/styles/06-components/_components.input.scss +3 -3
- package/src/styles/06-components/_components.messages.scss +6 -6
- package/src/styles/06-components/_components.modal.scss +1 -1
- package/src/styles/06-components/_components.navbar.scss +1 -1
- package/src/styles/06-components/_components.popover.scss +1 -1
- package/src/styles/06-components/_components.toggle.scss +1 -1
- package/src/styles/06-components/_components.tooltip.scss +3 -3
- package/src/styles/06-components/_index.scss +1 -0
|
@@ -1,185 +1,599 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Theme Utilities
|
|
3
|
-
*
|
|
4
|
-
* Helper
|
|
5
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for common theme operations including:
|
|
5
|
+
* - Theme switching (dark/light mode)
|
|
6
|
+
* - Theme persistence (localStorage)
|
|
7
|
+
* - System preference detection
|
|
8
|
+
* - Smooth transitions
|
|
9
|
+
* - Color manipulation
|
|
6
10
|
*/
|
|
7
11
|
|
|
12
|
+
import type { DesignTokens } from '../tokens';
|
|
8
13
|
import type { SpacingFunction, SpacingOptions } from '../types';
|
|
14
|
+
import { injectCSS, removeCSS } from '../utils/injectCSS';
|
|
15
|
+
import { deepMerge } from '../core/composeTheme';
|
|
9
16
|
|
|
10
17
|
// ============================================================================
|
|
11
|
-
//
|
|
18
|
+
// Type Definitions
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
22
|
+
|
|
23
|
+
export interface ThemeSwitcherOptions {
|
|
24
|
+
/** CSS selector for root element (default: ':root') */
|
|
25
|
+
selector?: string;
|
|
26
|
+
/** Storage key for theme persistence (default: 'atomix-theme') */
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
/** Enable smooth transitions (default: true) */
|
|
29
|
+
enableTransition?: boolean;
|
|
30
|
+
/** Transition duration in ms (default: 300) */
|
|
31
|
+
transitionDuration?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ThemePersistenceOptions {
|
|
35
|
+
/** Storage key (default: 'atomix-theme') */
|
|
36
|
+
storageKey?: string;
|
|
37
|
+
/** Storage type (default: 'localStorage') */
|
|
38
|
+
storageType?: 'localStorage' | 'sessionStorage';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Theme Mode Switching
|
|
12
43
|
// ============================================================================
|
|
13
44
|
|
|
14
45
|
/**
|
|
15
|
-
*
|
|
46
|
+
* Switch between light and dark themes
|
|
47
|
+
*
|
|
48
|
+
* Automatically toggles a class on the root element and persists the choice.
|
|
49
|
+
*
|
|
50
|
+
* @param mode - Theme mode ('light', 'dark', or 'system')
|
|
51
|
+
* @param options - Configuration options
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* import { switchTheme } from '@shohojdhara/atomix/theme/utils';
|
|
56
|
+
*
|
|
57
|
+
* // Switch to dark mode
|
|
58
|
+
* switchTheme('dark');
|
|
59
|
+
*
|
|
60
|
+
* // Toggle between light/dark
|
|
61
|
+
* const current = getCurrentTheme();
|
|
62
|
+
* switchTheme(current === 'dark' ? 'light' : 'dark');
|
|
63
|
+
* ```
|
|
16
64
|
*/
|
|
17
|
-
export function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
65
|
+
export function switchTheme(
|
|
66
|
+
mode: ThemeMode,
|
|
67
|
+
options: ThemeSwitcherOptions = {}
|
|
68
|
+
): void {
|
|
69
|
+
const {
|
|
70
|
+
selector = ':root',
|
|
71
|
+
storageKey = 'atomix-theme',
|
|
72
|
+
enableTransition = true,
|
|
73
|
+
transitionDuration = 300,
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
// Determine actual mode (resolve 'system')
|
|
77
|
+
const resolvedMode = mode === 'system' ? getSystemTheme() : mode;
|
|
78
|
+
|
|
79
|
+
// Get root element
|
|
80
|
+
const root = selector === ':root' ? document.documentElement : document.querySelector(selector);
|
|
81
|
+
if (!root) return;
|
|
82
|
+
|
|
83
|
+
// Add transition class if enabled
|
|
84
|
+
if (enableTransition) {
|
|
85
|
+
const htmlRoot = root as HTMLElement;
|
|
86
|
+
htmlRoot.style.transition = `all ${transitionDuration}ms ease-in-out`;
|
|
87
|
+
|
|
88
|
+
// Remove transition after it completes
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
htmlRoot.style.transition = '';
|
|
91
|
+
}, transitionDuration);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Apply theme class
|
|
95
|
+
root.classList.remove('atomix-theme-light', 'atomix-theme-dark');
|
|
96
|
+
root.classList.add(`atomix-theme-${resolvedMode}`);
|
|
97
|
+
|
|
98
|
+
// Set data attribute for CSS selectors
|
|
99
|
+
root.setAttribute('data-theme', resolvedMode);
|
|
100
|
+
|
|
101
|
+
// Persist choice
|
|
102
|
+
persistTheme(resolvedMode, { storageKey });
|
|
103
|
+
|
|
104
|
+
// Dispatch custom event for listeners
|
|
105
|
+
window.dispatchEvent(new CustomEvent('atomix-theme-change', {
|
|
106
|
+
detail: { mode: resolvedMode }
|
|
107
|
+
}));
|
|
26
108
|
}
|
|
27
109
|
|
|
28
110
|
/**
|
|
29
|
-
*
|
|
111
|
+
* Toggle between light and dark themes
|
|
112
|
+
*
|
|
113
|
+
* @param options - Configuration options
|
|
114
|
+
* @returns The new theme mode
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* const newMode = toggleTheme();
|
|
119
|
+
* console.log('Switched to:', newMode);
|
|
120
|
+
* ```
|
|
30
121
|
*/
|
|
31
|
-
export function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
122
|
+
export function toggleTheme(options: ThemeSwitcherOptions = {}): ThemeMode {
|
|
123
|
+
const current = getCurrentTheme(options.storageKey);
|
|
124
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
125
|
+
switchTheme(next, options);
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get current theme mode
|
|
131
|
+
*
|
|
132
|
+
* @param storageKey - Storage key (default: 'atomix-theme')
|
|
133
|
+
* @returns Current theme mode or 'light' if not set
|
|
134
|
+
*/
|
|
135
|
+
export function getCurrentTheme(storageKey: string = 'atomix-theme'): ThemeMode {
|
|
136
|
+
if (typeof window === 'undefined') return 'light';
|
|
137
|
+
|
|
138
|
+
const stored = localStorage.getItem(storageKey);
|
|
139
|
+
return (stored as ThemeMode) || 'light';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get system theme preference
|
|
144
|
+
*
|
|
145
|
+
* @returns 'dark' if system prefers dark mode, 'light' otherwise
|
|
146
|
+
*/
|
|
147
|
+
export function getSystemTheme(): ThemeMode {
|
|
148
|
+
if (typeof window === 'undefined') return 'light';
|
|
149
|
+
|
|
150
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
151
|
+
return prefersDark ? 'dark' : 'light';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Initialize theme based on saved preference or system preference
|
|
156
|
+
*
|
|
157
|
+
* Call this once at app startup.
|
|
158
|
+
*
|
|
159
|
+
* @param options - Configuration options
|
|
160
|
+
* @returns The initialized theme mode
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* // In your app entry point
|
|
165
|
+
* import { initializeTheme } from '@shohojdhara/atomix/theme/utils';
|
|
166
|
+
*
|
|
167
|
+
* const theme = initializeTheme();
|
|
168
|
+
* console.log('Theme initialized:', theme);
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function initializeTheme(options: ThemeSwitcherOptions = {}): ThemeMode {
|
|
172
|
+
const saved = getCurrentTheme(options.storageKey);
|
|
173
|
+
|
|
174
|
+
// If no saved preference, use system preference
|
|
175
|
+
if (!saved || saved === 'system') {
|
|
176
|
+
const system = getSystemTheme();
|
|
177
|
+
switchTheme(system, options);
|
|
178
|
+
return system;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Use saved preference
|
|
182
|
+
switchTheme(saved, options);
|
|
183
|
+
return saved;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Listen for system theme changes
|
|
188
|
+
*
|
|
189
|
+
* @param callback - Function to call when system theme changes
|
|
190
|
+
* @returns Cleanup function to stop listening
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* const cleanup = listenToSystemTheme((mode) => {
|
|
195
|
+
* console.log('System theme changed to:', mode);
|
|
196
|
+
* switchTheme(mode);
|
|
197
|
+
* });
|
|
198
|
+
*
|
|
199
|
+
* // Later, when component unmounts
|
|
200
|
+
* cleanup();
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
export function listenToSystemTheme(callback: (mode: ThemeMode) => void): () => void {
|
|
204
|
+
if (typeof window === 'undefined') return () => {};
|
|
205
|
+
|
|
206
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
207
|
+
|
|
208
|
+
const handler = (e: MediaQueryListEvent) => {
|
|
209
|
+
callback(e.matches ? 'dark' : 'light');
|
|
37
210
|
};
|
|
38
|
-
|
|
211
|
+
|
|
212
|
+
// Modern browsers
|
|
213
|
+
if (mediaQuery.addEventListener) {
|
|
214
|
+
mediaQuery.addEventListener('change', handler);
|
|
215
|
+
return () => mediaQuery.removeEventListener('change', handler);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Fallback for older browsers
|
|
219
|
+
mediaQuery.addListener(handler);
|
|
220
|
+
return () => mediaQuery.removeListener(handler);
|
|
39
221
|
}
|
|
40
222
|
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Theme Persistence
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
41
227
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
228
|
+
* Save theme preference to storage
|
|
229
|
+
*
|
|
230
|
+
* @param mode - Theme mode to save
|
|
231
|
+
* @param options - Persistence options
|
|
44
232
|
*/
|
|
45
|
-
export function
|
|
46
|
-
|
|
47
|
-
|
|
233
|
+
export function persistTheme(
|
|
234
|
+
mode: ThemeMode,
|
|
235
|
+
options: ThemePersistenceOptions = {}
|
|
236
|
+
): void {
|
|
237
|
+
if (typeof window === 'undefined') return;
|
|
238
|
+
|
|
239
|
+
const {
|
|
240
|
+
storageKey = 'atomix-theme',
|
|
241
|
+
storageType = 'localStorage',
|
|
242
|
+
} = options;
|
|
243
|
+
|
|
244
|
+
const storage = storageType === 'localStorage' ? localStorage : sessionStorage;
|
|
245
|
+
storage.setItem(storageKey, mode);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Clear saved theme preference
|
|
250
|
+
*
|
|
251
|
+
* @param options - Persistence options
|
|
252
|
+
*/
|
|
253
|
+
export function clearThemePreference(options: ThemePersistenceOptions = {}): void {
|
|
254
|
+
if (typeof window === 'undefined') return;
|
|
255
|
+
|
|
256
|
+
const {
|
|
257
|
+
storageKey = 'atomix-theme',
|
|
258
|
+
storageType = 'localStorage',
|
|
259
|
+
} = options;
|
|
260
|
+
|
|
261
|
+
const storage = storageType === 'localStorage' ? localStorage : sessionStorage;
|
|
262
|
+
storage.removeItem(storageKey);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// Theme Tokens Manipulation
|
|
267
|
+
// ============================================================================
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Merge multiple token sets
|
|
271
|
+
*
|
|
272
|
+
* Deep merges token objects, with later tokens overriding earlier ones.
|
|
273
|
+
*
|
|
274
|
+
* @param tokens - Token objects to merge
|
|
275
|
+
* @returns Merged tokens
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```typescript
|
|
279
|
+
* const merged = mergeTokens(
|
|
280
|
+
* baseTokens,
|
|
281
|
+
* { colors: { primary: { main: '#custom' } } }
|
|
282
|
+
* );
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
export function mergeTokens(...tokens: Array<Partial<DesignTokens>>): Partial<DesignTokens> {
|
|
286
|
+
return tokens.reduce((acc, current) => {
|
|
287
|
+
return deepMerge(acc, current);
|
|
288
|
+
}, {} as Partial<DesignTokens>);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Override specific tokens
|
|
293
|
+
*
|
|
294
|
+
* Creates a new token object with specific overrides.
|
|
295
|
+
*
|
|
296
|
+
* @param base - Base tokens
|
|
297
|
+
* @param overrides - Tokens to override
|
|
298
|
+
* @returns New tokens with overrides applied
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* ```typescript
|
|
302
|
+
* const customized = overrideTokens(defaultTokens, {
|
|
303
|
+
* colors: {
|
|
304
|
+
* primary: { main: '#ff0000' }
|
|
305
|
+
* }
|
|
306
|
+
* });
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
export function overrideTokens(
|
|
310
|
+
base: Partial<DesignTokens>,
|
|
311
|
+
overrides: Partial<DesignTokens>
|
|
312
|
+
): Partial<DesignTokens> {
|
|
313
|
+
return deepMerge({ ...base }, overrides as any);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Pick specific token categories
|
|
318
|
+
*
|
|
319
|
+
* Extracts only the specified categories from tokens.
|
|
320
|
+
*
|
|
321
|
+
* @param tokens - Source tokens
|
|
322
|
+
* @param categories - Categories to pick
|
|
323
|
+
* @returns Tokens with only selected categories
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```typescript
|
|
327
|
+
* const colorTokens = pickTokens(allTokens, ['colors']);
|
|
328
|
+
* ```
|
|
329
|
+
*/
|
|
330
|
+
export function pickTokens(
|
|
331
|
+
tokens: Partial<DesignTokens>,
|
|
332
|
+
categories: Array<keyof DesignTokens>
|
|
333
|
+
): Partial<DesignTokens> {
|
|
334
|
+
const result: Partial<DesignTokens> = {};
|
|
335
|
+
|
|
336
|
+
categories.forEach(category => {
|
|
337
|
+
if (tokens[category]) {
|
|
338
|
+
result[category] = tokens[category];
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
48
344
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Omit specific token categories
|
|
347
|
+
*
|
|
348
|
+
* Removes specified categories from tokens.
|
|
349
|
+
*
|
|
350
|
+
* @param tokens - Source tokens
|
|
351
|
+
* @param categories - Categories to omit
|
|
352
|
+
* @returns Tokens without omitted categories
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```typescript
|
|
356
|
+
* const withoutColors = omitTokens(allTokens, ['colors']);
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
export function omitTokens(
|
|
360
|
+
tokens: Partial<DesignTokens>,
|
|
361
|
+
categories: Array<keyof DesignTokens>
|
|
362
|
+
): Partial<DesignTokens> {
|
|
363
|
+
const result = { ...tokens };
|
|
364
|
+
|
|
365
|
+
categories.forEach(category => {
|
|
366
|
+
delete result[category];
|
|
53
367
|
});
|
|
368
|
+
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
54
371
|
|
|
55
|
-
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// Color Utilities
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Convert hex color to RGB
|
|
378
|
+
*
|
|
379
|
+
* @param hex - Hex color (with or without #)
|
|
380
|
+
* @returns RGB object { r, g, b }
|
|
381
|
+
*/
|
|
382
|
+
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
383
|
+
// Remove # if present
|
|
384
|
+
hex = hex.replace(/^#/, '');
|
|
385
|
+
|
|
386
|
+
// Handle shorthand hex
|
|
387
|
+
if (hex.length === 3) {
|
|
388
|
+
hex = hex.split('').map(c => c + c).join('');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Validate
|
|
392
|
+
if (hex.length !== 6) return null;
|
|
393
|
+
|
|
394
|
+
const num = parseInt(hex, 16);
|
|
395
|
+
return {
|
|
396
|
+
r: (num >> 16) & 255,
|
|
397
|
+
g: (num >> 8) & 255,
|
|
398
|
+
b: num & 255,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Convert RGB to hex
|
|
404
|
+
*
|
|
405
|
+
* @param r - Red (0-255)
|
|
406
|
+
* @param g - Green (0-255)
|
|
407
|
+
* @param b - Blue (0-255)
|
|
408
|
+
* @returns Hex color with #
|
|
409
|
+
*/
|
|
410
|
+
export function rgbToHex(r: number, g: number, b: number): string {
|
|
411
|
+
return '#' + [r, g, b].map(x => {
|
|
412
|
+
const hex = x.toString(16);
|
|
413
|
+
return hex.length === 1 ? '0' + hex : hex;
|
|
414
|
+
}).join('');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Calculate luminance of a color
|
|
419
|
+
*
|
|
420
|
+
* Used for determining contrast ratios.
|
|
421
|
+
*
|
|
422
|
+
* @param hex - Hex color
|
|
423
|
+
* @returns Luminance value (0-1)
|
|
424
|
+
*/
|
|
425
|
+
export function getLuminance(hex: string): number {
|
|
426
|
+
const rgb = hexToRgb(hex);
|
|
427
|
+
if (!rgb) return 0;
|
|
428
|
+
|
|
429
|
+
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(v => {
|
|
430
|
+
v /= 255;
|
|
431
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
|
|
56
435
|
}
|
|
57
436
|
|
|
58
437
|
/**
|
|
59
438
|
* Calculate contrast ratio between two colors
|
|
439
|
+
*
|
|
440
|
+
* @param hex1 - First hex color
|
|
441
|
+
* @param hex2 - Second hex color
|
|
442
|
+
* @returns Contrast ratio (1-21)
|
|
60
443
|
*/
|
|
61
|
-
export function getContrastRatio(
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
444
|
+
export function getContrastRatio(hex1: string, hex2: string): number {
|
|
445
|
+
const lum1 = getLuminance(hex1);
|
|
446
|
+
const lum2 = getLuminance(hex2);
|
|
447
|
+
|
|
448
|
+
const brightest = Math.max(lum1, lum2);
|
|
449
|
+
const darkest = Math.min(lum1, lum2);
|
|
450
|
+
|
|
451
|
+
return (brightest + 0.05) / (darkest + 0.05);
|
|
65
452
|
}
|
|
66
453
|
|
|
67
454
|
/**
|
|
68
|
-
*
|
|
455
|
+
* Check if text color passes WCAG AA standard
|
|
456
|
+
*
|
|
457
|
+
* @param textColor - Text color hex
|
|
458
|
+
* @param backgroundColor - Background color hex
|
|
459
|
+
* @param size - Font size ('small' or 'large')
|
|
460
|
+
* @returns true if passes WCAG AA
|
|
69
461
|
*/
|
|
70
|
-
export function
|
|
71
|
-
|
|
72
|
-
|
|
462
|
+
export function isAccessible(
|
|
463
|
+
textColor: string,
|
|
464
|
+
backgroundColor: string,
|
|
465
|
+
size: 'small' | 'large' = 'small'
|
|
466
|
+
): boolean {
|
|
467
|
+
const ratio = getContrastRatio(textColor, backgroundColor);
|
|
468
|
+
|
|
469
|
+
// WCAG AA requires 4.5:1 for normal text, 3:1 for large text
|
|
470
|
+
const minimumRatio = size === 'large' ? 3 : 4.5;
|
|
471
|
+
|
|
472
|
+
return ratio >= minimumRatio;
|
|
473
|
+
}
|
|
73
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Get appropriate text color (black or white) for a background
|
|
477
|
+
*
|
|
478
|
+
* @param backgroundColor - Background hex color
|
|
479
|
+
* @param threshold - Contrast threshold (default: 3)
|
|
480
|
+
* @returns '#000000' or '#FFFFFF'
|
|
481
|
+
*/
|
|
482
|
+
export function getContrastText(backgroundColor: string, threshold: number = 3): string {
|
|
483
|
+
const contrastWithWhite = getContrastRatio(backgroundColor, '#FFFFFF');
|
|
74
484
|
if (contrastWithWhite >= threshold) {
|
|
75
485
|
return '#FFFFFF';
|
|
76
486
|
}
|
|
77
|
-
|
|
78
|
-
return '#000000';
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Default to white if neither meets threshold
|
|
82
|
-
return contrastWithWhite > contrastWithBlack ? '#FFFFFF' : '#000000';
|
|
487
|
+
return '#000000';
|
|
83
488
|
}
|
|
84
489
|
|
|
85
490
|
/**
|
|
86
|
-
* Lighten a color
|
|
87
|
-
*
|
|
88
|
-
* @param
|
|
89
|
-
* @param amount - Amount to lighten (0-1)
|
|
491
|
+
* Lighten a color
|
|
492
|
+
*
|
|
493
|
+
* @param hex - Base hex color
|
|
494
|
+
* @param amount - Amount to lighten (0-1)
|
|
90
495
|
* @returns Lightened hex color
|
|
91
496
|
*/
|
|
92
|
-
export function lighten(
|
|
93
|
-
const rgb = hexToRgb(
|
|
94
|
-
if (!rgb) return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
497
|
+
export function lighten(hex: string, amount: number = 0): string {
|
|
498
|
+
const rgb = hexToRgb(hex);
|
|
499
|
+
if (!rgb) return hex;
|
|
500
|
+
|
|
501
|
+
// Use amount directly as factor (0-1)
|
|
502
|
+
const factor = Math.max(0, Math.min(1, amount));
|
|
503
|
+
|
|
504
|
+
const r = Math.round(rgb.r + (255 - rgb.r) * factor);
|
|
505
|
+
const g = Math.round(rgb.g + (255 - rgb.g) * factor);
|
|
506
|
+
const b = Math.round(rgb.b + (255 - rgb.b) * factor);
|
|
507
|
+
|
|
508
|
+
return rgbToHex(Math.min(255, r), Math.min(255, g), Math.min(255, b));
|
|
100
509
|
}
|
|
101
510
|
|
|
102
511
|
/**
|
|
103
|
-
* Darken a color
|
|
104
|
-
*
|
|
105
|
-
* @param
|
|
106
|
-
* @param amount - Amount to darken (0-1)
|
|
512
|
+
* Darken a color
|
|
513
|
+
*
|
|
514
|
+
* @param hex - Base hex color
|
|
515
|
+
* @param amount - Amount to darken (0-1)
|
|
107
516
|
* @returns Darkened hex color
|
|
108
517
|
*/
|
|
109
|
-
export function darken(
|
|
110
|
-
const rgb = hexToRgb(
|
|
111
|
-
if (!rgb) return
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
518
|
+
export function darken(hex: string, amount: number = 0): string {
|
|
519
|
+
const rgb = hexToRgb(hex);
|
|
520
|
+
if (!rgb) return hex;
|
|
521
|
+
|
|
522
|
+
// Use amount directly as factor (0-1)
|
|
523
|
+
const factor = Math.max(0, Math.min(1, amount));
|
|
524
|
+
|
|
525
|
+
const r = Math.round(rgb.r * (1 - factor));
|
|
526
|
+
const g = Math.round(rgb.g * (1 - factor));
|
|
527
|
+
const b = Math.round(rgb.b * (1 - factor));
|
|
528
|
+
|
|
529
|
+
return rgbToHex(Math.max(0, r), Math.max(0, g), Math.max(0, b));
|
|
117
530
|
}
|
|
118
531
|
|
|
119
532
|
/**
|
|
120
|
-
* Add alpha
|
|
121
|
-
*
|
|
122
|
-
* @param
|
|
533
|
+
* Add alpha to a color
|
|
534
|
+
*
|
|
535
|
+
* @param hex - Hex color
|
|
123
536
|
* @param opacity - Opacity value (0-1)
|
|
124
537
|
* @returns RGBA color string
|
|
125
538
|
*/
|
|
126
|
-
export function alpha(
|
|
127
|
-
const rgb = hexToRgb(
|
|
128
|
-
if (!rgb) return
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return `rgba(${r}, ${g}, ${b}, ${clampedOpacity})`;
|
|
539
|
+
export function alpha(hex: string, opacity: number): string {
|
|
540
|
+
const rgb = hexToRgb(hex);
|
|
541
|
+
if (!rgb) return hex;
|
|
542
|
+
|
|
543
|
+
const validOpacity = Math.max(0, Math.min(1, opacity));
|
|
544
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${validOpacity})`;
|
|
134
545
|
}
|
|
135
546
|
|
|
136
547
|
/**
|
|
137
548
|
* Emphasize a color (lighten if dark, darken if light)
|
|
138
|
-
*
|
|
139
|
-
* @param
|
|
140
|
-
* @param
|
|
549
|
+
*
|
|
550
|
+
* @param hex - Hex color
|
|
551
|
+
* @param amount - Amount to emphasize (0-1)
|
|
141
552
|
* @returns Emphasized hex color
|
|
142
553
|
*/
|
|
143
|
-
export function emphasize(
|
|
144
|
-
const luminance = getLuminance(
|
|
145
|
-
return luminance > 0.5 ? darken(
|
|
554
|
+
export function emphasize(hex: string, amount: number = 0.15): string {
|
|
555
|
+
const luminance = getLuminance(hex);
|
|
556
|
+
return luminance > 0.5 ? darken(hex, amount) : lighten(hex, amount);
|
|
146
557
|
}
|
|
147
558
|
|
|
148
|
-
// ============================================================================
|
|
149
|
-
// Spacing Utilities
|
|
150
|
-
// ============================================================================
|
|
151
|
-
|
|
152
559
|
/**
|
|
153
|
-
* Create a spacing
|
|
154
|
-
*
|
|
155
|
-
* @param spacingInput - Spacing configuration
|
|
560
|
+
* Create a spacing utility
|
|
561
|
+
*
|
|
562
|
+
* @param spacingInput - Spacing configuration
|
|
156
563
|
* @returns Spacing function
|
|
157
564
|
*/
|
|
158
565
|
export function createSpacing(spacingInput: SpacingOptions = 4): SpacingFunction {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
566
|
+
return (...values: number[]): string => {
|
|
567
|
+
if (values.length === 0) {
|
|
568
|
+
return typeof spacingInput === 'number' ? `0px` : '0px';
|
|
569
|
+
}
|
|
163
570
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (values.length === 0) return '0px';
|
|
168
|
-
return values.map(value => `${value * spacingInput}px`).join(' ');
|
|
169
|
-
};
|
|
170
|
-
}
|
|
571
|
+
if (typeof spacingInput === 'function') {
|
|
572
|
+
return spacingInput(...values);
|
|
573
|
+
}
|
|
171
574
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
575
|
+
return values
|
|
576
|
+
.map(value => {
|
|
577
|
+
if (typeof spacingInput === 'number') {
|
|
578
|
+
return `${value * spacingInput}px`;
|
|
579
|
+
}
|
|
580
|
+
if (Array.isArray(spacingInput)) {
|
|
581
|
+
const scaled = spacingInput[value];
|
|
582
|
+
return typeof scaled === 'number' ? `${scaled}px` : `${value}px`;
|
|
583
|
+
}
|
|
584
|
+
return `${value}px`;
|
|
585
|
+
})
|
|
586
|
+
.join(' ');
|
|
184
587
|
};
|
|
185
588
|
}
|
|
589
|
+
|
|
590
|
+
// ============================================================================
|
|
591
|
+
// Helper Functions
|
|
592
|
+
// ============================================================================
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Check if value is an object
|
|
596
|
+
*/
|
|
597
|
+
function isObject(item: any): item is object {
|
|
598
|
+
return item && typeof item === 'object' && !Array.isArray(item);
|
|
599
|
+
}
|