shru-design-system 0.1.5 → 0.1.8
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 +96 -166
- package/dist/index.d.mts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +232 -104
- package/dist/index.mjs +222 -105
- package/package.json +7 -1
- package/scripts/apply-theme-sync.js +59 -23
- package/scripts/applyThemeSync.js +285 -0
- package/scripts/init.js +192 -367
- package/scripts/themeConfig.js +214 -0
- package/scripts/themeUtils.js +452 -0
- package/scripts/tokens/base.json +46 -0
- package/scripts/tokens/palettes.json +47 -0
- package/scripts/tokens/themes/animation/brisk.json +10 -0
- package/scripts/tokens/themes/animation/gentle.json +10 -0
- package/scripts/tokens/themes/color/dark.json +25 -0
- package/scripts/tokens/themes/color/white.json +25 -0
- package/scripts/tokens/themes/custom/brand.json +14 -0
- package/scripts/tokens/themes/custom/minimal.json +17 -0
- package/scripts/tokens/themes/density/comfortable.json +12 -0
- package/scripts/tokens/themes/density/compact.json +12 -0
- package/scripts/tokens/themes/shape/sharp.json +8 -0
- package/scripts/tokens/themes/shape/smooth.json +8 -0
- package/scripts/tokens/themes/typography/sans.json +7 -0
- package/scripts/tokens/themes/typography/serif.json +7 -0
- package/dist/index.js.map +0 -1
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Configuration
|
|
3
|
+
* Registry of all available themes organized by category
|
|
4
|
+
*
|
|
5
|
+
* Base themes are defined here. Additional themes can be discovered dynamically
|
|
6
|
+
* by scanning the /tokens/themes/ directory structure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Centralized theme category order
|
|
11
|
+
* Used everywhere to ensure consistency
|
|
12
|
+
* This is the SINGLE SOURCE OF TRUTH for category order
|
|
13
|
+
* Custom category is included but handled specially (optional, user-created files)
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ IF YOU UPDATE THIS, ALSO UPDATE:
|
|
16
|
+
* 1. src/themes/themeConfig.ts - THEME_CATEGORY_ORDER (TypeScript source)
|
|
17
|
+
* 2. scripts/apply-theme-sync.js - THEME_CATEGORY_ORDER constant (standalone script, can't import)
|
|
18
|
+
*/
|
|
19
|
+
export const THEME_CATEGORY_ORDER = ['color', 'typography', 'shape', 'density', 'animation', 'custom'];
|
|
20
|
+
|
|
21
|
+
// Base theme categories (always available)
|
|
22
|
+
export const baseThemeCategories = {
|
|
23
|
+
color: {
|
|
24
|
+
name: 'Color',
|
|
25
|
+
order: 1.0,
|
|
26
|
+
themes: {
|
|
27
|
+
white: {
|
|
28
|
+
name: 'White',
|
|
29
|
+
file: 'color/white.json',
|
|
30
|
+
icon: '🎨',
|
|
31
|
+
description: 'Light theme with white background'
|
|
32
|
+
},
|
|
33
|
+
dark: {
|
|
34
|
+
name: 'Dark',
|
|
35
|
+
file: 'color/dark.json',
|
|
36
|
+
icon: '🌙',
|
|
37
|
+
description: 'Dark theme with dark background'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
typography: {
|
|
42
|
+
name: 'Typography',
|
|
43
|
+
order: 2.0,
|
|
44
|
+
themes: {
|
|
45
|
+
sans: {
|
|
46
|
+
name: 'Sans',
|
|
47
|
+
file: 'typography/sans.json',
|
|
48
|
+
icon: '📝',
|
|
49
|
+
description: 'Sans-serif font family'
|
|
50
|
+
},
|
|
51
|
+
serif: {
|
|
52
|
+
name: 'Serif',
|
|
53
|
+
file: 'typography/serif.json',
|
|
54
|
+
icon: '📖',
|
|
55
|
+
description: 'Serif font family'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
shape: {
|
|
60
|
+
name: 'Shape',
|
|
61
|
+
order: 3.0,
|
|
62
|
+
themes: {
|
|
63
|
+
smooth: {
|
|
64
|
+
name: 'Smooth',
|
|
65
|
+
file: 'shape/smooth.json',
|
|
66
|
+
icon: '🔲',
|
|
67
|
+
description: 'Smooth rounded corners'
|
|
68
|
+
},
|
|
69
|
+
sharp: {
|
|
70
|
+
name: 'Sharp',
|
|
71
|
+
file: 'shape/sharp.json',
|
|
72
|
+
icon: '⬜',
|
|
73
|
+
description: 'Sharp square corners'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
density: {
|
|
78
|
+
name: 'Density',
|
|
79
|
+
order: 4.0,
|
|
80
|
+
themes: {
|
|
81
|
+
comfortable: {
|
|
82
|
+
name: 'Comfortable',
|
|
83
|
+
file: 'density/comfortable.json',
|
|
84
|
+
icon: '📏',
|
|
85
|
+
description: 'Comfortable spacing'
|
|
86
|
+
},
|
|
87
|
+
compact: {
|
|
88
|
+
name: 'Compact',
|
|
89
|
+
file: 'density/compact.json',
|
|
90
|
+
icon: '📐',
|
|
91
|
+
description: 'Compact spacing'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
animation: {
|
|
96
|
+
name: 'Animation',
|
|
97
|
+
order: 5.0,
|
|
98
|
+
themes: {
|
|
99
|
+
gentle: {
|
|
100
|
+
name: 'Gentle',
|
|
101
|
+
file: 'animation/gentle.json',
|
|
102
|
+
icon: '✨',
|
|
103
|
+
description: 'Gentle animations'
|
|
104
|
+
},
|
|
105
|
+
brisk: {
|
|
106
|
+
name: 'Brisk',
|
|
107
|
+
file: 'animation/brisk.json',
|
|
108
|
+
icon: '⚡',
|
|
109
|
+
description: 'Fast, brisk animations'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
// Custom themes are not included in base config
|
|
114
|
+
// They should be discovered dynamically or registered by users
|
|
115
|
+
// Users can add custom themes by creating files in /tokens/themes/custom/
|
|
116
|
+
// and registering them using registerTheme()
|
|
117
|
+
};
|
|
118
|
+
// Cache for dynamically discovered themes
|
|
119
|
+
let discoveredThemesCache = null;
|
|
120
|
+
/**
|
|
121
|
+
* Discover themes by scanning token directory structure
|
|
122
|
+
* Scans /tokens/themes/ to find all available theme files
|
|
123
|
+
*/
|
|
124
|
+
export async function discoverThemes() {
|
|
125
|
+
if (discoveredThemesCache) {
|
|
126
|
+
return discoveredThemesCache;
|
|
127
|
+
}
|
|
128
|
+
const discovered = JSON.parse(JSON.stringify(baseThemeCategories));
|
|
129
|
+
try {
|
|
130
|
+
// Get base path for tokens
|
|
131
|
+
const tokensBase = typeof window !== 'undefined' && window.__THEME_TOKENS_BASE__
|
|
132
|
+
? window.__THEME_TOKENS_BASE__
|
|
133
|
+
: '/tokens';
|
|
134
|
+
// Known categories from base config
|
|
135
|
+
const knownCategories = Object.keys(baseThemeCategories);
|
|
136
|
+
// For each category, try to discover additional themes
|
|
137
|
+
for (const category of knownCategories) {
|
|
138
|
+
const categoryPath = `${tokensBase}/themes/${category}`;
|
|
139
|
+
// Try to fetch an index or scan common theme files
|
|
140
|
+
// Since we can't list directories via fetch, we'll try common patterns
|
|
141
|
+
// Users can add themes by following the naming convention
|
|
142
|
+
// For now, we'll rely on users to add themes to the config
|
|
143
|
+
// But we can validate that theme files exist when requested
|
|
144
|
+
}
|
|
145
|
+
discoveredThemesCache = discovered;
|
|
146
|
+
return discovered;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Only log in debug mode
|
|
150
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
151
|
+
console.warn('Error discovering themes:', error);
|
|
152
|
+
}
|
|
153
|
+
return baseThemeCategories;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Register a custom theme dynamically
|
|
158
|
+
* Allows users to add themes without modifying the base config
|
|
159
|
+
*/
|
|
160
|
+
export function registerTheme(category, themeId, metadata) {
|
|
161
|
+
if (!discoveredThemesCache) {
|
|
162
|
+
discoveredThemesCache = JSON.parse(JSON.stringify(baseThemeCategories));
|
|
163
|
+
}
|
|
164
|
+
// TypeScript now knows discoveredThemesCache is not null after the check above
|
|
165
|
+
const cache = discoveredThemesCache;
|
|
166
|
+
// Create category if it doesn't exist
|
|
167
|
+
if (!cache[category]) {
|
|
168
|
+
cache[category] = {
|
|
169
|
+
name: category.charAt(0).toUpperCase() + category.slice(1),
|
|
170
|
+
order: 99, // Custom categories get high order
|
|
171
|
+
themes: {}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// Register the theme
|
|
175
|
+
cache[category].themes[themeId] = {
|
|
176
|
+
name: metadata.name,
|
|
177
|
+
file: metadata.file,
|
|
178
|
+
icon: metadata.icon || '🎨',
|
|
179
|
+
description: metadata.description || ''
|
|
180
|
+
};
|
|
181
|
+
return cache;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Get merged theme categories (base + discovered)
|
|
185
|
+
*/
|
|
186
|
+
export async function getThemeCategories() {
|
|
187
|
+
return await discoverThemes();
|
|
188
|
+
}
|
|
189
|
+
// For backward compatibility, export baseThemeCategories as themeCategories
|
|
190
|
+
export const themeCategories = baseThemeCategories;
|
|
191
|
+
/**
|
|
192
|
+
* Get theme file path
|
|
193
|
+
*/
|
|
194
|
+
export function getThemeFilePath(category, themeId) {
|
|
195
|
+
const categories = discoveredThemesCache || baseThemeCategories;
|
|
196
|
+
const theme = categories[category]?.themes[themeId];
|
|
197
|
+
if (!theme)
|
|
198
|
+
return null;
|
|
199
|
+
return `/tokens/themes/${theme.file}`;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Get all themes for a category
|
|
203
|
+
*/
|
|
204
|
+
export async function getThemesForCategory(category) {
|
|
205
|
+
const categories = await getThemeCategories();
|
|
206
|
+
return categories[category]?.themes || {};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Get theme by ID
|
|
210
|
+
*/
|
|
211
|
+
export async function getTheme(category, themeId) {
|
|
212
|
+
const categories = await getThemeCategories();
|
|
213
|
+
return categories[category]?.themes[themeId] || null;
|
|
214
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Utilities
|
|
3
|
+
* Pure utility functions for theme management
|
|
4
|
+
* Note: generateAndApplyTheme has side effects (modifies DOM) but is the main theme application utility
|
|
5
|
+
*/
|
|
6
|
+
import { getThemeCategories, THEME_CATEGORY_ORDER } from './themeConfig';
|
|
7
|
+
/**
|
|
8
|
+
* Generate theme combination name
|
|
9
|
+
*/
|
|
10
|
+
export function getThemeName(selectedThemes) {
|
|
11
|
+
const parts = [];
|
|
12
|
+
if (selectedThemes.color)
|
|
13
|
+
parts.push(selectedThemes.color);
|
|
14
|
+
if (selectedThemes.typography)
|
|
15
|
+
parts.push(selectedThemes.typography);
|
|
16
|
+
if (selectedThemes.shape)
|
|
17
|
+
parts.push(selectedThemes.shape);
|
|
18
|
+
if (selectedThemes.density)
|
|
19
|
+
parts.push(selectedThemes.density);
|
|
20
|
+
if (selectedThemes.animation)
|
|
21
|
+
parts.push(selectedThemes.animation);
|
|
22
|
+
return parts.length > 0 ? parts.join('-') : 'default';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validate theme selection
|
|
26
|
+
*/
|
|
27
|
+
export function validateThemeSelection(selectedThemes, themeCategories) {
|
|
28
|
+
const errors = [];
|
|
29
|
+
for (const [category, themeId] of Object.entries(selectedThemes)) {
|
|
30
|
+
if (!themeId)
|
|
31
|
+
continue;
|
|
32
|
+
const categoryConfig = themeCategories[category];
|
|
33
|
+
if (!categoryConfig) {
|
|
34
|
+
errors.push(`Unknown category: ${category}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const theme = categoryConfig.themes[themeId];
|
|
38
|
+
if (!theme) {
|
|
39
|
+
errors.push(`Theme ${themeId} not found in category ${category}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
valid: errors.length === 0,
|
|
44
|
+
errors
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get default theme selections
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ IF YOU UPDATE THIS, ALSO UPDATE:
|
|
51
|
+
* 1. src/themes/themeUtils.ts - getDefaultThemes() function (TypeScript source)
|
|
52
|
+
* 2. scripts/apply-theme-sync.js - DEFAULT_THEMES constant (standalone script, can't import)
|
|
53
|
+
*/
|
|
54
|
+
export function getDefaultThemes() {
|
|
55
|
+
return {
|
|
56
|
+
color: 'white',
|
|
57
|
+
typography: 'sans',
|
|
58
|
+
shape: 'smooth',
|
|
59
|
+
density: 'comfortable',
|
|
60
|
+
animation: 'gentle'
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Deep clone object
|
|
65
|
+
*/
|
|
66
|
+
export function deepClone(obj) {
|
|
67
|
+
return JSON.parse(JSON.stringify(obj));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check if value is an object
|
|
71
|
+
*/
|
|
72
|
+
function isObject(item) {
|
|
73
|
+
return item && typeof item === 'object' && !Array.isArray(item);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Deep merge objects
|
|
77
|
+
*/
|
|
78
|
+
export function deepMerge(target, source) {
|
|
79
|
+
const output = { ...target };
|
|
80
|
+
if (isObject(target) && isObject(source)) {
|
|
81
|
+
Object.keys(source).forEach(key => {
|
|
82
|
+
if (isObject(source[key])) {
|
|
83
|
+
if (!(key in target)) {
|
|
84
|
+
Object.assign(output, { [key]: source[key] });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
Object.assign(output, { [key]: source[key] });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return output;
|
|
96
|
+
}
|
|
97
|
+
// Cache for loaded JSON files
|
|
98
|
+
const tokenCache = new Map();
|
|
99
|
+
/**
|
|
100
|
+
* Load JSON file with caching
|
|
101
|
+
*/
|
|
102
|
+
export async function loadTokenFile(path) {
|
|
103
|
+
if (tokenCache.has(path)) {
|
|
104
|
+
return deepClone(tokenCache.get(path));
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(path);
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
// 404 means file doesn't exist - return null instead of throwing
|
|
110
|
+
if (response.status === 404) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`Failed to load ${path}: ${response.statusText}`);
|
|
114
|
+
}
|
|
115
|
+
const contentType = response.headers.get('content-type');
|
|
116
|
+
// Check if response is actually JSON (not HTML error page)
|
|
117
|
+
if (!contentType || !contentType.includes('application/json')) {
|
|
118
|
+
// Likely got HTML error page instead of JSON
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const data = await response.json();
|
|
122
|
+
tokenCache.set(path, data);
|
|
123
|
+
return deepClone(data);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// Only log errors in debug mode
|
|
127
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
128
|
+
console.warn(`Error loading token file ${path}:`, error);
|
|
129
|
+
}
|
|
130
|
+
// Return null instead of throwing - allows theme to continue with other files
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve token references
|
|
136
|
+
* {palette.blue.500} → actual color value
|
|
137
|
+
*/
|
|
138
|
+
function resolveReferences(tokens, palette) {
|
|
139
|
+
const resolved = JSON.parse(JSON.stringify(tokens));
|
|
140
|
+
function resolveValue(value) {
|
|
141
|
+
if (typeof value !== 'string')
|
|
142
|
+
return value;
|
|
143
|
+
// Match {path.to.token} pattern
|
|
144
|
+
const match = value.match(/^\{([^}]+)\}$/);
|
|
145
|
+
if (!match)
|
|
146
|
+
return value;
|
|
147
|
+
const path = match[1].split('.');
|
|
148
|
+
let current = { palette, ...resolved };
|
|
149
|
+
for (const key of path) {
|
|
150
|
+
if (current && typeof current === 'object' && key in current) {
|
|
151
|
+
current = current[key];
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Token reference not found - return original value
|
|
155
|
+
// Only warn in debug mode
|
|
156
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
157
|
+
console.warn(`Token reference not found: {${match[1]}}`);
|
|
158
|
+
}
|
|
159
|
+
return value; // Return original if not found
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return typeof current === 'string' ? current : value;
|
|
163
|
+
}
|
|
164
|
+
function traverse(obj) {
|
|
165
|
+
for (const key in obj) {
|
|
166
|
+
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
167
|
+
traverse(obj[key]);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
obj[key] = resolveValue(obj[key]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
traverse(resolved);
|
|
175
|
+
return resolved;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Convert hex color to HSL format (without hsl() wrapper)
|
|
179
|
+
* Returns format: "h s% l%" for use with hsl(var(--color))
|
|
180
|
+
*/
|
|
181
|
+
function hexToHSL(hex) {
|
|
182
|
+
// Remove # if present
|
|
183
|
+
hex = hex.replace('#', '');
|
|
184
|
+
// Parse RGB
|
|
185
|
+
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
|
186
|
+
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
|
187
|
+
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
|
188
|
+
const max = Math.max(r, g, b);
|
|
189
|
+
const min = Math.min(r, g, b);
|
|
190
|
+
let h = 0;
|
|
191
|
+
let s = 0;
|
|
192
|
+
const l = (max + min) / 2;
|
|
193
|
+
if (max !== min) {
|
|
194
|
+
const d = max - min;
|
|
195
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
196
|
+
switch (max) {
|
|
197
|
+
case r:
|
|
198
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
199
|
+
break;
|
|
200
|
+
case g:
|
|
201
|
+
h = ((b - r) / d + 2) / 6;
|
|
202
|
+
break;
|
|
203
|
+
case b:
|
|
204
|
+
h = ((r - g) / d + 4) / 6;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
h = Math.round(h * 360);
|
|
209
|
+
s = Math.round(s * 100);
|
|
210
|
+
const lPercent = Math.round(l * 100);
|
|
211
|
+
return `${h} ${s}% ${lPercent}%`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Check if a string is a hex color
|
|
215
|
+
*/
|
|
216
|
+
function isHexColor(value) {
|
|
217
|
+
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Flatten nested object to CSS variables
|
|
221
|
+
* Maps token structure to CSS variable naming:
|
|
222
|
+
* - color.primary → --primary (semantic colors are flat)
|
|
223
|
+
* - font.body → --font-body
|
|
224
|
+
* - radius.button → --radius-button
|
|
225
|
+
* - font.body → --font-body
|
|
226
|
+
* - spacing.component.md → --spacing-component-md
|
|
227
|
+
*/
|
|
228
|
+
function flattenToCSS(tokens, prefix = '', result = {}, isColorContext = false) {
|
|
229
|
+
for (const key in tokens) {
|
|
230
|
+
const value = tokens[key];
|
|
231
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
232
|
+
// Token files are already in correct structure (no nested typography/shape wrappers)
|
|
233
|
+
const enteringColor = key === 'color' && prefix === '';
|
|
234
|
+
const inColorContext = isColorContext || enteringColor;
|
|
235
|
+
if (enteringColor) {
|
|
236
|
+
// When entering color object, flatten without "color-" prefix
|
|
237
|
+
flattenToCSS(value, '', result, true);
|
|
238
|
+
}
|
|
239
|
+
else if (inColorContext) {
|
|
240
|
+
// Already in color context, continue with empty prefix
|
|
241
|
+
flattenToCSS(value, '', result, true);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
// For all other tokens, use simple prefix-based flattening
|
|
245
|
+
// font.body → --font-body, radius.button → --radius-button, spacing.component.md → --spacing-component-md
|
|
246
|
+
const newPrefix = prefix ? `${prefix}-${key}` : key;
|
|
247
|
+
flattenToCSS(value, newPrefix, result, false);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Generate CSS variable name
|
|
252
|
+
let cssKey;
|
|
253
|
+
if (isColorContext || (prefix === '' && key === 'color')) {
|
|
254
|
+
// Semantic colors: --primary, --background, etc. (no "color-" prefix)
|
|
255
|
+
cssKey = `--${key}`;
|
|
256
|
+
}
|
|
257
|
+
else if (prefix === '') {
|
|
258
|
+
// Top-level tokens (non-color)
|
|
259
|
+
cssKey = `--${key}`;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// Nested tokens: --font-body, --radius-button, --spacing-component-md, etc.
|
|
263
|
+
cssKey = `--${prefix}-${key}`;
|
|
264
|
+
}
|
|
265
|
+
// Convert hex colors to HSL format for Tailwind compatibility
|
|
266
|
+
let finalValue = value;
|
|
267
|
+
if (isColorContext && typeof value === 'string' && isHexColor(value)) {
|
|
268
|
+
finalValue = hexToHSL(value);
|
|
269
|
+
}
|
|
270
|
+
result[cssKey] = finalValue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Map theme variables to Tailwind-compatible CSS variables
|
|
277
|
+
* This function automatically passes through ALL CSS variables
|
|
278
|
+
* and only adds convenience mappings for Tailwind-specific needs
|
|
279
|
+
*/
|
|
280
|
+
function mapToTailwindVars(cssVars) {
|
|
281
|
+
// Start with all generated variables - they're all valid CSS variables
|
|
282
|
+
const mapped = { ...cssVars };
|
|
283
|
+
// Only add convenience mappings for Tailwind's expected variable names
|
|
284
|
+
// These are optional - the actual variables are already in the map
|
|
285
|
+
// Map radius-button to --radius for Tailwind's rounded-* utilities
|
|
286
|
+
if (cssVars['--radius-button'] && !cssVars['--radius']) {
|
|
287
|
+
mapped['--radius'] = cssVars['--radius-button'];
|
|
288
|
+
}
|
|
289
|
+
// Map radius-card to --radius-lg for larger rounded corners
|
|
290
|
+
if (cssVars['--radius-card'] && !cssVars['--radius-lg']) {
|
|
291
|
+
mapped['--radius-lg'] = cssVars['--radius-card'];
|
|
292
|
+
}
|
|
293
|
+
// Map font-body to --font-sans for Tailwind's font-sans utility
|
|
294
|
+
if (cssVars['--font-body'] && !cssVars['--font-sans']) {
|
|
295
|
+
mapped['--font-sans'] = cssVars['--font-body'];
|
|
296
|
+
}
|
|
297
|
+
// Map spacing-base or spacing-component-md to --spacing for convenience
|
|
298
|
+
if (cssVars['--spacing-base'] && !cssVars['--spacing']) {
|
|
299
|
+
mapped['--spacing'] = cssVars['--spacing-base'];
|
|
300
|
+
}
|
|
301
|
+
else if (cssVars['--spacing-component-md'] && !cssVars['--spacing']) {
|
|
302
|
+
mapped['--spacing'] = cssVars['--spacing-component-md'];
|
|
303
|
+
}
|
|
304
|
+
return mapped;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Generate CSS string from CSS variables
|
|
308
|
+
*/
|
|
309
|
+
function generateCSSString(cssVars) {
|
|
310
|
+
const mappedVars = mapToTailwindVars(cssVars);
|
|
311
|
+
const vars = Object.entries(mappedVars)
|
|
312
|
+
.sort(([a], [b]) => a.localeCompare(b)) // Sort alphabetically for easier debugging
|
|
313
|
+
.map(([key, value]) => ` ${key}: ${value};`)
|
|
314
|
+
.join('\n');
|
|
315
|
+
// Debug mode: log all CSS variables in development
|
|
316
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
317
|
+
console.group('🎨 Design System CSS Variables');
|
|
318
|
+
console.table(mappedVars);
|
|
319
|
+
console.log('Total variables:', Object.keys(mappedVars).length);
|
|
320
|
+
console.groupEnd();
|
|
321
|
+
}
|
|
322
|
+
return `:root {\n${vars}\n}`;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Apply CSS to DOM
|
|
326
|
+
*/
|
|
327
|
+
function applyThemeCSS(css) {
|
|
328
|
+
let styleTag = document.getElementById('dynamic-theme');
|
|
329
|
+
if (!styleTag) {
|
|
330
|
+
styleTag = document.createElement('style');
|
|
331
|
+
styleTag.id = 'dynamic-theme';
|
|
332
|
+
document.head.appendChild(styleTag);
|
|
333
|
+
}
|
|
334
|
+
styleTag.textContent = css;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Debug helper: Enable debug mode to see all CSS variables in console
|
|
338
|
+
* Call this in browser console: window.__DESIGN_SYSTEM_DEBUG__ = true
|
|
339
|
+
*/
|
|
340
|
+
export function enableDebugMode() {
|
|
341
|
+
if (typeof window !== 'undefined') {
|
|
342
|
+
window.__DESIGN_SYSTEM_DEBUG__ = true;
|
|
343
|
+
console.log('🔍 Design System debug mode enabled');
|
|
344
|
+
console.log('CSS variables will be logged when themes change');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Debug helper: Get all current CSS variables
|
|
349
|
+
*/
|
|
350
|
+
export function getCurrentCSSVariables() {
|
|
351
|
+
if (typeof window === 'undefined')
|
|
352
|
+
return {};
|
|
353
|
+
const styleTag = document.getElementById('dynamic-theme');
|
|
354
|
+
if (!styleTag)
|
|
355
|
+
return {};
|
|
356
|
+
const cssText = styleTag.textContent || '';
|
|
357
|
+
const vars = {};
|
|
358
|
+
const matches = cssText.matchAll(/--([^:]+):\s*([^;]+);/g);
|
|
359
|
+
for (const match of matches) {
|
|
360
|
+
vars[`--${match[1].trim()}`] = match[2].trim();
|
|
361
|
+
}
|
|
362
|
+
return vars;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Generate and apply theme
|
|
366
|
+
* Main function that composes everything
|
|
367
|
+
*/
|
|
368
|
+
export async function generateAndApplyTheme(selectedThemes = {}) {
|
|
369
|
+
try {
|
|
370
|
+
// Get theme categories (with dynamic discovery)
|
|
371
|
+
const themeCategories = await getThemeCategories();
|
|
372
|
+
// Validate theme selection (but be lenient with custom themes - they might not exist yet)
|
|
373
|
+
const validation = validateThemeSelection(selectedThemes, themeCategories);
|
|
374
|
+
if (!validation.valid) {
|
|
375
|
+
// Filter out custom theme errors - custom themes are optional
|
|
376
|
+
const nonCustomErrors = validation.errors.filter(err => !err.includes('custom'));
|
|
377
|
+
if (nonCustomErrors.length > 0) {
|
|
378
|
+
// Only log in debug mode
|
|
379
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
380
|
+
console.error('Invalid theme selection:', nonCustomErrors);
|
|
381
|
+
}
|
|
382
|
+
throw new Error(`Invalid theme selection: ${nonCustomErrors.join(', ')}`);
|
|
383
|
+
}
|
|
384
|
+
// If only custom theme errors, just warn and continue
|
|
385
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
386
|
+
console.warn('Custom theme files not found, continuing without them:', validation.errors);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// 1. Load base tokens
|
|
390
|
+
const base = await loadTokenFile('/tokens/base.json');
|
|
391
|
+
if (!base) {
|
|
392
|
+
throw new Error('Failed to load base tokens from /tokens/base.json');
|
|
393
|
+
}
|
|
394
|
+
// 2. Load palette
|
|
395
|
+
const palettes = await loadTokenFile('/tokens/palettes.json');
|
|
396
|
+
if (!palettes || !palettes.palette) {
|
|
397
|
+
throw new Error('Failed to load palette from /tokens/palettes.json');
|
|
398
|
+
}
|
|
399
|
+
const palette = palettes.palette;
|
|
400
|
+
// 3. Deep merge: base → palette
|
|
401
|
+
let merged = deepMerge(base, { palette });
|
|
402
|
+
// 4. Load and merge category themes in order (includes custom, use centralized order from themeConfig.js)
|
|
403
|
+
for (const category of THEME_CATEGORY_ORDER) {
|
|
404
|
+
const themeId = selectedThemes[category];
|
|
405
|
+
if (!themeId)
|
|
406
|
+
continue;
|
|
407
|
+
const themePath = `/tokens/themes/${category}/${themeId}.json`;
|
|
408
|
+
const themeData = await loadTokenFile(themePath);
|
|
409
|
+
// Only merge if theme data was successfully loaded
|
|
410
|
+
if (themeData) {
|
|
411
|
+
merged = deepMerge(merged, themeData);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// For custom themes, silently skip if not found (user-created, optional)
|
|
415
|
+
// For other categories, warn in debug mode
|
|
416
|
+
if (category !== 'custom' && typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
417
|
+
console.warn(`Theme file not found: ${themePath}`);
|
|
418
|
+
}
|
|
419
|
+
else if (category === 'custom' && typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
420
|
+
console.warn(`Custom theme file not found: ${themePath} (this is normal if you haven't created it yet)`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// 6. Resolve references
|
|
425
|
+
const resolved = resolveReferences(merged, palette);
|
|
426
|
+
// 7. Flatten to CSS variables
|
|
427
|
+
const cssVars = flattenToCSS(resolved);
|
|
428
|
+
// 8. Generate CSS string
|
|
429
|
+
const css = generateCSSString(cssVars);
|
|
430
|
+
// 9. Apply to DOM
|
|
431
|
+
if (typeof document !== 'undefined') {
|
|
432
|
+
applyThemeCSS(css);
|
|
433
|
+
// Debug: expose CSS variables to window for inspection
|
|
434
|
+
if (window.__DESIGN_SYSTEM_DEBUG__) {
|
|
435
|
+
window.__DESIGN_SYSTEM_VARS__ = cssVars;
|
|
436
|
+
console.log('💡 Access CSS variables via: window.__DESIGN_SYSTEM_VARS__');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
success: true,
|
|
441
|
+
themeName: getThemeName(selectedThemes),
|
|
442
|
+
cssVars
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
// Only log in debug mode
|
|
447
|
+
if (typeof window !== 'undefined' && window.__DESIGN_SYSTEM_DEBUG__) {
|
|
448
|
+
console.error('Error generating theme:', error);
|
|
449
|
+
}
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
}
|