omgkit 2.29.0 → 2.31.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/lib/theme.js CHANGED
@@ -9,6 +9,15 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from
9
9
  import { join, dirname } from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
 
12
+ // V2 imports (lazy-loaded to avoid circular deps)
13
+ let themeV2Module = null;
14
+ async function getThemeV2Module() {
15
+ if (!themeV2Module) {
16
+ themeV2Module = await import('./theme-v2.js');
17
+ }
18
+ return themeV2Module;
19
+ }
20
+
12
21
  // Package root detection
13
22
  let PACKAGE_ROOT;
14
23
 
@@ -89,6 +98,57 @@ export const OPTIONAL_COLORS = [
89
98
  'sidebar-border', 'sidebar-ring'
90
99
  ];
91
100
 
101
+ /**
102
+ * V2 extended tokens (new in v2 schema)
103
+ */
104
+ export const V2_EXTENDED_TOKENS = [
105
+ 'surface', 'surface-hover', 'surface-active',
106
+ 'primary-hover', 'secondary-hover', 'accent-hover',
107
+ 'border-hover', 'input-hover',
108
+ 'ring-offset', 'panel', 'panel-translucent', 'overlay'
109
+ ];
110
+
111
+ /**
112
+ * V2 status colors
113
+ */
114
+ export const V2_STATUS_COLORS = [
115
+ 'success', 'success-foreground',
116
+ 'warning', 'warning-foreground',
117
+ 'info', 'info-foreground'
118
+ ];
119
+
120
+ /**
121
+ * Detect theme schema version
122
+ * @param {Object} theme - Theme object
123
+ * @returns {'1.0' | '2.0'} Theme version
124
+ */
125
+ export function detectThemeVersion(theme) {
126
+ if (!theme) return '1.0';
127
+
128
+ // Explicit version check
129
+ if (theme.version === '2.0' || theme.version === 2) return '2.0';
130
+
131
+ // V2 indicators (any of these marks it as v2)
132
+ if (theme.scales) return '2.0';
133
+ if (theme.semanticTokens) return '2.0';
134
+ if (theme.effects) return '2.0';
135
+ if (theme.animations) return '2.0';
136
+ if (theme.colorSystem) return '2.0';
137
+ if (theme.statusColors) return '2.0';
138
+
139
+ // Default to v1
140
+ return '1.0';
141
+ }
142
+
143
+ /**
144
+ * Check if theme is v2 format
145
+ * @param {Object} theme - Theme object
146
+ * @returns {boolean} True if v2 theme
147
+ */
148
+ export function isV2Theme(theme) {
149
+ return detectThemeVersion(theme) === '2.0';
150
+ }
151
+
92
152
  /**
93
153
  * Load all available themes from templates/design/themes
94
154
  * @returns {Object} Themes grouped by category
@@ -203,11 +263,13 @@ export function validateTheme(theme) {
203
263
  }
204
264
 
205
265
  /**
206
- * Generate CSS variables from theme
266
+ * Generate CSS variables from theme (sync version, v1 only)
207
267
  * @param {Object} theme - Theme object
208
268
  * @returns {string} CSS content with variables
209
269
  */
210
270
  export function generateThemeCSS(theme) {
271
+ // Check if v2 theme - if so, use simple generation for compatibility
272
+ // For full v2 features, use generateThemeCSSAsync or processTheme from theme-v2.js
211
273
  const generateColorVars = (colors) => {
212
274
  let css = '';
213
275
  for (const [key, value] of Object.entries(colors)) {
@@ -216,17 +278,24 @@ export function generateThemeCSS(theme) {
216
278
  return css;
217
279
  };
218
280
 
219
- const lightVars = generateColorVars(theme.colors.light);
220
- const darkVars = generateColorVars(theme.colors.dark);
281
+ // Handle v2 themes with semanticTokens
282
+ const lightColors = theme.colors?.light || theme.semanticTokens?.light || {};
283
+ const darkColors = theme.colors?.dark || theme.semanticTokens?.dark || {};
284
+
285
+ const lightVars = generateColorVars(lightColors);
286
+ const darkVars = generateColorVars(darkColors);
287
+
288
+ const version = isV2Theme(theme) ? '2.0' : '1.0';
221
289
 
222
290
  return `/* OMGKIT Theme: ${theme.name} */
223
291
  /* Theme ID: ${theme.id} */
224
292
  /* Category: ${theme.category} */
293
+ /* Version: ${version} */
225
294
  /* Generated by OMGKIT Design System */
226
295
 
227
296
  @layer base {
228
297
  :root {
229
- ${lightVars} --radius: ${theme.radius || '0.5rem'};
298
+ ${lightVars} --radius: ${theme.spacing?.radius || theme.radius || '0.5rem'};
230
299
  }
231
300
 
232
301
  .dark {
@@ -244,6 +313,30 @@ ${darkVars} }
244
313
  `;
245
314
  }
246
315
 
316
+ /**
317
+ * Generate CSS variables from theme (async version, full v2 support)
318
+ * @param {Object} theme - Theme object (v1 or v2)
319
+ * @returns {Promise<string>} CSS content with all v2 features
320
+ */
321
+ export async function generateThemeCSSAsync(theme) {
322
+ if (isV2Theme(theme)) {
323
+ const v2 = await getThemeV2Module();
324
+ return v2.generateV2ThemeCSS(theme);
325
+ }
326
+ return generateThemeCSS(theme);
327
+ }
328
+
329
+ /**
330
+ * Process theme with unified processor (async)
331
+ * @param {Object} theme - Theme object (v1 or v2)
332
+ * @param {Object} options - Processing options
333
+ * @returns {Promise<Object>} Processed theme result
334
+ */
335
+ export async function processThemeUnified(theme, options = {}) {
336
+ const v2 = await getThemeV2Module();
337
+ return v2.processTheme(theme, options);
338
+ }
339
+
247
340
  /**
248
341
  * Generate components.json for shadcn
249
342
  * @param {Object} theme - Theme object
@@ -569,3 +662,652 @@ export function hslToHex(hsl) {
569
662
 
570
663
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
571
664
  }
665
+
666
+ // ============================================================================
667
+ // THEME REBUILD & ROLLBACK FUNCTIONS
668
+ // ============================================================================
669
+
670
+ /**
671
+ * Directories to scan for color references (standard React/Next.js paths)
672
+ */
673
+ export const SCAN_DIRECTORIES = ['app', 'components', 'src', 'pages'];
674
+
675
+ /**
676
+ * File extensions to scan
677
+ */
678
+ export const SCAN_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
679
+
680
+ /**
681
+ * Directories to always exclude from scanning
682
+ */
683
+ export const EXCLUDE_DIRS = ['node_modules', '.git', '.omgkit', 'dist', 'build', '.next', 'out'];
684
+
685
+ /**
686
+ * Color patterns to detect non-compliant colors
687
+ */
688
+ export const COLOR_PATTERNS = {
689
+ // Tailwind default colors (should use theme vars)
690
+ tailwindDefaults: /\b(bg|text|border|ring|fill|stroke|outline|divide|from|via|to|shadow|decoration)-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)-(\d{2,3})\b/g,
691
+
692
+ // Hardcoded hex colors in className or style
693
+ hexColors: /#([0-9A-Fa-f]{3}){1,2}\b/g,
694
+
695
+ // Hardcoded RGB/HSL in styles
696
+ rgbHsl: /\b(rgb|hsl)a?\([^)]+\)/g
697
+ };
698
+
699
+ /**
700
+ * Mapping of hardcoded Tailwind colors to theme variables
701
+ */
702
+ export const THEME_VAR_MAP = {
703
+ // Background mappings
704
+ 'bg-white': 'bg-background',
705
+ 'bg-gray-50': 'bg-muted',
706
+ 'bg-gray-100': 'bg-muted',
707
+ 'bg-gray-200': 'bg-muted',
708
+ 'bg-gray-900': 'bg-foreground',
709
+ 'bg-slate-50': 'bg-muted',
710
+ 'bg-slate-100': 'bg-muted',
711
+ 'bg-slate-900': 'bg-foreground',
712
+ 'bg-zinc-50': 'bg-muted',
713
+ 'bg-zinc-100': 'bg-muted',
714
+ 'bg-zinc-900': 'bg-foreground',
715
+
716
+ // Text mappings
717
+ 'text-black': 'text-foreground',
718
+ 'text-white': 'text-background',
719
+ 'text-gray-900': 'text-foreground',
720
+ 'text-gray-800': 'text-foreground',
721
+ 'text-gray-700': 'text-foreground',
722
+ 'text-gray-600': 'text-muted-foreground',
723
+ 'text-gray-500': 'text-muted-foreground',
724
+ 'text-gray-400': 'text-muted-foreground',
725
+ 'text-slate-900': 'text-foreground',
726
+ 'text-slate-600': 'text-muted-foreground',
727
+ 'text-slate-500': 'text-muted-foreground',
728
+ 'text-zinc-900': 'text-foreground',
729
+ 'text-zinc-600': 'text-muted-foreground',
730
+ 'text-zinc-500': 'text-muted-foreground',
731
+
732
+ // Border mappings
733
+ 'border-gray-100': 'border-border',
734
+ 'border-gray-200': 'border-border',
735
+ 'border-gray-300': 'border-input',
736
+ 'border-slate-200': 'border-border',
737
+ 'border-slate-300': 'border-input',
738
+ 'border-zinc-200': 'border-border',
739
+ 'border-zinc-300': 'border-input',
740
+
741
+ // Primary/accent colors (common patterns)
742
+ 'bg-blue-500': 'bg-primary',
743
+ 'bg-blue-600': 'bg-primary',
744
+ 'bg-blue-700': 'bg-primary',
745
+ 'text-blue-500': 'text-primary',
746
+ 'text-blue-600': 'text-primary',
747
+ 'text-blue-700': 'text-primary',
748
+ 'ring-blue-500': 'ring-ring',
749
+ 'ring-blue-600': 'ring-ring',
750
+
751
+ // Destructive
752
+ 'bg-red-500': 'bg-destructive',
753
+ 'bg-red-600': 'bg-destructive',
754
+ 'text-red-500': 'text-destructive',
755
+ 'text-red-600': 'text-destructive',
756
+ 'border-red-500': 'border-destructive',
757
+
758
+ // Secondary/accent patterns
759
+ 'bg-gray-100': 'bg-secondary',
760
+ 'bg-slate-100': 'bg-secondary',
761
+ 'hover:bg-gray-100': 'hover:bg-accent',
762
+ 'hover:bg-slate-100': 'hover:bg-accent'
763
+ };
764
+
765
+ /**
766
+ * Recursively get all files in a directory
767
+ * @param {string} dir - Directory to scan
768
+ * @param {string[]} extensions - File extensions to include
769
+ * @param {string[]} excludeDirs - Directories to exclude
770
+ * @returns {string[]} Array of file paths
771
+ */
772
+ function getFilesRecursive(dir, extensions, excludeDirs) {
773
+ const files = [];
774
+ if (!existsSync(dir)) return files;
775
+
776
+ const entries = readdirSync(dir, { withFileTypes: true });
777
+
778
+ for (const entry of entries) {
779
+ const fullPath = join(dir, entry.name);
780
+
781
+ if (entry.isDirectory()) {
782
+ if (!excludeDirs.includes(entry.name)) {
783
+ files.push(...getFilesRecursive(fullPath, extensions, excludeDirs));
784
+ }
785
+ } else if (entry.isFile()) {
786
+ const ext = '.' + entry.name.split('.').pop();
787
+ if (extensions.includes(ext)) {
788
+ files.push(fullPath);
789
+ }
790
+ }
791
+ }
792
+
793
+ return files;
794
+ }
795
+
796
+ /**
797
+ * Scan project for files with non-compliant color references
798
+ * @param {string} projectDir - Project root directory
799
+ * @returns {Object} { files: [], totalReferences: number, nonCompliant: [], compliant: number }
800
+ */
801
+ export function scanProjectColors(projectDir) {
802
+ const result = {
803
+ files: [],
804
+ totalReferences: 0,
805
+ nonCompliant: [],
806
+ compliant: 0,
807
+ scannedFiles: 0
808
+ };
809
+
810
+ // Find all files to scan
811
+ const filesToScan = [];
812
+ for (const scanDir of SCAN_DIRECTORIES) {
813
+ const fullPath = join(projectDir, scanDir);
814
+ if (existsSync(fullPath)) {
815
+ filesToScan.push(...getFilesRecursive(fullPath, SCAN_EXTENSIONS, EXCLUDE_DIRS));
816
+ }
817
+ }
818
+
819
+ result.scannedFiles = filesToScan.length;
820
+
821
+ // Scan each file
822
+ for (const filePath of filesToScan) {
823
+ try {
824
+ const content = readFileSync(filePath, 'utf8');
825
+ const lines = content.split('\n');
826
+ const relativePath = filePath.replace(projectDir + '/', '');
827
+ const fileMatches = [];
828
+
829
+ for (let i = 0; i < lines.length; i++) {
830
+ const line = lines[i];
831
+ const lineNum = i + 1;
832
+
833
+ // Check for Tailwind default colors
834
+ let match;
835
+ const pattern = new RegExp(COLOR_PATTERNS.tailwindDefaults.source, 'g');
836
+ while ((match = pattern.exec(line)) !== null) {
837
+ result.totalReferences++;
838
+ const fullMatch = match[0];
839
+
840
+ // Check if this has a theme-compliant mapping
841
+ const mapping = THEME_VAR_MAP[fullMatch];
842
+ if (mapping) {
843
+ fileMatches.push({
844
+ file: relativePath,
845
+ line: lineNum,
846
+ column: match.index,
847
+ match: fullMatch,
848
+ suggestion: mapping,
849
+ type: 'tailwind-default',
850
+ fixable: true
851
+ });
852
+ result.nonCompliant.push({
853
+ file: relativePath,
854
+ line: lineNum,
855
+ match: fullMatch,
856
+ suggestion: mapping
857
+ });
858
+ } else {
859
+ // Unmapped color - warn only
860
+ fileMatches.push({
861
+ file: relativePath,
862
+ line: lineNum,
863
+ column: match.index,
864
+ match: fullMatch,
865
+ suggestion: null,
866
+ type: 'unmapped',
867
+ fixable: false
868
+ });
869
+ result.nonCompliant.push({
870
+ file: relativePath,
871
+ line: lineNum,
872
+ match: fullMatch,
873
+ suggestion: null
874
+ });
875
+ }
876
+ }
877
+
878
+ // Check for hex colors in className or style attributes
879
+ const hexPattern = new RegExp(COLOR_PATTERNS.hexColors.source, 'g');
880
+ while ((match = hexPattern.exec(line)) !== null) {
881
+ // Only flag if it appears to be in className or style context
882
+ const before = line.slice(0, match.index);
883
+ if (before.includes('className') || before.includes('style') || before.includes('bg-[') || before.includes('text-[')) {
884
+ result.totalReferences++;
885
+ fileMatches.push({
886
+ file: relativePath,
887
+ line: lineNum,
888
+ column: match.index,
889
+ match: match[0],
890
+ suggestion: 'Use CSS variable (e.g., bg-background)',
891
+ type: 'hex-color',
892
+ fixable: false
893
+ });
894
+ result.nonCompliant.push({
895
+ file: relativePath,
896
+ line: lineNum,
897
+ match: match[0],
898
+ suggestion: 'Use CSS variable'
899
+ });
900
+ }
901
+ }
902
+ }
903
+
904
+ if (fileMatches.length > 0) {
905
+ result.files.push({
906
+ path: relativePath,
907
+ matches: fileMatches
908
+ });
909
+ }
910
+ } catch (err) {
911
+ // Skip files that can't be read
912
+ }
913
+ }
914
+
915
+ result.compliant = result.totalReferences - result.nonCompliant.length;
916
+
917
+ return result;
918
+ }
919
+
920
+ /**
921
+ * Create a theme backup before rebuild
922
+ * @param {string} projectDir - Project root directory
923
+ * @param {string} newThemeId - ID of new theme being applied
924
+ * @returns {Object} { success, backupId, backupPath, error }
925
+ */
926
+ export function createThemeBackup(projectDir, newThemeId = 'unknown') {
927
+ const designDir = join(projectDir, '.omgkit', 'design');
928
+ const backupsDir = join(designDir, 'backups');
929
+
930
+ // Create backups directory
931
+ mkdirSync(backupsDir, { recursive: true });
932
+
933
+ // Generate backup ID
934
+ const now = new Date();
935
+ const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
936
+ const backupId = `${timestamp}-${newThemeId}`;
937
+ const backupPath = join(backupsDir, backupId);
938
+
939
+ try {
940
+ mkdirSync(backupPath, { recursive: true });
941
+
942
+ // Get current theme
943
+ const currentTheme = getProjectTheme(projectDir);
944
+ const previousThemeId = currentTheme?.id || 'none';
945
+
946
+ // Create manifest
947
+ const manifest = {
948
+ id: backupId,
949
+ previousTheme: previousThemeId,
950
+ newTheme: newThemeId,
951
+ timestamp: now.toISOString(),
952
+ changedFiles: []
953
+ };
954
+
955
+ // Backup theme.json if exists
956
+ const themeJsonPath = join(designDir, 'theme.json');
957
+ if (existsSync(themeJsonPath)) {
958
+ const content = readFileSync(themeJsonPath, 'utf8');
959
+ writeFileSync(join(backupPath, 'theme.json.bak'), content);
960
+ manifest.changedFiles.push({ path: '.omgkit/design/theme.json', backup: 'theme.json.bak' });
961
+ }
962
+
963
+ // Backup theme.css if exists
964
+ const themeCssPath = join(designDir, 'theme.css');
965
+ if (existsSync(themeCssPath)) {
966
+ const content = readFileSync(themeCssPath, 'utf8');
967
+ writeFileSync(join(backupPath, 'theme.css.bak'), content);
968
+ manifest.changedFiles.push({ path: '.omgkit/design/theme.css', backup: 'theme.css.bak' });
969
+ }
970
+
971
+ // Backup tailwind.config.ts if exists
972
+ const tailwindConfigPath = join(projectDir, 'tailwind.config.ts');
973
+ if (existsSync(tailwindConfigPath)) {
974
+ const content = readFileSync(tailwindConfigPath, 'utf8');
975
+ writeFileSync(join(backupPath, 'tailwind.config.ts.bak'), content);
976
+ manifest.changedFiles.push({ path: 'tailwind.config.ts', backup: 'tailwind.config.ts.bak' });
977
+ }
978
+
979
+ // Also check for .js version
980
+ const tailwindConfigJsPath = join(projectDir, 'tailwind.config.js');
981
+ if (existsSync(tailwindConfigJsPath)) {
982
+ const content = readFileSync(tailwindConfigJsPath, 'utf8');
983
+ writeFileSync(join(backupPath, 'tailwind.config.js.bak'), content);
984
+ manifest.changedFiles.push({ path: 'tailwind.config.js', backup: 'tailwind.config.js.bak' });
985
+ }
986
+
987
+ // Write manifest
988
+ writeFileSync(join(backupPath, 'manifest.json'), JSON.stringify(manifest, null, 2));
989
+
990
+ return { success: true, backupId, backupPath, manifest };
991
+ } catch (err) {
992
+ return { success: false, error: err.message };
993
+ }
994
+ }
995
+
996
+ /**
997
+ * List available theme backups
998
+ * @param {string} projectDir - Project root directory
999
+ * @returns {Array} Array of backup info objects
1000
+ */
1001
+ export function listThemeBackups(projectDir) {
1002
+ const backupsDir = join(projectDir, '.omgkit', 'design', 'backups');
1003
+ if (!existsSync(backupsDir)) return [];
1004
+
1005
+ const backups = [];
1006
+ const entries = readdirSync(backupsDir, { withFileTypes: true });
1007
+
1008
+ for (const entry of entries) {
1009
+ if (!entry.isDirectory()) continue;
1010
+
1011
+ const manifestPath = join(backupsDir, entry.name, 'manifest.json');
1012
+ if (!existsSync(manifestPath)) continue;
1013
+
1014
+ try {
1015
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1016
+ backups.push({
1017
+ id: manifest.id,
1018
+ previousTheme: manifest.previousTheme,
1019
+ newTheme: manifest.newTheme,
1020
+ timestamp: manifest.timestamp,
1021
+ date: new Date(manifest.timestamp).toLocaleString(),
1022
+ filesChanged: manifest.changedFiles.length,
1023
+ path: join(backupsDir, entry.name)
1024
+ });
1025
+ } catch {
1026
+ // Skip invalid backup
1027
+ }
1028
+ }
1029
+
1030
+ // Sort by timestamp descending (newest first)
1031
+ return backups.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
1032
+ }
1033
+
1034
+ /**
1035
+ * Rollback to a previous theme state
1036
+ * @param {string} projectDir - Project root directory
1037
+ * @param {string} backupId - Backup ID to restore (optional, defaults to latest)
1038
+ * @returns {Object} { success, restoredTheme, restoredFiles, error }
1039
+ */
1040
+ export function rollbackTheme(projectDir, backupId = null) {
1041
+ const backups = listThemeBackups(projectDir);
1042
+
1043
+ if (backups.length === 0) {
1044
+ return { success: false, error: 'No theme backups found' };
1045
+ }
1046
+
1047
+ // Find backup to restore
1048
+ let backup;
1049
+ if (backupId) {
1050
+ backup = backups.find(b => b.id === backupId);
1051
+ if (!backup) {
1052
+ return { success: false, error: `Backup not found: ${backupId}` };
1053
+ }
1054
+ } else {
1055
+ backup = backups[0]; // Latest
1056
+ }
1057
+
1058
+ try {
1059
+ const manifestPath = join(backup.path, 'manifest.json');
1060
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1061
+ const restoredFiles = [];
1062
+
1063
+ // Create a new backup before rollback (safety)
1064
+ createThemeBackup(projectDir, `rollback-from-${manifest.newTheme}`);
1065
+
1066
+ // Restore each file
1067
+ for (const file of manifest.changedFiles) {
1068
+ const backupFilePath = join(backup.path, file.backup);
1069
+ const targetPath = join(projectDir, file.path);
1070
+
1071
+ if (existsSync(backupFilePath)) {
1072
+ const content = readFileSync(backupFilePath, 'utf8');
1073
+ mkdirSync(dirname(targetPath), { recursive: true });
1074
+ writeFileSync(targetPath, content);
1075
+ restoredFiles.push(file.path);
1076
+ }
1077
+ }
1078
+
1079
+ return {
1080
+ success: true,
1081
+ restoredTheme: manifest.previousTheme,
1082
+ restoredFiles,
1083
+ backupUsed: backup.id
1084
+ };
1085
+ } catch (err) {
1086
+ return { success: false, error: err.message };
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * Update file replacing hardcoded colors with theme variables
1092
+ * @param {string} filePath - File to update
1093
+ * @param {string} projectDir - Project root directory
1094
+ * @returns {Object} { changed, replacements, content }
1095
+ */
1096
+ export function updateFileColors(filePath, projectDir) {
1097
+ const fullPath = join(projectDir, filePath);
1098
+ if (!existsSync(fullPath)) {
1099
+ return { changed: false, replacements: [], error: 'File not found' };
1100
+ }
1101
+
1102
+ let content = readFileSync(fullPath, 'utf8');
1103
+ const replacements = [];
1104
+ let changed = false;
1105
+
1106
+ // Apply theme variable mappings
1107
+ for (const [pattern, replacement] of Object.entries(THEME_VAR_MAP)) {
1108
+ // Escape special regex characters in pattern
1109
+ const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1110
+ const regex = new RegExp(`\\b${escapedPattern}\\b`, 'g');
1111
+
1112
+ const matches = content.match(regex);
1113
+ if (matches && matches.length > 0) {
1114
+ content = content.replace(regex, replacement);
1115
+ replacements.push({
1116
+ from: pattern,
1117
+ to: replacement,
1118
+ count: matches.length
1119
+ });
1120
+ changed = true;
1121
+ }
1122
+ }
1123
+
1124
+ return { changed, replacements, content };
1125
+ }
1126
+
1127
+ /**
1128
+ * Update project's tailwind.config file with new theme
1129
+ * @param {Object} theme - Theme object
1130
+ * @param {string} projectDir - Project root directory
1131
+ * @returns {Object} { success, path, error }
1132
+ */
1133
+ export function updateProjectTailwindConfig(theme, projectDir) {
1134
+ // Check for tailwind.config.ts first, then .js
1135
+ let configPath = join(projectDir, 'tailwind.config.ts');
1136
+ let isTs = true;
1137
+
1138
+ if (!existsSync(configPath)) {
1139
+ configPath = join(projectDir, 'tailwind.config.js');
1140
+ isTs = false;
1141
+ if (!existsSync(configPath)) {
1142
+ // Create new config
1143
+ configPath = join(projectDir, 'tailwind.config.ts');
1144
+ isTs = true;
1145
+ }
1146
+ }
1147
+
1148
+ try {
1149
+ const newConfig = generateTailwindConfig(theme);
1150
+ writeFileSync(configPath, newConfig);
1151
+ return { success: true, path: configPath, isTs };
1152
+ } catch (err) {
1153
+ return { success: false, error: err.message };
1154
+ }
1155
+ }
1156
+
1157
+ /**
1158
+ * Ensure globals.css imports theme.css
1159
+ * @param {string} projectDir - Project root directory
1160
+ * @returns {Object} { updated, path, alreadyImported }
1161
+ */
1162
+ export function ensureThemeImport(projectDir) {
1163
+ // Look for globals.css in common locations
1164
+ const possiblePaths = [
1165
+ join(projectDir, 'app', 'globals.css'),
1166
+ join(projectDir, 'src', 'app', 'globals.css'),
1167
+ join(projectDir, 'styles', 'globals.css'),
1168
+ join(projectDir, 'src', 'styles', 'globals.css')
1169
+ ];
1170
+
1171
+ let globalsPath = null;
1172
+ for (const p of possiblePaths) {
1173
+ if (existsSync(p)) {
1174
+ globalsPath = p;
1175
+ break;
1176
+ }
1177
+ }
1178
+
1179
+ if (!globalsPath) {
1180
+ return { updated: false, path: null, error: 'globals.css not found' };
1181
+ }
1182
+
1183
+ let content = readFileSync(globalsPath, 'utf8');
1184
+ const themeImport = "@import '../.omgkit/design/theme.css';";
1185
+ const altThemeImport = "@import '../../.omgkit/design/theme.css';";
1186
+
1187
+ // Check if already imported
1188
+ if (content.includes('.omgkit/design/theme.css')) {
1189
+ return { updated: false, path: globalsPath, alreadyImported: true };
1190
+ }
1191
+
1192
+ // Add import at the beginning
1193
+ const relativePath = globalsPath.includes('/src/') ? altThemeImport : themeImport;
1194
+ content = `${relativePath}\n${content}`;
1195
+
1196
+ writeFileSync(globalsPath, content);
1197
+ return { updated: true, path: globalsPath, alreadyImported: false };
1198
+ }
1199
+
1200
+ /**
1201
+ * Rebuild entire project with a new theme
1202
+ * @param {string} projectDir - Project root directory
1203
+ * @param {string} themeId - New theme ID
1204
+ * @param {Object} options - { dryRun, force, fixColors }
1205
+ * @returns {Object} { success, backupPath, changedFiles, warnings, error }
1206
+ */
1207
+ export function rebuildProjectTheme(projectDir, themeId, options = {}) {
1208
+ const { dryRun = false, force = false, fixColors = true } = options;
1209
+
1210
+ // Validate project has .omgkit
1211
+ if (!existsSync(join(projectDir, '.omgkit'))) {
1212
+ return { success: false, error: 'Not an OMGKIT project. Run: omgkit init' };
1213
+ }
1214
+
1215
+ // Get new theme
1216
+ const newTheme = getThemeById(themeId);
1217
+ if (!newTheme) {
1218
+ return { success: false, error: `Theme not found: ${themeId}. Run /design:themes to see available themes.` };
1219
+ }
1220
+
1221
+ // Validate theme
1222
+ const validation = validateTheme(newTheme);
1223
+ if (!validation.valid) {
1224
+ return { success: false, error: `Invalid theme: ${validation.errors.join(', ')}` };
1225
+ }
1226
+
1227
+ const result = {
1228
+ success: true,
1229
+ newTheme: themeId,
1230
+ backupId: null,
1231
+ backupPath: null,
1232
+ changedFiles: [],
1233
+ fixedColors: [],
1234
+ warnings: [],
1235
+ dryRun
1236
+ };
1237
+
1238
+ // Step 1: Create backup (unless dry-run)
1239
+ if (!dryRun) {
1240
+ const backup = createThemeBackup(projectDir, themeId);
1241
+ if (!backup.success) {
1242
+ return { success: false, error: `Failed to create backup: ${backup.error}` };
1243
+ }
1244
+ result.backupId = backup.backupId;
1245
+ result.backupPath = backup.backupPath;
1246
+ }
1247
+
1248
+ // Step 2: Apply new theme
1249
+ if (!dryRun) {
1250
+ const applied = applyThemeToProject(newTheme, projectDir);
1251
+ result.changedFiles.push(applied.themeJson.replace(projectDir + '/', ''));
1252
+ result.changedFiles.push(applied.themeCss.replace(projectDir + '/', ''));
1253
+ } else {
1254
+ result.changedFiles.push('.omgkit/design/theme.json');
1255
+ result.changedFiles.push('.omgkit/design/theme.css');
1256
+ }
1257
+
1258
+ // Step 3: Update tailwind config
1259
+ if (!dryRun) {
1260
+ const tailwindResult = updateProjectTailwindConfig(newTheme, projectDir);
1261
+ if (tailwindResult.success) {
1262
+ result.changedFiles.push(tailwindResult.path.replace(projectDir + '/', ''));
1263
+ }
1264
+ } else {
1265
+ result.changedFiles.push('tailwind.config.ts');
1266
+ }
1267
+
1268
+ // Step 4: Ensure theme import in globals.css
1269
+ if (!dryRun) {
1270
+ const importResult = ensureThemeImport(projectDir);
1271
+ if (importResult.updated) {
1272
+ result.changedFiles.push(importResult.path.replace(projectDir + '/', ''));
1273
+ }
1274
+ }
1275
+
1276
+ // Step 5: Scan and fix colors (if enabled)
1277
+ if (fixColors) {
1278
+ const scanResult = scanProjectColors(projectDir);
1279
+
1280
+ for (const fileInfo of scanResult.files) {
1281
+ const fixableMatches = fileInfo.matches.filter(m => m.fixable);
1282
+
1283
+ if (fixableMatches.length > 0) {
1284
+ if (!dryRun) {
1285
+ const updateResult = updateFileColors(fileInfo.path, projectDir);
1286
+ if (updateResult.changed) {
1287
+ // Write updated content
1288
+ writeFileSync(join(projectDir, fileInfo.path), updateResult.content);
1289
+ result.changedFiles.push(fileInfo.path);
1290
+ result.fixedColors.push({
1291
+ file: fileInfo.path,
1292
+ replacements: updateResult.replacements
1293
+ });
1294
+ }
1295
+ } else {
1296
+ // Dry run - just report what would be changed
1297
+ result.fixedColors.push({
1298
+ file: fileInfo.path,
1299
+ replacements: fixableMatches.map(m => ({ from: m.match, to: m.suggestion }))
1300
+ });
1301
+ }
1302
+ }
1303
+
1304
+ // Add warnings for unfixable colors
1305
+ const unfixable = fileInfo.matches.filter(m => !m.fixable);
1306
+ for (const u of unfixable) {
1307
+ result.warnings.push(`${u.file}:${u.line} - ${u.match} (manual review needed)`);
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ return result;
1313
+ }