@uniweb/build 0.1.32 → 0.2.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.
@@ -0,0 +1,666 @@
1
+ /**
2
+ * OKLCH Shade Generator
3
+ *
4
+ * Generates 11 color shades (50-950) from a single base color using
5
+ * the OKLCH color space for perceptually uniform results.
6
+ *
7
+ * Supports multiple generation modes:
8
+ * - 'fixed' (default): Predictable lightness values, constant hue
9
+ * - 'natural': Temperature-aware hue shifts, curved chroma
10
+ * - 'vivid': Higher saturation, more dramatic chroma curve
11
+ *
12
+ * @module @uniweb/build/theme/shade-generator
13
+ */
14
+
15
+ // Standard shade levels matching Tailwind's scale
16
+ const SHADE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
17
+
18
+ // Lightness values for each shade (perceptually uniform steps)
19
+ // These are calibrated to match typical design system expectations
20
+ const LIGHTNESS_MAP = {
21
+ 50: 0.97, // Very light - almost white
22
+ 100: 0.93,
23
+ 200: 0.87,
24
+ 300: 0.78,
25
+ 400: 0.68,
26
+ 500: 0.55, // Base color - most vibrant
27
+ 600: 0.48,
28
+ 700: 0.40,
29
+ 800: 0.32,
30
+ 900: 0.24,
31
+ 950: 0.14, // Very dark - almost black
32
+ }
33
+
34
+ // Chroma scaling - reduce saturation at extremes to avoid clipping
35
+ // Values represent percentage of original chroma to preserve
36
+ const CHROMA_SCALE = {
37
+ 50: 0.15, // Very desaturated at light end
38
+ 100: 0.25,
39
+ 200: 0.40,
40
+ 300: 0.65,
41
+ 400: 0.85,
42
+ 500: 1.0, // Full chroma at base
43
+ 600: 0.95,
44
+ 700: 0.85,
45
+ 800: 0.75,
46
+ 900: 0.60,
47
+ 950: 0.45, // Reduced chroma at dark end
48
+ }
49
+
50
+ // Mode-specific configurations
51
+ const MODE_CONFIG = {
52
+ // Fixed mode: predictable, consistent (current default behavior)
53
+ fixed: {
54
+ hueShift: { light: 0, dark: 0 },
55
+ chromaBoost: 1.0,
56
+ lightEndChroma: 0.15,
57
+ darkEndChroma: 0.45,
58
+ },
59
+ // Natural mode: temperature-aware hue shifts, organic feel
60
+ natural: {
61
+ hueShift: { light: 5, dark: -15 }, // For warm colors (inverted for cool)
62
+ chromaBoost: 1.1,
63
+ lightEndChroma: 0.20,
64
+ darkEndChroma: 0.40,
65
+ },
66
+ // Vivid mode: higher saturation, more dramatic
67
+ vivid: {
68
+ hueShift: { light: 3, dark: -10 },
69
+ chromaBoost: 1.4,
70
+ lightEndChroma: 0.35,
71
+ darkEndChroma: 0.55,
72
+ },
73
+ }
74
+
75
+ /**
76
+ * Parse a color string into OKLCH components
77
+ * Supports: hex (#fff, #ffffff), rgb(), hsl(), oklch()
78
+ *
79
+ * @param {string} color - Color string in any supported format
80
+ * @returns {{ l: number, c: number, h: number }} OKLCH components
81
+ */
82
+ export function parseColor(color) {
83
+ if (!color || typeof color !== 'string') {
84
+ throw new Error(`Invalid color: ${color}`)
85
+ }
86
+
87
+ const trimmed = color.trim().toLowerCase()
88
+
89
+ // OKLCH format: oklch(0.55 0.2 250) or oklch(55% 0.2 250deg)
90
+ if (trimmed.startsWith('oklch(')) {
91
+ return parseOklch(trimmed)
92
+ }
93
+
94
+ // Hex format: #fff or #ffffff
95
+ if (trimmed.startsWith('#')) {
96
+ return hexToOklch(trimmed)
97
+ }
98
+
99
+ // RGB format: rgb(255, 100, 50) or rgb(255 100 50)
100
+ if (trimmed.startsWith('rgb')) {
101
+ return rgbToOklch(trimmed)
102
+ }
103
+
104
+ // HSL format: hsl(200, 80%, 50%) or hsl(200 80% 50%)
105
+ if (trimmed.startsWith('hsl')) {
106
+ return hslToOklch(trimmed)
107
+ }
108
+
109
+ // Try as hex without #
110
+ if (/^[0-9a-f]{3,8}$/i.test(trimmed)) {
111
+ return hexToOklch('#' + trimmed)
112
+ }
113
+
114
+ throw new Error(`Unsupported color format: ${color}`)
115
+ }
116
+
117
+ /**
118
+ * Parse OKLCH string
119
+ */
120
+ function parseOklch(str) {
121
+ const match = str.match(/oklch\(\s*([0-9.]+)(%?)\s+([0-9.]+)\s+([0-9.]+)(deg)?\s*\)/)
122
+ if (!match) {
123
+ throw new Error(`Invalid oklch format: ${str}`)
124
+ }
125
+
126
+ let l = parseFloat(match[1])
127
+ if (match[2] === '%') l /= 100
128
+
129
+ const c = parseFloat(match[3])
130
+ const h = parseFloat(match[4])
131
+
132
+ return { l, c, h }
133
+ }
134
+
135
+ /**
136
+ * Convert hex color to OKLCH
137
+ */
138
+ function hexToOklch(hex) {
139
+ const rgb = hexToRgb(hex)
140
+ return rgbValuesToOklch(rgb.r, rgb.g, rgb.b)
141
+ }
142
+
143
+ /**
144
+ * Parse hex string to RGB values
145
+ */
146
+ function hexToRgb(hex) {
147
+ let h = hex.replace('#', '')
148
+
149
+ // Expand shorthand (e.g., #fff -> #ffffff)
150
+ if (h.length === 3) {
151
+ h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
152
+ }
153
+
154
+ if (h.length === 4) {
155
+ h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]
156
+ }
157
+
158
+ const num = parseInt(h.slice(0, 6), 16)
159
+ return {
160
+ r: (num >> 16) & 255,
161
+ g: (num >> 8) & 255,
162
+ b: num & 255,
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Parse RGB string to OKLCH
168
+ */
169
+ function rgbToOklch(str) {
170
+ // Match rgb(r, g, b) or rgb(r g b) or rgba(r, g, b, a)
171
+ const match = str.match(/rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/)
172
+ if (!match) {
173
+ throw new Error(`Invalid rgb format: ${str}`)
174
+ }
175
+
176
+ const r = parseFloat(match[1])
177
+ const g = parseFloat(match[2])
178
+ const b = parseFloat(match[3])
179
+
180
+ return rgbValuesToOklch(r, g, b)
181
+ }
182
+
183
+ /**
184
+ * Parse HSL string to OKLCH
185
+ */
186
+ function hslToOklch(str) {
187
+ // Match hsl(h, s%, l%) or hsl(h s% l%) or hsla(h, s%, l%, a)
188
+ const match = str.match(/hsla?\(\s*([0-9.]+)(deg)?[\s,]+([0-9.]+)%[\s,]+([0-9.]+)%/)
189
+ if (!match) {
190
+ throw new Error(`Invalid hsl format: ${str}`)
191
+ }
192
+
193
+ const h = parseFloat(match[1])
194
+ const s = parseFloat(match[3]) / 100
195
+ const l = parseFloat(match[4]) / 100
196
+
197
+ // Convert HSL to RGB first
198
+ const rgb = hslToRgb(h, s, l)
199
+ return rgbValuesToOklch(rgb.r * 255, rgb.g * 255, rgb.b * 255)
200
+ }
201
+
202
+ /**
203
+ * Convert HSL to RGB (values 0-1)
204
+ */
205
+ function hslToRgb(h, s, l) {
206
+ const hue = h / 360
207
+ let r, g, b
208
+
209
+ if (s === 0) {
210
+ r = g = b = l
211
+ } else {
212
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s
213
+ const p = 2 * l - q
214
+ r = hueToRgb(p, q, hue + 1 / 3)
215
+ g = hueToRgb(p, q, hue)
216
+ b = hueToRgb(p, q, hue - 1 / 3)
217
+ }
218
+
219
+ return { r, g, b }
220
+ }
221
+
222
+ function hueToRgb(p, q, t) {
223
+ if (t < 0) t += 1
224
+ if (t > 1) t -= 1
225
+ if (t < 1 / 6) return p + (q - p) * 6 * t
226
+ if (t < 1 / 2) return q
227
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
228
+ return p
229
+ }
230
+
231
+ /**
232
+ * Convert RGB (0-255) to OKLCH
233
+ * Uses the OKLab intermediate color space
234
+ */
235
+ function rgbValuesToOklch(r, g, b) {
236
+ // Normalize to 0-1 range
237
+ r /= 255
238
+ g /= 255
239
+ b /= 255
240
+
241
+ // Apply sRGB gamma correction (linearize)
242
+ r = srgbToLinear(r)
243
+ g = srgbToLinear(g)
244
+ b = srgbToLinear(b)
245
+
246
+ // Convert linear RGB to OKLab via LMS
247
+ // Using the OKLab matrix from https://bottosson.github.io/posts/oklab/
248
+ const l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
249
+ const m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
250
+ const s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
251
+
252
+ const l = Math.cbrt(l_)
253
+ const m = Math.cbrt(m_)
254
+ const s = Math.cbrt(s_)
255
+
256
+ // OKLab coordinates
257
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
258
+ const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
259
+ const bLab = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
260
+
261
+ // Convert OKLab to OKLCH
262
+ const C = Math.sqrt(a * a + bLab * bLab)
263
+ let H = Math.atan2(bLab, a) * (180 / Math.PI)
264
+ if (H < 0) H += 360
265
+
266
+ return { l: L, c: C, h: H }
267
+ }
268
+
269
+ /**
270
+ * Convert sRGB component to linear RGB
271
+ */
272
+ function srgbToLinear(c) {
273
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
274
+ }
275
+
276
+ /**
277
+ * Convert linear RGB component to sRGB
278
+ */
279
+ function linearToSrgb(c) {
280
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
281
+ }
282
+
283
+ /**
284
+ * Convert OKLCH to RGB (0-255)
285
+ */
286
+ function oklchToRgb(l, c, h) {
287
+ // Convert OKLCH to OKLab
288
+ const hRad = h * (Math.PI / 180)
289
+ const a = c * Math.cos(hRad)
290
+ const bLab = c * Math.sin(hRad)
291
+
292
+ // Convert OKLab to linear RGB via LMS
293
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * bLab
294
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * bLab
295
+ const s_ = l - 0.0894841775 * a - 1.2914855480 * bLab
296
+
297
+ const lCubed = l_ * l_ * l_
298
+ const mCubed = m_ * m_ * m_
299
+ const sCubed = s_ * s_ * s_
300
+
301
+ // Linear RGB
302
+ let r = +4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed
303
+ let g = -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed
304
+ let b = -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.7076147010 * sCubed
305
+
306
+ // Apply sRGB gamma and clamp
307
+ r = Math.round(Math.max(0, Math.min(1, linearToSrgb(r))) * 255)
308
+ g = Math.round(Math.max(0, Math.min(1, linearToSrgb(g))) * 255)
309
+ b = Math.round(Math.max(0, Math.min(1, linearToSrgb(b))) * 255)
310
+
311
+ return { r, g, b }
312
+ }
313
+
314
+ /**
315
+ * Check if RGB values are within sRGB gamut
316
+ */
317
+ function inGamut(r, g, b) {
318
+ return r >= -0.5 && r <= 255.5 && g >= -0.5 && g <= 255.5 && b >= -0.5 && b <= 255.5
319
+ }
320
+
321
+ /**
322
+ * Find maximum chroma that fits within sRGB gamut using binary search
323
+ *
324
+ * @param {number} l - Lightness
325
+ * @param {number} h - Hue
326
+ * @param {number} idealC - Desired chroma (upper bound)
327
+ * @returns {number} Maximum valid chroma
328
+ */
329
+ function findMaxChroma(l, h, idealC) {
330
+ let minC = 0
331
+ let maxC = idealC
332
+ let bestC = 0
333
+
334
+ // Binary search with 8 iterations for precision
335
+ for (let i = 0; i < 8; i++) {
336
+ const midC = (minC + maxC) / 2
337
+ const rgb = oklchToRgb(l, midC, h)
338
+ if (inGamut(rgb.r, rgb.g, rgb.b)) {
339
+ bestC = midC
340
+ minC = midC
341
+ } else {
342
+ maxC = midC
343
+ }
344
+ }
345
+
346
+ return bestC
347
+ }
348
+
349
+ /**
350
+ * Quadratic Bézier interpolation for smooth chroma curves
351
+ *
352
+ * @param {number} a - Start value
353
+ * @param {number} control - Control point
354
+ * @param {number} b - End value
355
+ * @param {number} t - Interpolation factor (0-1)
356
+ * @returns {number} Interpolated value
357
+ */
358
+ function quadBezier(a, control, b, t) {
359
+ const mt = 1 - t
360
+ return mt * mt * a + 2 * mt * t * control + t * t * b
361
+ }
362
+
363
+ /**
364
+ * Linear interpolation
365
+ */
366
+ function lerp(a, b, t) {
367
+ return a + (b - a) * t
368
+ }
369
+
370
+ /**
371
+ * Normalize hue to 0-360 range
372
+ */
373
+ function normalizeHue(h) {
374
+ h = h % 360
375
+ return h < 0 ? h + 360 : h
376
+ }
377
+
378
+ /**
379
+ * Check if a color is warm (reds, oranges, yellows)
380
+ *
381
+ * @param {number} h - Hue angle (0-360)
382
+ * @returns {boolean} True if warm
383
+ */
384
+ function isWarmColor(h) {
385
+ return (h >= 0 && h < 120) || h > 300
386
+ }
387
+
388
+ /**
389
+ * Format OKLCH values as CSS string
390
+ *
391
+ * @param {number} l - Lightness (0-1)
392
+ * @param {number} c - Chroma
393
+ * @param {number} h - Hue (0-360)
394
+ * @returns {string} CSS oklch() string
395
+ */
396
+ export function formatOklch(l, c, h) {
397
+ // Round to reasonable precision
398
+ const lStr = (l * 100).toFixed(1)
399
+ const cStr = c.toFixed(4)
400
+ const hStr = h.toFixed(1)
401
+ return `oklch(${lStr}% ${cStr} ${hStr})`
402
+ }
403
+
404
+ /**
405
+ * Format RGB values as hex string
406
+ *
407
+ * @param {number} r - Red (0-255)
408
+ * @param {number} g - Green (0-255)
409
+ * @param {number} b - Blue (0-255)
410
+ * @returns {string} Hex color string
411
+ */
412
+ export function formatHex(r, g, b) {
413
+ const toHex = (n) => n.toString(16).padStart(2, '0')
414
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
415
+ }
416
+
417
+ /**
418
+ * Generate color shades from a base color
419
+ *
420
+ * @param {string} color - Base color in any supported format
421
+ * @param {Object} options - Options
422
+ * @param {string} [options.format='oklch'] - Output format: 'oklch' or 'hex'
423
+ * @param {string} [options.mode='fixed'] - Generation mode: 'fixed', 'natural', or 'vivid'
424
+ * @param {boolean} [options.exactMatch=false] - If true, shade 500 will be the exact input color
425
+ * @returns {Object} Object with shade levels as keys (50-950) and color values
426
+ *
427
+ * @example
428
+ * // Default fixed mode (predictable, constant hue)
429
+ * generateShades('#3b82f6')
430
+ *
431
+ * @example
432
+ * // Natural mode (temperature-aware hue shifts)
433
+ * generateShades('#3b82f6', { mode: 'natural' })
434
+ *
435
+ * @example
436
+ * // Vivid mode (higher saturation)
437
+ * generateShades('#3b82f6', { mode: 'vivid', exactMatch: true })
438
+ */
439
+ export function generateShades(color, options = {}) {
440
+ const { format = 'oklch', mode = 'fixed', exactMatch = false } = options
441
+ const base = parseColor(color)
442
+ const config = MODE_CONFIG[mode] || MODE_CONFIG.fixed
443
+
444
+ // For fixed mode, use the original simple algorithm
445
+ if (mode === 'fixed') {
446
+ return generateFixedShades(base, color, format, exactMatch)
447
+ }
448
+
449
+ // For natural/vivid modes, use enhanced algorithm
450
+ return generateEnhancedShades(base, color, format, config, exactMatch)
451
+ }
452
+
453
+ /**
454
+ * Original fixed-lightness algorithm (default)
455
+ */
456
+ function generateFixedShades(base, originalColor, format, exactMatch) {
457
+ const shades = {}
458
+
459
+ for (const level of SHADE_LEVELS) {
460
+ // Handle exact match at 500
461
+ if (exactMatch && level === 500) {
462
+ if (format === 'hex') {
463
+ shades[level] = originalColor.startsWith('#') ? originalColor : formatHexFromOklch(base)
464
+ } else {
465
+ shades[level] = formatOklch(base.l, base.c, base.h)
466
+ }
467
+ continue
468
+ }
469
+
470
+ const targetL = LIGHTNESS_MAP[level]
471
+ const chromaScale = CHROMA_SCALE[level]
472
+ const targetC = base.c * chromaScale
473
+
474
+ // Use gamut mapping to find valid chroma
475
+ const safeC = findMaxChroma(targetL, base.h, targetC)
476
+
477
+ if (format === 'hex') {
478
+ const rgb = oklchToRgb(targetL, safeC, base.h)
479
+ shades[level] = formatHex(rgb.r, rgb.g, rgb.b)
480
+ } else {
481
+ shades[level] = formatOklch(targetL, safeC, base.h)
482
+ }
483
+ }
484
+
485
+ return shades
486
+ }
487
+
488
+ /**
489
+ * Enhanced algorithm with hue shifting and curved chroma (natural/vivid modes)
490
+ */
491
+ function generateEnhancedShades(base, originalColor, format, config, exactMatch) {
492
+ const shades = {}
493
+ const isWarm = isWarmColor(base.h)
494
+
495
+ // Calculate hue shift direction based on color temperature
496
+ const hueShiftLight = isWarm ? config.hueShift.light : -config.hueShift.light
497
+ const hueShiftDark = isWarm ? config.hueShift.dark : -config.hueShift.dark
498
+
499
+ // Define endpoints
500
+ const lightEnd = {
501
+ l: LIGHTNESS_MAP[50],
502
+ c: base.c * config.lightEndChroma,
503
+ h: normalizeHue(base.h + hueShiftLight),
504
+ }
505
+
506
+ const darkEnd = {
507
+ l: LIGHTNESS_MAP[950],
508
+ c: base.c * config.darkEndChroma,
509
+ h: normalizeHue(base.h + hueShiftDark),
510
+ }
511
+
512
+ // Control point for chroma curve (peaks at middle)
513
+ const peakChroma = base.c * config.chromaBoost
514
+
515
+ for (let i = 0; i < SHADE_LEVELS.length; i++) {
516
+ const level = SHADE_LEVELS[i]
517
+
518
+ // Handle exact match at 500 (index 5)
519
+ if (exactMatch && level === 500) {
520
+ if (format === 'hex') {
521
+ shades[level] = originalColor.startsWith('#') ? originalColor : formatHexFromOklch(base)
522
+ } else {
523
+ shades[level] = formatOklch(base.l, base.c, base.h)
524
+ }
525
+ continue
526
+ }
527
+
528
+ let targetL, targetC, targetH
529
+
530
+ // Split the curve at the base color (index 5 = shade 500)
531
+ if (i <= 5) {
532
+ // Light half: interpolate from lightEnd to base
533
+ const t = i / 5
534
+ targetL = lerp(lightEnd.l, base.l, t)
535
+ targetH = lerp(lightEnd.h, base.h, t)
536
+
537
+ // Bézier curve for chroma with peak at middle
538
+ const controlC = (lightEnd.c + peakChroma) / 2
539
+ targetC = quadBezier(lightEnd.c, controlC, peakChroma, t)
540
+ } else {
541
+ // Dark half: interpolate from base to darkEnd
542
+ const t = (i - 5) / 5
543
+ targetL = lerp(base.l, darkEnd.l, t)
544
+ targetH = lerp(base.h, darkEnd.h, t)
545
+
546
+ // Bézier curve for chroma, descending from peak
547
+ const controlC = (peakChroma + darkEnd.c) / 2
548
+ targetC = quadBezier(peakChroma, controlC, darkEnd.c, t)
549
+ }
550
+
551
+ // Normalize hue
552
+ targetH = normalizeHue(targetH)
553
+
554
+ // Gamut map to find maximum valid chroma
555
+ const safeC = findMaxChroma(targetL, targetH, targetC)
556
+
557
+ if (format === 'hex') {
558
+ const rgb = oklchToRgb(targetL, safeC, targetH)
559
+ shades[level] = formatHex(rgb.r, rgb.g, rgb.b)
560
+ } else {
561
+ shades[level] = formatOklch(targetL, safeC, targetH)
562
+ }
563
+ }
564
+
565
+ return shades
566
+ }
567
+
568
+ /**
569
+ * Helper to format OKLCH as hex
570
+ */
571
+ function formatHexFromOklch(oklch) {
572
+ const rgb = oklchToRgb(oklch.l, oklch.c, oklch.h)
573
+ return formatHex(rgb.r, rgb.g, rgb.b)
574
+ }
575
+
576
+ /**
577
+ * Generate shades for multiple colors
578
+ *
579
+ * @param {Object} colors - Object with color names as keys and color values or config objects
580
+ * @param {Object} options - Default options passed to generateShades
581
+ * @returns {Object} Object with color names, each containing shade levels
582
+ *
583
+ * @example
584
+ * // Simple usage with defaults
585
+ * generatePalettes({
586
+ * primary: '#3b82f6',
587
+ * secondary: '#64748b'
588
+ * })
589
+ *
590
+ * @example
591
+ * // With per-color options
592
+ * generatePalettes({
593
+ * primary: { base: '#3b82f6', mode: 'vivid', exactMatch: true },
594
+ * secondary: '#64748b', // Uses defaults
595
+ * neutral: { base: '#737373', mode: 'fixed' }
596
+ * })
597
+ */
598
+ export function generatePalettes(colors, options = {}) {
599
+ const palettes = {}
600
+
601
+ for (const [name, colorConfig] of Object.entries(colors)) {
602
+ // Pre-defined shades (object with numeric keys)
603
+ if (typeof colorConfig === 'object' && colorConfig !== null && !colorConfig.base) {
604
+ // Check if it's a shades object (has numeric keys like 50, 100, etc)
605
+ const keys = Object.keys(colorConfig)
606
+ if (keys.some(k => !isNaN(parseInt(k)))) {
607
+ palettes[name] = colorConfig
608
+ continue
609
+ }
610
+ }
611
+
612
+ // Color config object with base and options
613
+ if (typeof colorConfig === 'object' && colorConfig !== null && colorConfig.base) {
614
+ const { base, ...colorOptions } = colorConfig
615
+ palettes[name] = generateShades(base, { ...options, ...colorOptions })
616
+ }
617
+ // Simple color string
618
+ else if (typeof colorConfig === 'string') {
619
+ palettes[name] = generateShades(colorConfig, options)
620
+ }
621
+ }
622
+
623
+ return palettes
624
+ }
625
+
626
+ /**
627
+ * Get available generation modes
628
+ * @returns {string[]} Array of mode names
629
+ */
630
+ export function getAvailableModes() {
631
+ return Object.keys(MODE_CONFIG)
632
+ }
633
+
634
+ /**
635
+ * Check if a color string is valid
636
+ *
637
+ * @param {string} color - Color string to validate
638
+ * @returns {boolean} True if color can be parsed
639
+ */
640
+ export function isValidColor(color) {
641
+ try {
642
+ parseColor(color)
643
+ return true
644
+ } catch {
645
+ return false
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Get the shade levels used for generation
651
+ * @returns {number[]} Array of shade levels
652
+ */
653
+ export function getShadeLevels() {
654
+ return [...SHADE_LEVELS]
655
+ }
656
+
657
+ export default {
658
+ parseColor,
659
+ formatOklch,
660
+ formatHex,
661
+ generateShades,
662
+ generatePalettes,
663
+ isValidColor,
664
+ getShadeLevels,
665
+ getAvailableModes,
666
+ }