@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.
- package/README.md +30 -1
- package/package.json +3 -3
- package/src/docs.js +2 -2
- package/src/generate-entry.js +26 -5
- package/src/index.js +2 -1
- package/src/prerender.js +18 -2
- package/src/schema.js +78 -11
- package/src/site/config.js +2 -1
- package/src/site/content-collector.js +265 -20
- package/src/site/plugin.js +14 -4
- package/src/theme/css-generator.js +341 -0
- package/src/theme/index.js +65 -0
- package/src/theme/processor.js +422 -0
- package/src/theme/shade-generator.js +666 -0
|
@@ -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
|
+
}
|