@uniweb/build 0.1.33 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.1.33",
3
+ "version": "0.2.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/content-reader": "1.0.4",
54
- "@uniweb/runtime": "0.2.19"
54
+ "@uniweb/runtime": "0.3.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.1.16"
63
+ "@uniweb/core": "0.2.1"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
package/src/docs.js CHANGED
@@ -165,8 +165,8 @@ export async function generateDocs(foundationDir, options = {}) {
165
165
 
166
166
  let schema
167
167
 
168
- // Try to load schema.json from dist
169
- const schemaPath = join(foundationDir, 'dist', 'schema.json')
168
+ // Try to load schema.json from dist/meta (where foundation build outputs it)
169
+ const schemaPath = join(foundationDir, 'dist', 'meta', 'schema.json')
170
170
 
171
171
  if (!fromSource && existsSync(schemaPath)) {
172
172
  // Load from existing schema.json
package/src/prerender.js CHANGED
@@ -306,9 +306,11 @@ function renderBlock(block) {
306
306
  }
307
307
 
308
308
  // Wrapper props
309
+ // Use stableId for DOM ID if available (stable across reordering)
309
310
  const theme = block.themeName
311
+ const sectionId = block.stableId || block.id
310
312
  const wrapperProps = {
311
- id: `Section${block.id}`,
313
+ id: `section-${sectionId}`,
312
314
  className: theme || ''
313
315
  }
314
316
 
@@ -67,6 +67,90 @@ function extractRouteParam(folderName) {
67
67
  return match ? match[1] : null
68
68
  }
69
69
 
70
+ // ─────────────────────────────────────────────────────────────────
71
+ // Version Detection
72
+ // ─────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Check if a folder name represents a version (e.g., v1, v2, v1.0, v2.1)
76
+ * @param {string} folderName - The folder name to check
77
+ * @returns {boolean}
78
+ */
79
+ function isVersionFolder(folderName) {
80
+ return /^v\d+(\.\d+)?$/.test(folderName)
81
+ }
82
+
83
+ /**
84
+ * Parse version info from folder name
85
+ * @param {string} folderName - The folder name (e.g., "v1", "v2.1")
86
+ * @returns {Object} Version info { id, major, minor, sortKey }
87
+ */
88
+ function parseVersionInfo(folderName) {
89
+ const match = folderName.match(/^v(\d+)(?:\.(\d+))?$/)
90
+ if (!match) return null
91
+
92
+ const major = parseInt(match[1], 10)
93
+ const minor = match[2] ? parseInt(match[2], 10) : 0
94
+
95
+ return {
96
+ id: folderName,
97
+ major,
98
+ minor,
99
+ sortKey: major * 1000 + minor // For sorting: v2.1 > v2.0 > v1.9
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Detect if a set of folders contains version folders
105
+ * @param {Array<string>} folderNames - List of folder names
106
+ * @returns {Array<Object>|null} Sorted version infos (highest first) or null if not versioned
107
+ */
108
+ function detectVersions(folderNames) {
109
+ const versions = folderNames
110
+ .filter(isVersionFolder)
111
+ .map(parseVersionInfo)
112
+ .filter(Boolean)
113
+
114
+ if (versions.length === 0) return null
115
+
116
+ // Sort by version (highest first)
117
+ versions.sort((a, b) => b.sortKey - a.sortKey)
118
+
119
+ return versions
120
+ }
121
+
122
+ /**
123
+ * Build version metadata from detected versions and page.yml config
124
+ * @param {Array<Object>} detectedVersions - Detected version infos
125
+ * @param {Object} pageConfig - page.yml configuration
126
+ * @returns {Object} Version metadata { versions, latestId, scope }
127
+ */
128
+ function buildVersionMetadata(detectedVersions, pageConfig = {}) {
129
+ const configVersions = pageConfig.versions || {}
130
+
131
+ // Build version list with metadata
132
+ const versions = detectedVersions.map((v, index) => {
133
+ const config = configVersions[v.id] || {}
134
+ const isLatest = config.latest === true || (index === 0 && !Object.values(configVersions).some(c => c.latest))
135
+
136
+ return {
137
+ id: v.id,
138
+ label: config.label || v.id,
139
+ latest: isLatest,
140
+ deprecated: config.deprecated || false,
141
+ sortKey: v.sortKey
142
+ }
143
+ })
144
+
145
+ // Find the latest version
146
+ const latestVersion = versions.find(v => v.latest) || versions[0]
147
+
148
+ return {
149
+ versions,
150
+ latestId: latestVersion?.id || null
151
+ }
152
+ }
153
+
70
154
  /**
71
155
  * Parse YAML string using js-yaml
72
156
  */
@@ -144,11 +228,12 @@ function compareFilenames(a, b) {
144
228
  * Process a markdown file into a section
145
229
  *
146
230
  * @param {string} filePath - Path to markdown file
147
- * @param {string} id - Section ID
231
+ * @param {string} id - Section ID (numeric/positional)
148
232
  * @param {string} siteRoot - Site root directory for asset resolution
233
+ * @param {string} defaultStableId - Default stable ID from filename (can be overridden in frontmatter)
149
234
  * @returns {Object} Section data with assets manifest
150
235
  */
151
- async function processMarkdownFile(filePath, id, siteRoot) {
236
+ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = null) {
152
237
  const content = await readFile(filePath, 'utf8')
153
238
  let frontMatter = {}
154
239
  let markdown = content
@@ -162,7 +247,7 @@ async function processMarkdownFile(filePath, id, siteRoot) {
162
247
  }
163
248
  }
164
249
 
165
- const { type, component, preset, input, props, fetch, data, ...params } = frontMatter
250
+ const { type, component, preset, input, props, fetch, data, id: frontmatterId, ...params } = frontMatter
166
251
 
167
252
  // Convert markdown to ProseMirror
168
253
  const proseMirrorContent = markdownToProseMirror(markdown)
@@ -176,8 +261,13 @@ async function processMarkdownFile(filePath, id, siteRoot) {
176
261
  resolvedFetch = { collection: collectionName }
177
262
  }
178
263
 
264
+ // Stable ID for scroll targeting: frontmatter id > filename-derived > null
265
+ // This ID is stable across reordering (unlike the positional id)
266
+ const stableId = frontmatterId || defaultStableId || null
267
+
179
268
  const section = {
180
269
  id,
270
+ stableId,
181
271
  component: type || component || 'Section',
182
272
  preset,
183
273
  input,
@@ -290,7 +380,8 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
290
380
  }
291
381
 
292
382
  // Process the section
293
- const { section, assetCollection: sectionAssets } = await processMarkdownFile(filePath, id, siteRoot)
383
+ // Use sectionName as stable ID for scroll targeting (e.g., "hero", "features")
384
+ const { section, assetCollection: sectionAssets } = await processMarkdownFile(filePath, id, siteRoot, sectionName)
294
385
  assetCollection = mergeAssetCollections(assetCollection, sectionAssets)
295
386
 
296
387
  // Track last modified
@@ -326,9 +417,10 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
326
417
  * @param {boolean} options.isIndex - Whether this page is the index for its parent route
327
418
  * @param {string} options.parentRoute - The parent route (e.g., '/' or '/docs')
328
419
  * @param {Object} options.parentFetch - Parent page's fetch config (for dynamic routes)
420
+ * @param {Object} options.versionContext - Version context from parent { version, versionMeta, scope }
329
421
  * @returns {Object} Page data with assets manifest
330
422
  */
331
- async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null } = {}) {
423
+ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null } = {}) {
332
424
  const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
333
425
 
334
426
  // Note: We no longer skip hidden pages here - they still exist as valid pages,
@@ -354,10 +446,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
354
446
  const sections = []
355
447
  for (const file of mdFiles) {
356
448
  const { name } = parse(file)
357
- const { prefix } = parseNumericPrefix(name)
449
+ const { prefix, name: stableName } = parseNumericPrefix(name)
358
450
  const id = prefix || name
451
+ // Use the name part (after prefix) as stable ID for scroll targeting
452
+ // e.g., "1-intro.md" → stableId: "intro", "2-features.md" → stableId: "features"
453
+ const stableId = stableName || name
359
454
 
360
- const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot)
455
+ const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot, stableId)
361
456
  sections.push(section)
362
457
  pageAssetCollection = mergeAssetCollections(pageAssetCollection, assetCollection)
363
458
 
@@ -415,6 +510,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
415
510
  return {
416
511
  page: {
417
512
  route,
513
+ id: pageConfig.id || null, // Stable page ID for page: links (survives reorganization)
418
514
  isIndex, // Marks this page as the index for its parent route (accessible at parentRoute)
419
515
  title: pageConfig.title || pageName,
420
516
  description: pageConfig.description || '',
@@ -427,6 +523,11 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
427
523
  paramName, // e.g., "slug" from [slug]
428
524
  parentSchema, // e.g., "articles" - the data array to iterate over
429
525
 
526
+ // Version metadata (if within a versioned section)
527
+ version: versionContext?.version || null,
528
+ versionMeta: versionContext?.versionMeta || null,
529
+ versionScope: versionContext?.scope || null,
530
+
430
531
  // Navigation options
431
532
  hidden: pageConfig.hidden || false, // Hide from all navigation
432
533
  hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
@@ -504,9 +605,10 @@ function determineIndexPage(orderConfig, availableFolders) {
504
605
  * @param {string} siteRoot - Site root directory for asset resolution
505
606
  * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent's config
506
607
  * @param {Object} parentFetch - Parent page's fetch config (for dynamic child routes)
507
- * @returns {Promise<Object>} { pages, assetCollection, header, footer, left, right, notFound }
608
+ * @param {Object} versionContext - Version context from parent { version, versionMeta }
609
+ * @returns {Promise<Object>} { pages, assetCollection, header, footer, left, right, notFound, versionedScopes }
508
610
  */
509
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null) {
611
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null) {
510
612
  const entries = await readdir(dirPath)
511
613
  const pages = []
512
614
  let assetCollection = {
@@ -519,6 +621,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
519
621
  let left = null
520
622
  let right = null
521
623
  let notFound = null
624
+ const versionedScopes = new Map() // scope route → versionMeta
522
625
 
523
626
  // First pass: discover all page folders and read their order values
524
627
  const pageFolders = []
@@ -533,6 +636,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
533
636
  name: entry,
534
637
  path: entryPath,
535
638
  order: pageConfig.order,
639
+ pageConfig,
536
640
  childOrderConfig: {
537
641
  pages: pageConfig.pages,
538
642
  index: pageConfig.index
@@ -540,6 +644,77 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
540
644
  })
541
645
  }
542
646
 
647
+ // Check if this directory contains version folders (versioned section)
648
+ const folderNames = pageFolders.map(f => f.name)
649
+ const detectedVersions = detectVersions(folderNames)
650
+
651
+ // If versioned section, handle version folders specially
652
+ if (detectedVersions && !versionContext) {
653
+ // Read parent page.yml for version metadata
654
+ const parentConfig = await readYamlFile(join(dirPath, 'page.yml'))
655
+ const versionMeta = buildVersionMetadata(detectedVersions, parentConfig)
656
+
657
+ // Record this versioned scope
658
+ versionedScopes.set(parentRoute, versionMeta)
659
+
660
+ // Process version folders
661
+ for (const folder of pageFolders) {
662
+ const { name: entry, path: entryPath, childOrderConfig, pageConfig } = folder
663
+
664
+ if (isVersionFolder(entry)) {
665
+ // This is a version folder
666
+ const versionInfo = versionMeta.versions.find(v => v.id === entry)
667
+ const isLatest = versionInfo?.latest || false
668
+
669
+ // For latest version, use parent route directly
670
+ // For other versions, add version prefix to route
671
+ // Handle root scope specially to avoid double slash (//v1 → /v1)
672
+ const versionRoute = isLatest
673
+ ? parentRoute
674
+ : parentRoute === '/'
675
+ ? `/${entry}`
676
+ : `${parentRoute}/${entry}`
677
+
678
+ // Recurse into version folder with version context
679
+ const subResult = await collectPagesRecursive(
680
+ entryPath,
681
+ versionRoute,
682
+ siteRoot,
683
+ childOrderConfig,
684
+ parentFetch,
685
+ {
686
+ version: versionInfo,
687
+ versionMeta,
688
+ scope: parentRoute // The route where versioning is scoped
689
+ }
690
+ )
691
+
692
+ pages.push(...subResult.pages)
693
+ assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
694
+ // Merge any nested versioned scopes (shouldn't happen often, but possible)
695
+ for (const [scope, meta] of subResult.versionedScopes) {
696
+ versionedScopes.set(scope, meta)
697
+ }
698
+ } else if (!entry.startsWith('@')) {
699
+ // Non-version, non-special folders in a versioned section
700
+ // These could be shared across versions - process normally
701
+ const result = await processPage(entryPath, entry, siteRoot, {
702
+ isIndex: false,
703
+ parentRoute,
704
+ parentFetch
705
+ })
706
+
707
+ if (result) {
708
+ pages.push(result.page)
709
+ assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
710
+ }
711
+ }
712
+ }
713
+
714
+ // Return early - we've handled all children
715
+ return { pages, assetCollection, header, footer, left, right, notFound, versionedScopes }
716
+ }
717
+
543
718
  // Determine which page is the index for this level
544
719
  const regularFolders = pageFolders.filter(f => !f.name.startsWith('@'))
545
720
  const indexPageName = determineIndexPage(orderConfig, regularFolders)
@@ -555,7 +730,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
555
730
  const result = await processPage(entryPath, entry, siteRoot, {
556
731
  isIndex: isIndex && !isSpecial,
557
732
  parentRoute,
558
- parentFetch
733
+ parentFetch,
734
+ versionContext
559
735
  })
560
736
 
561
737
  if (result) {
@@ -587,14 +763,19 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
587
763
  const childParentRoute = isIndex ? parentRoute : page.route
588
764
  // Pass this page's fetch config to children (for dynamic routes that inherit parent data)
589
765
  const childFetch = page.fetch || parentFetch
590
- const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch)
766
+ // Pass version context to children (maintains version scope)
767
+ const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext)
591
768
  pages.push(...subResult.pages)
592
769
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
770
+ // Merge any versioned scopes from children
771
+ for (const [scope, meta] of subResult.versionedScopes) {
772
+ versionedScopes.set(scope, meta)
773
+ }
593
774
  }
594
775
  }
595
776
  }
596
777
 
597
- return { pages, assetCollection, header, footer, left, right, notFound }
778
+ return { pages, assetCollection, header, footer, left, right, notFound, versionedScopes }
598
779
  }
599
780
 
600
781
  /**
@@ -606,11 +787,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
606
787
  async function loadFoundationVars(foundationPath) {
607
788
  if (!foundationPath) return {}
608
789
 
609
- // Try dist/schema.json first (built foundation), then src/schema.json
610
- const distSchemaPath = join(foundationPath, 'dist', 'schema.json')
611
- const srcSchemaPath = join(foundationPath, 'schema.json')
790
+ // Try dist/meta/schema.json first (built foundation), then root schema.json
791
+ const distSchemaPath = join(foundationPath, 'dist', 'meta', 'schema.json')
792
+ const rootSchemaPath = join(foundationPath, 'schema.json')
612
793
 
613
- const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : srcSchemaPath
794
+ const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : rootSchemaPath
614
795
 
615
796
  if (!existsSync(schemaPath)) {
616
797
  return {}
@@ -672,7 +853,7 @@ export async function collectSiteContent(sitePath, options = {}) {
672
853
  }
673
854
 
674
855
  // Recursively collect all pages
675
- const { pages, assetCollection, header, footer, left, right, notFound } =
856
+ const { pages, assetCollection, header, footer, left, right, notFound, versionedScopes } =
676
857
  await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig)
677
858
 
678
859
  // Sort pages by order
@@ -685,6 +866,9 @@ export async function collectSiteContent(sitePath, options = {}) {
685
866
  console.log(`[content-collector] Found ${assetCount} asset references${explicitCount > 0 ? ` (${explicitCount} with explicit poster/preview)` : ''}`)
686
867
  }
687
868
 
869
+ // Convert versionedScopes Map to plain object for JSON serialization
870
+ const versionedScopesObj = Object.fromEntries(versionedScopes)
871
+
688
872
  return {
689
873
  config: {
690
874
  ...siteConfig,
@@ -700,6 +884,8 @@ export async function collectSiteContent(sitePath, options = {}) {
700
884
  left,
701
885
  right,
702
886
  notFound,
887
+ // Versioned scopes: route → { versions, latestId }
888
+ versionedScopes: versionedScopesObj,
703
889
  assets: assetCollection.assets,
704
890
  hasExplicitPoster: assetCollection.hasExplicitPoster,
705
891
  hasExplicitPreview: assetCollection.hasExplicitPreview
@@ -4,6 +4,11 @@
4
4
  * Generates 11 color shades (50-950) from a single base color using
5
5
  * the OKLCH color space for perceptually uniform results.
6
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
+ *
7
12
  * @module @uniweb/build/theme/shade-generator
8
13
  */
9
14
 
@@ -42,6 +47,31 @@ const CHROMA_SCALE = {
42
47
  950: 0.45, // Reduced chroma at dark end
43
48
  }
44
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
+
45
75
  /**
46
76
  * Parse a color string into OKLCH components
47
77
  * Supports: hex (#fff, #ffffff), rgb(), hsl(), oklch()
@@ -281,6 +311,80 @@ function oklchToRgb(l, c, h) {
281
311
  return { r, g, b }
282
312
  }
283
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
+
284
388
  /**
285
389
  * Format OKLCH values as CSS string
286
390
  *
@@ -316,62 +420,217 @@ export function formatHex(r, g, b) {
316
420
  * @param {string} color - Base color in any supported format
317
421
  * @param {Object} options - Options
318
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
319
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 })
320
438
  */
321
439
  export function generateShades(color, options = {}) {
322
- const { format = 'oklch' } = options
323
- const { l, c, h } = parseColor(color)
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
+ }
324
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) {
325
457
  const shades = {}
326
458
 
327
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
+
328
470
  const targetL = LIGHTNESS_MAP[level]
329
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
+ }
330
550
 
331
- // Scale chroma based on lightness to prevent clipping
332
- // Also consider the original chroma - low chroma colors stay low
333
- const targetC = c * chromaScale
551
+ // Normalize hue
552
+ targetH = normalizeHue(targetH)
553
+
554
+ // Gamut map to find maximum valid chroma
555
+ const safeC = findMaxChroma(targetL, targetH, targetC)
334
556
 
335
557
  if (format === 'hex') {
336
- const rgb = oklchToRgb(targetL, targetC, h)
558
+ const rgb = oklchToRgb(targetL, safeC, targetH)
337
559
  shades[level] = formatHex(rgb.r, rgb.g, rgb.b)
338
560
  } else {
339
- shades[level] = formatOklch(targetL, targetC, h)
561
+ shades[level] = formatOklch(targetL, safeC, targetH)
340
562
  }
341
563
  }
342
564
 
343
565
  return shades
344
566
  }
345
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
+
346
576
  /**
347
577
  * Generate shades for multiple colors
348
578
  *
349
- * @param {Object} colors - Object with color names as keys and color values
350
- * @param {Object} options - Options passed to generateShades
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
351
581
  * @returns {Object} Object with color names, each containing shade levels
352
582
  *
353
583
  * @example
584
+ * // Simple usage with defaults
354
585
  * generatePalettes({
355
586
  * primary: '#3b82f6',
356
587
  * secondary: '#64748b'
357
588
  * })
358
- * // Returns: { primary: { 50: '...', 100: '...', ... }, secondary: { ... } }
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
+ * })
359
597
  */
360
598
  export function generatePalettes(colors, options = {}) {
361
599
  const palettes = {}
362
600
 
363
- for (const [name, color] of Object.entries(colors)) {
364
- // Skip if color is already an object (pre-defined shades)
365
- if (typeof color === 'object' && color !== null) {
366
- palettes[name] = color
367
- } else {
368
- palettes[name] = generateShades(color, options)
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)
369
620
  }
370
621
  }
371
622
 
372
623
  return palettes
373
624
  }
374
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
+
375
634
  /**
376
635
  * Check if a color string is valid
377
636
  *
@@ -403,4 +662,5 @@ export default {
403
662
  generatePalettes,
404
663
  isValidColor,
405
664
  getShadeLevels,
665
+ getAvailableModes,
406
666
  }