@uniweb/build 0.8.12 → 0.8.14

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