omgkit 2.28.0 → 2.30.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 +72 -1
- package/bin/omgkit.js +188 -1
- package/lib/cli.js +58 -4
- package/lib/theme.js +1220 -0
- package/package.json +2 -2
- package/plugin/agents/fullstack-developer.md +1 -0
- package/plugin/agents/ui-ux-designer.md +175 -41
- package/plugin/commands/design/add.md +86 -0
- package/plugin/commands/design/builder.md +96 -0
- package/plugin/commands/design/from-screenshot.md +64 -0
- package/plugin/commands/design/from-url.md +74 -0
- package/plugin/commands/design/preview.md +55 -0
- package/plugin/commands/design/rebuild.md +153 -0
- package/plugin/commands/design/reset.md +65 -0
- package/plugin/commands/design/rollback.md +179 -0
- package/plugin/commands/design/scan.md +155 -0
- package/plugin/commands/design/theme.md +65 -0
- package/plugin/commands/design/themes.md +50 -0
- package/plugin/registry.yaml +15 -3
- package/plugin/skills/frontend/design-system-context/SKILL.md +252 -0
- package/templates/design/schema/theme.schema.json +102 -0
- package/templates/design/themes/corporate-enterprise/consulting.json +81 -0
- package/templates/design/themes/corporate-enterprise/corporate-indigo.json +81 -0
- package/templates/design/themes/corporate-enterprise/finance.json +81 -0
- package/templates/design/themes/corporate-enterprise/healthcare.json +81 -0
- package/templates/design/themes/corporate-enterprise/legal.json +81 -0
- package/templates/design/themes/corporate-enterprise/ocean-blue.json +81 -0
- package/templates/design/themes/creative-bold/candy.json +81 -0
- package/templates/design/themes/creative-bold/coral-sunset.json +81 -0
- package/templates/design/themes/creative-bold/gradient-dream.json +81 -0
- package/templates/design/themes/creative-bold/neon.json +81 -0
- package/templates/design/themes/creative-bold/retro.json +81 -0
- package/templates/design/themes/creative-bold/studio.json +81 -0
- package/templates/design/themes/minimal-clean/minimal-slate.json +81 -0
- package/templates/design/themes/minimal-clean/mono.json +81 -0
- package/templates/design/themes/minimal-clean/nordic.json +81 -0
- package/templates/design/themes/minimal-clean/paper.json +81 -0
- package/templates/design/themes/minimal-clean/swiss.json +81 -0
- package/templates/design/themes/minimal-clean/zen.json +81 -0
- package/templates/design/themes/nature-organic/arctic.json +81 -0
- package/templates/design/themes/nature-organic/autumn.json +81 -0
- package/templates/design/themes/nature-organic/desert.json +81 -0
- package/templates/design/themes/nature-organic/forest.json +81 -0
- package/templates/design/themes/nature-organic/lavender.json +81 -0
- package/templates/design/themes/nature-organic/ocean.json +81 -0
- package/templates/design/themes/tech-ai/electric-cyan.json +81 -0
- package/templates/design/themes/tech-ai/hologram.json +81 -0
- package/templates/design/themes/tech-ai/matrix-green.json +81 -0
- package/templates/design/themes/tech-ai/neo-tokyo.json +81 -0
- package/templates/design/themes/tech-ai/neural-dark.json +81 -0
- package/templates/design/themes/tech-ai/quantum-purple.json +81 -0
package/lib/theme.js
ADDED
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMGKIT Theme Processing Library
|
|
3
|
+
* Handles theme loading, validation, CSS generation, and extraction
|
|
4
|
+
*
|
|
5
|
+
* @module lib/theme
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
// Package root detection
|
|
13
|
+
let PACKAGE_ROOT;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set the package root directory (for testing)
|
|
17
|
+
* @param {string} root - Package root path
|
|
18
|
+
*/
|
|
19
|
+
export function setThemePackageRoot(root) {
|
|
20
|
+
PACKAGE_ROOT = root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the package root directory
|
|
25
|
+
* @returns {string} Package root path
|
|
26
|
+
*/
|
|
27
|
+
export function getThemePackageRoot() {
|
|
28
|
+
if (PACKAGE_ROOT) return PACKAGE_ROOT;
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = dirname(__filename);
|
|
31
|
+
PACKAGE_ROOT = join(__dirname, '..');
|
|
32
|
+
return PACKAGE_ROOT;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Theme category definitions
|
|
37
|
+
*/
|
|
38
|
+
export const THEME_CATEGORIES = {
|
|
39
|
+
'tech-ai': {
|
|
40
|
+
name: 'Tech & AI',
|
|
41
|
+
description: 'Futuristic, cyberpunk, and technology-inspired themes',
|
|
42
|
+
emoji: '⚡'
|
|
43
|
+
},
|
|
44
|
+
'minimal-clean': {
|
|
45
|
+
name: 'Minimal & Clean',
|
|
46
|
+
description: 'Simple, elegant, and distraction-free themes',
|
|
47
|
+
emoji: '✨'
|
|
48
|
+
},
|
|
49
|
+
'corporate-enterprise': {
|
|
50
|
+
name: 'Corporate & Enterprise',
|
|
51
|
+
description: 'Professional themes for business applications',
|
|
52
|
+
emoji: '🏢'
|
|
53
|
+
},
|
|
54
|
+
'creative-bold': {
|
|
55
|
+
name: 'Creative & Bold',
|
|
56
|
+
description: 'Vibrant, expressive themes for creative projects',
|
|
57
|
+
emoji: '🎨'
|
|
58
|
+
},
|
|
59
|
+
'nature-organic': {
|
|
60
|
+
name: 'Nature & Organic',
|
|
61
|
+
description: 'Earthy, natural color palettes inspired by nature',
|
|
62
|
+
emoji: '🌿'
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Required color variables for shadcn compatibility
|
|
68
|
+
*/
|
|
69
|
+
export const REQUIRED_COLORS = [
|
|
70
|
+
'background', 'foreground',
|
|
71
|
+
'primary', 'primary-foreground',
|
|
72
|
+
'secondary', 'secondary-foreground',
|
|
73
|
+
'muted', 'muted-foreground',
|
|
74
|
+
'accent', 'accent-foreground',
|
|
75
|
+
'destructive', 'destructive-foreground',
|
|
76
|
+
'border', 'input', 'ring',
|
|
77
|
+
'card', 'card-foreground',
|
|
78
|
+
'popover', 'popover-foreground'
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Optional color variables (charts, sidebar)
|
|
83
|
+
*/
|
|
84
|
+
export const OPTIONAL_COLORS = [
|
|
85
|
+
'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5',
|
|
86
|
+
'sidebar-background', 'sidebar-foreground',
|
|
87
|
+
'sidebar-primary', 'sidebar-primary-foreground',
|
|
88
|
+
'sidebar-accent', 'sidebar-accent-foreground',
|
|
89
|
+
'sidebar-border', 'sidebar-ring'
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load all available themes from templates/design/themes
|
|
94
|
+
* @returns {Object} Themes grouped by category
|
|
95
|
+
*/
|
|
96
|
+
export function loadAllThemes() {
|
|
97
|
+
const themesDir = join(getThemePackageRoot(), 'templates', 'design', 'themes');
|
|
98
|
+
const themes = {};
|
|
99
|
+
|
|
100
|
+
for (const categoryId of Object.keys(THEME_CATEGORIES)) {
|
|
101
|
+
const categoryDir = join(themesDir, categoryId);
|
|
102
|
+
if (!existsSync(categoryDir)) {
|
|
103
|
+
themes[categoryId] = [];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
themes[categoryId] = [];
|
|
108
|
+
const files = readdirSync(categoryDir).filter(f => f.endsWith('.json'));
|
|
109
|
+
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
try {
|
|
112
|
+
const themePath = join(categoryDir, file);
|
|
113
|
+
const theme = JSON.parse(readFileSync(themePath, 'utf8'));
|
|
114
|
+
themes[categoryId].push(theme);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.warn(`Failed to load theme ${file}: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return themes;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get a specific theme by ID
|
|
126
|
+
* @param {string} themeId - Theme identifier
|
|
127
|
+
* @returns {Object|null} Theme object or null if not found
|
|
128
|
+
*/
|
|
129
|
+
export function getThemeById(themeId) {
|
|
130
|
+
const themes = loadAllThemes();
|
|
131
|
+
for (const category of Object.values(themes)) {
|
|
132
|
+
const theme = category.find(t => t.id === themeId);
|
|
133
|
+
if (theme) return theme;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get all theme IDs
|
|
140
|
+
* @returns {string[]} Array of theme IDs
|
|
141
|
+
*/
|
|
142
|
+
export function getAllThemeIds() {
|
|
143
|
+
const themes = loadAllThemes();
|
|
144
|
+
const ids = [];
|
|
145
|
+
for (const category of Object.values(themes)) {
|
|
146
|
+
for (const theme of category) {
|
|
147
|
+
ids.push(theme.id);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return ids;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate theme against schema
|
|
155
|
+
* @param {Object} theme - Theme object to validate
|
|
156
|
+
* @returns {{valid: boolean, errors: string[]}} Validation result
|
|
157
|
+
*/
|
|
158
|
+
export function validateTheme(theme) {
|
|
159
|
+
const errors = [];
|
|
160
|
+
|
|
161
|
+
// Check required fields
|
|
162
|
+
const requiredFields = ['name', 'id', 'category', 'colors'];
|
|
163
|
+
for (const field of requiredFields) {
|
|
164
|
+
if (!theme[field]) {
|
|
165
|
+
errors.push(`Missing required field: ${field}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate ID format
|
|
170
|
+
if (theme.id && !/^[a-z0-9-]+$/.test(theme.id)) {
|
|
171
|
+
errors.push('ID must be kebab-case (lowercase letters, numbers, hyphens)');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate category
|
|
175
|
+
if (theme.category && !THEME_CATEGORIES[theme.category]) {
|
|
176
|
+
errors.push(`Invalid category: ${theme.category}. Must be one of: ${Object.keys(THEME_CATEGORIES).join(', ')}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Validate colors
|
|
180
|
+
if (theme.colors) {
|
|
181
|
+
if (!theme.colors.light) errors.push('Missing light color palette');
|
|
182
|
+
if (!theme.colors.dark) errors.push('Missing dark color palette');
|
|
183
|
+
|
|
184
|
+
for (const mode of ['light', 'dark']) {
|
|
185
|
+
if (theme.colors[mode]) {
|
|
186
|
+
for (const color of REQUIRED_COLORS) {
|
|
187
|
+
if (!theme.colors[mode][color]) {
|
|
188
|
+
errors.push(`Missing ${mode}.${color} color`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Validate HSL format
|
|
193
|
+
for (const [key, value] of Object.entries(theme.colors[mode])) {
|
|
194
|
+
if (typeof value === 'string' && !/^\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%$/.test(value)) {
|
|
195
|
+
errors.push(`Invalid HSL format for ${mode}.${key}: "${value}". Expected format: "H S% L%" (e.g., "220 14.3% 95.9%")`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { valid: errors.length === 0, errors };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Generate CSS variables from theme
|
|
207
|
+
* @param {Object} theme - Theme object
|
|
208
|
+
* @returns {string} CSS content with variables
|
|
209
|
+
*/
|
|
210
|
+
export function generateThemeCSS(theme) {
|
|
211
|
+
const generateColorVars = (colors) => {
|
|
212
|
+
let css = '';
|
|
213
|
+
for (const [key, value] of Object.entries(colors)) {
|
|
214
|
+
css += ` --${key}: ${value};\n`;
|
|
215
|
+
}
|
|
216
|
+
return css;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const lightVars = generateColorVars(theme.colors.light);
|
|
220
|
+
const darkVars = generateColorVars(theme.colors.dark);
|
|
221
|
+
|
|
222
|
+
return `/* OMGKIT Theme: ${theme.name} */
|
|
223
|
+
/* Theme ID: ${theme.id} */
|
|
224
|
+
/* Category: ${theme.category} */
|
|
225
|
+
/* Generated by OMGKIT Design System */
|
|
226
|
+
|
|
227
|
+
@layer base {
|
|
228
|
+
:root {
|
|
229
|
+
${lightVars} --radius: ${theme.radius || '0.5rem'};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.dark {
|
|
233
|
+
${darkVars} }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@layer base {
|
|
237
|
+
* {
|
|
238
|
+
@apply border-border;
|
|
239
|
+
}
|
|
240
|
+
body {
|
|
241
|
+
@apply bg-background text-foreground;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Generate components.json for shadcn
|
|
249
|
+
* @param {Object} theme - Theme object
|
|
250
|
+
* @param {Object} options - Configuration options
|
|
251
|
+
* @returns {Object} components.json content
|
|
252
|
+
*/
|
|
253
|
+
export function generateComponentsJson(theme, options = {}) {
|
|
254
|
+
const {
|
|
255
|
+
cssPath = 'app/globals.css',
|
|
256
|
+
tailwindConfig = 'tailwind.config.ts',
|
|
257
|
+
style = 'new-york',
|
|
258
|
+
rsc = true,
|
|
259
|
+
tsx = true
|
|
260
|
+
} = options;
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
264
|
+
"style": style,
|
|
265
|
+
"rsc": rsc,
|
|
266
|
+
"tsx": tsx,
|
|
267
|
+
"tailwind": {
|
|
268
|
+
"config": tailwindConfig,
|
|
269
|
+
"css": cssPath,
|
|
270
|
+
"baseColor": "slate",
|
|
271
|
+
"cssVariables": true,
|
|
272
|
+
"prefix": ""
|
|
273
|
+
},
|
|
274
|
+
"aliases": {
|
|
275
|
+
"components": "@/components",
|
|
276
|
+
"utils": "@/lib/utils",
|
|
277
|
+
"ui": "@/components/ui",
|
|
278
|
+
"lib": "@/lib",
|
|
279
|
+
"hooks": "@/hooks"
|
|
280
|
+
},
|
|
281
|
+
"iconLibrary": "lucide"
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Generate tailwind.config.ts content
|
|
287
|
+
* @param {Object} theme - Theme object
|
|
288
|
+
* @returns {string} Tailwind config content
|
|
289
|
+
*/
|
|
290
|
+
export function generateTailwindConfig(theme) {
|
|
291
|
+
const fontSans = theme.fontFamily?.sans || 'Inter, system-ui, sans-serif';
|
|
292
|
+
const fontMono = theme.fontFamily?.mono || 'JetBrains Mono, monospace';
|
|
293
|
+
|
|
294
|
+
return `import type { Config } from "tailwindcss";
|
|
295
|
+
|
|
296
|
+
const config: Config = {
|
|
297
|
+
darkMode: ["class"],
|
|
298
|
+
content: [
|
|
299
|
+
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
300
|
+
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
301
|
+
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
302
|
+
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
|
303
|
+
],
|
|
304
|
+
theme: {
|
|
305
|
+
extend: {
|
|
306
|
+
colors: {
|
|
307
|
+
background: "hsl(var(--background))",
|
|
308
|
+
foreground: "hsl(var(--foreground))",
|
|
309
|
+
card: {
|
|
310
|
+
DEFAULT: "hsl(var(--card))",
|
|
311
|
+
foreground: "hsl(var(--card-foreground))",
|
|
312
|
+
},
|
|
313
|
+
popover: {
|
|
314
|
+
DEFAULT: "hsl(var(--popover))",
|
|
315
|
+
foreground: "hsl(var(--popover-foreground))",
|
|
316
|
+
},
|
|
317
|
+
primary: {
|
|
318
|
+
DEFAULT: "hsl(var(--primary))",
|
|
319
|
+
foreground: "hsl(var(--primary-foreground))",
|
|
320
|
+
},
|
|
321
|
+
secondary: {
|
|
322
|
+
DEFAULT: "hsl(var(--secondary))",
|
|
323
|
+
foreground: "hsl(var(--secondary-foreground))",
|
|
324
|
+
},
|
|
325
|
+
muted: {
|
|
326
|
+
DEFAULT: "hsl(var(--muted))",
|
|
327
|
+
foreground: "hsl(var(--muted-foreground))",
|
|
328
|
+
},
|
|
329
|
+
accent: {
|
|
330
|
+
DEFAULT: "hsl(var(--accent))",
|
|
331
|
+
foreground: "hsl(var(--accent-foreground))",
|
|
332
|
+
},
|
|
333
|
+
destructive: {
|
|
334
|
+
DEFAULT: "hsl(var(--destructive))",
|
|
335
|
+
foreground: "hsl(var(--destructive-foreground))",
|
|
336
|
+
},
|
|
337
|
+
border: "hsl(var(--border))",
|
|
338
|
+
input: "hsl(var(--input))",
|
|
339
|
+
ring: "hsl(var(--ring))",
|
|
340
|
+
chart: {
|
|
341
|
+
"1": "hsl(var(--chart-1))",
|
|
342
|
+
"2": "hsl(var(--chart-2))",
|
|
343
|
+
"3": "hsl(var(--chart-3))",
|
|
344
|
+
"4": "hsl(var(--chart-4))",
|
|
345
|
+
"5": "hsl(var(--chart-5))",
|
|
346
|
+
},
|
|
347
|
+
sidebar: {
|
|
348
|
+
DEFAULT: "hsl(var(--sidebar-background))",
|
|
349
|
+
foreground: "hsl(var(--sidebar-foreground))",
|
|
350
|
+
primary: "hsl(var(--sidebar-primary))",
|
|
351
|
+
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
|
352
|
+
accent: "hsl(var(--sidebar-accent))",
|
|
353
|
+
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
|
354
|
+
border: "hsl(var(--sidebar-border))",
|
|
355
|
+
ring: "hsl(var(--sidebar-ring))",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
borderRadius: {
|
|
359
|
+
lg: "var(--radius)",
|
|
360
|
+
md: "calc(var(--radius) - 2px)",
|
|
361
|
+
sm: "calc(var(--radius) - 4px)",
|
|
362
|
+
},
|
|
363
|
+
fontFamily: {
|
|
364
|
+
sans: ["${fontSans}"],
|
|
365
|
+
mono: ["${fontMono}"],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
plugins: [require("tailwindcss-animate")],
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export default config;
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Apply theme to project directory
|
|
378
|
+
* @param {Object} theme - Theme object
|
|
379
|
+
* @param {string} projectDir - Project directory path
|
|
380
|
+
* @returns {{themeJson: string, themeCss: string}} Created file paths
|
|
381
|
+
*/
|
|
382
|
+
export function applyThemeToProject(theme, projectDir) {
|
|
383
|
+
const designDir = join(projectDir, '.omgkit', 'design');
|
|
384
|
+
|
|
385
|
+
// Create design directory
|
|
386
|
+
mkdirSync(designDir, { recursive: true });
|
|
387
|
+
|
|
388
|
+
// Write theme.json
|
|
389
|
+
const themeJsonPath = join(designDir, 'theme.json');
|
|
390
|
+
writeFileSync(themeJsonPath, JSON.stringify(theme, null, 2));
|
|
391
|
+
|
|
392
|
+
// Write theme.css
|
|
393
|
+
const themeCssPath = join(designDir, 'theme.css');
|
|
394
|
+
writeFileSync(themeCssPath, generateThemeCSS(theme));
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
themeJson: themeJsonPath,
|
|
398
|
+
themeCss: themeCssPath
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Get project's current theme
|
|
404
|
+
* @param {string} projectDir - Project directory path
|
|
405
|
+
* @returns {Object|null} Theme object or null if not found
|
|
406
|
+
*/
|
|
407
|
+
export function getProjectTheme(projectDir) {
|
|
408
|
+
const themeJsonPath = join(projectDir, '.omgkit', 'design', 'theme.json');
|
|
409
|
+
if (!existsSync(themeJsonPath)) return null;
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(readFileSync(themeJsonPath, 'utf8'));
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* List all themes with preview data
|
|
420
|
+
* @returns {Array} Array of category objects with themes
|
|
421
|
+
*/
|
|
422
|
+
export function listThemesWithPreview() {
|
|
423
|
+
const themes = loadAllThemes();
|
|
424
|
+
const result = [];
|
|
425
|
+
|
|
426
|
+
for (const [categoryId, categoryThemes] of Object.entries(themes)) {
|
|
427
|
+
const category = THEME_CATEGORIES[categoryId];
|
|
428
|
+
if (!category) continue;
|
|
429
|
+
|
|
430
|
+
result.push({
|
|
431
|
+
categoryId,
|
|
432
|
+
categoryName: category.name,
|
|
433
|
+
emoji: category.emoji,
|
|
434
|
+
description: category.description,
|
|
435
|
+
themes: categoryThemes.map(t => ({
|
|
436
|
+
id: t.id,
|
|
437
|
+
name: t.name,
|
|
438
|
+
description: t.description,
|
|
439
|
+
primaryLight: t.colors.light.primary,
|
|
440
|
+
primaryDark: t.colors.dark.primary,
|
|
441
|
+
backgroundLight: t.colors.light.background,
|
|
442
|
+
backgroundDark: t.colors.dark.background
|
|
443
|
+
}))
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get prompt for Claude Vision screenshot extraction
|
|
452
|
+
* @returns {string} Extraction prompt
|
|
453
|
+
*/
|
|
454
|
+
export function getScreenshotExtractionPrompt() {
|
|
455
|
+
return `Analyze this screenshot and extract a cohesive color theme for a web application.
|
|
456
|
+
|
|
457
|
+
For each color, provide the HSL value in this format: "H S% L%" (e.g., "220 14.3% 95.9%")
|
|
458
|
+
|
|
459
|
+
Extract these colors:
|
|
460
|
+
1. **background** - Main page background
|
|
461
|
+
2. **foreground** - Primary text color
|
|
462
|
+
3. **primary** - Brand/accent color (buttons, links)
|
|
463
|
+
4. **primary-foreground** - Text on primary color
|
|
464
|
+
5. **secondary** - Secondary backgrounds
|
|
465
|
+
6. **secondary-foreground** - Text on secondary
|
|
466
|
+
7. **muted** - Subtle backgrounds
|
|
467
|
+
8. **muted-foreground** - Subtle text
|
|
468
|
+
9. **accent** - Highlights, hovers
|
|
469
|
+
10. **accent-foreground** - Text on accent
|
|
470
|
+
11. **destructive** - Error/danger color
|
|
471
|
+
12. **destructive-foreground** - Text on destructive
|
|
472
|
+
13. **border** - Border colors
|
|
473
|
+
14. **input** - Input field borders
|
|
474
|
+
15. **ring** - Focus ring color
|
|
475
|
+
16. **card** - Card background
|
|
476
|
+
17. **card-foreground** - Card text
|
|
477
|
+
18. **popover** - Popover background
|
|
478
|
+
19. **popover-foreground** - Popover text
|
|
479
|
+
|
|
480
|
+
Return a JSON object with this structure:
|
|
481
|
+
{
|
|
482
|
+
"name": "Extracted Theme",
|
|
483
|
+
"id": "extracted-theme",
|
|
484
|
+
"category": "custom",
|
|
485
|
+
"description": "Theme extracted from screenshot",
|
|
486
|
+
"colors": {
|
|
487
|
+
"light": { ... all colors ... },
|
|
488
|
+
"dark": { ... inverted/dark mode colors ... }
|
|
489
|
+
},
|
|
490
|
+
"radius": "0.5rem"
|
|
491
|
+
}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Convert hex color to HSL string
|
|
496
|
+
* @param {string} hex - Hex color (e.g., "#E11D48")
|
|
497
|
+
* @returns {string} HSL string (e.g., "346.8 77.2% 49.8%")
|
|
498
|
+
*/
|
|
499
|
+
export function hexToHsl(hex) {
|
|
500
|
+
// Remove # if present
|
|
501
|
+
hex = hex.replace(/^#/, '');
|
|
502
|
+
|
|
503
|
+
// Parse hex
|
|
504
|
+
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
|
505
|
+
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
|
506
|
+
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
|
507
|
+
|
|
508
|
+
const max = Math.max(r, g, b);
|
|
509
|
+
const min = Math.min(r, g, b);
|
|
510
|
+
let h, s, l = (max + min) / 2;
|
|
511
|
+
|
|
512
|
+
if (max === min) {
|
|
513
|
+
h = s = 0;
|
|
514
|
+
} else {
|
|
515
|
+
const d = max - min;
|
|
516
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
517
|
+
|
|
518
|
+
switch (max) {
|
|
519
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
520
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
521
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
h = Math.round(h * 360 * 10) / 10;
|
|
526
|
+
s = Math.round(s * 100 * 10) / 10;
|
|
527
|
+
l = Math.round(l * 100 * 10) / 10;
|
|
528
|
+
|
|
529
|
+
return `${h} ${s}% ${l}%`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Convert HSL string to hex color
|
|
534
|
+
* @param {string} hsl - HSL string (e.g., "346.8 77.2% 49.8%")
|
|
535
|
+
* @returns {string} Hex color (e.g., "#E11D48")
|
|
536
|
+
*/
|
|
537
|
+
export function hslToHex(hsl) {
|
|
538
|
+
const [h, s, l] = hsl.split(/\s+/).map((v, i) => {
|
|
539
|
+
const num = parseFloat(v);
|
|
540
|
+
return i === 0 ? num / 360 : num / 100;
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
let r, g, b;
|
|
544
|
+
|
|
545
|
+
if (s === 0) {
|
|
546
|
+
r = g = b = l;
|
|
547
|
+
} else {
|
|
548
|
+
const hue2rgb = (p, q, t) => {
|
|
549
|
+
if (t < 0) t += 1;
|
|
550
|
+
if (t > 1) t -= 1;
|
|
551
|
+
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
552
|
+
if (t < 1/2) return q;
|
|
553
|
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
554
|
+
return p;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
558
|
+
const p = 2 * l - q;
|
|
559
|
+
|
|
560
|
+
r = hue2rgb(p, q, h + 1/3);
|
|
561
|
+
g = hue2rgb(p, q, h);
|
|
562
|
+
b = hue2rgb(p, q, h - 1/3);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const toHex = x => {
|
|
566
|
+
const hex = Math.round(x * 255).toString(16);
|
|
567
|
+
return hex.length === 1 ? '0' + hex : hex;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ============================================================================
|
|
574
|
+
// THEME REBUILD & ROLLBACK FUNCTIONS
|
|
575
|
+
// ============================================================================
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Directories to scan for color references (standard React/Next.js paths)
|
|
579
|
+
*/
|
|
580
|
+
export const SCAN_DIRECTORIES = ['app', 'components', 'src', 'pages'];
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* File extensions to scan
|
|
584
|
+
*/
|
|
585
|
+
export const SCAN_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Directories to always exclude from scanning
|
|
589
|
+
*/
|
|
590
|
+
export const EXCLUDE_DIRS = ['node_modules', '.git', '.omgkit', 'dist', 'build', '.next', 'out'];
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Color patterns to detect non-compliant colors
|
|
594
|
+
*/
|
|
595
|
+
export const COLOR_PATTERNS = {
|
|
596
|
+
// Tailwind default colors (should use theme vars)
|
|
597
|
+
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,
|
|
598
|
+
|
|
599
|
+
// Hardcoded hex colors in className or style
|
|
600
|
+
hexColors: /#([0-9A-Fa-f]{3}){1,2}\b/g,
|
|
601
|
+
|
|
602
|
+
// Hardcoded RGB/HSL in styles
|
|
603
|
+
rgbHsl: /\b(rgb|hsl)a?\([^)]+\)/g
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Mapping of hardcoded Tailwind colors to theme variables
|
|
608
|
+
*/
|
|
609
|
+
export const THEME_VAR_MAP = {
|
|
610
|
+
// Background mappings
|
|
611
|
+
'bg-white': 'bg-background',
|
|
612
|
+
'bg-gray-50': 'bg-muted',
|
|
613
|
+
'bg-gray-100': 'bg-muted',
|
|
614
|
+
'bg-gray-200': 'bg-muted',
|
|
615
|
+
'bg-gray-900': 'bg-foreground',
|
|
616
|
+
'bg-slate-50': 'bg-muted',
|
|
617
|
+
'bg-slate-100': 'bg-muted',
|
|
618
|
+
'bg-slate-900': 'bg-foreground',
|
|
619
|
+
'bg-zinc-50': 'bg-muted',
|
|
620
|
+
'bg-zinc-100': 'bg-muted',
|
|
621
|
+
'bg-zinc-900': 'bg-foreground',
|
|
622
|
+
|
|
623
|
+
// Text mappings
|
|
624
|
+
'text-black': 'text-foreground',
|
|
625
|
+
'text-white': 'text-background',
|
|
626
|
+
'text-gray-900': 'text-foreground',
|
|
627
|
+
'text-gray-800': 'text-foreground',
|
|
628
|
+
'text-gray-700': 'text-foreground',
|
|
629
|
+
'text-gray-600': 'text-muted-foreground',
|
|
630
|
+
'text-gray-500': 'text-muted-foreground',
|
|
631
|
+
'text-gray-400': 'text-muted-foreground',
|
|
632
|
+
'text-slate-900': 'text-foreground',
|
|
633
|
+
'text-slate-600': 'text-muted-foreground',
|
|
634
|
+
'text-slate-500': 'text-muted-foreground',
|
|
635
|
+
'text-zinc-900': 'text-foreground',
|
|
636
|
+
'text-zinc-600': 'text-muted-foreground',
|
|
637
|
+
'text-zinc-500': 'text-muted-foreground',
|
|
638
|
+
|
|
639
|
+
// Border mappings
|
|
640
|
+
'border-gray-100': 'border-border',
|
|
641
|
+
'border-gray-200': 'border-border',
|
|
642
|
+
'border-gray-300': 'border-input',
|
|
643
|
+
'border-slate-200': 'border-border',
|
|
644
|
+
'border-slate-300': 'border-input',
|
|
645
|
+
'border-zinc-200': 'border-border',
|
|
646
|
+
'border-zinc-300': 'border-input',
|
|
647
|
+
|
|
648
|
+
// Primary/accent colors (common patterns)
|
|
649
|
+
'bg-blue-500': 'bg-primary',
|
|
650
|
+
'bg-blue-600': 'bg-primary',
|
|
651
|
+
'bg-blue-700': 'bg-primary',
|
|
652
|
+
'text-blue-500': 'text-primary',
|
|
653
|
+
'text-blue-600': 'text-primary',
|
|
654
|
+
'text-blue-700': 'text-primary',
|
|
655
|
+
'ring-blue-500': 'ring-ring',
|
|
656
|
+
'ring-blue-600': 'ring-ring',
|
|
657
|
+
|
|
658
|
+
// Destructive
|
|
659
|
+
'bg-red-500': 'bg-destructive',
|
|
660
|
+
'bg-red-600': 'bg-destructive',
|
|
661
|
+
'text-red-500': 'text-destructive',
|
|
662
|
+
'text-red-600': 'text-destructive',
|
|
663
|
+
'border-red-500': 'border-destructive',
|
|
664
|
+
|
|
665
|
+
// Secondary/accent patterns
|
|
666
|
+
'bg-gray-100': 'bg-secondary',
|
|
667
|
+
'bg-slate-100': 'bg-secondary',
|
|
668
|
+
'hover:bg-gray-100': 'hover:bg-accent',
|
|
669
|
+
'hover:bg-slate-100': 'hover:bg-accent'
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Recursively get all files in a directory
|
|
674
|
+
* @param {string} dir - Directory to scan
|
|
675
|
+
* @param {string[]} extensions - File extensions to include
|
|
676
|
+
* @param {string[]} excludeDirs - Directories to exclude
|
|
677
|
+
* @returns {string[]} Array of file paths
|
|
678
|
+
*/
|
|
679
|
+
function getFilesRecursive(dir, extensions, excludeDirs) {
|
|
680
|
+
const files = [];
|
|
681
|
+
if (!existsSync(dir)) return files;
|
|
682
|
+
|
|
683
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
684
|
+
|
|
685
|
+
for (const entry of entries) {
|
|
686
|
+
const fullPath = join(dir, entry.name);
|
|
687
|
+
|
|
688
|
+
if (entry.isDirectory()) {
|
|
689
|
+
if (!excludeDirs.includes(entry.name)) {
|
|
690
|
+
files.push(...getFilesRecursive(fullPath, extensions, excludeDirs));
|
|
691
|
+
}
|
|
692
|
+
} else if (entry.isFile()) {
|
|
693
|
+
const ext = '.' + entry.name.split('.').pop();
|
|
694
|
+
if (extensions.includes(ext)) {
|
|
695
|
+
files.push(fullPath);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return files;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Scan project for files with non-compliant color references
|
|
705
|
+
* @param {string} projectDir - Project root directory
|
|
706
|
+
* @returns {Object} { files: [], totalReferences: number, nonCompliant: [], compliant: number }
|
|
707
|
+
*/
|
|
708
|
+
export function scanProjectColors(projectDir) {
|
|
709
|
+
const result = {
|
|
710
|
+
files: [],
|
|
711
|
+
totalReferences: 0,
|
|
712
|
+
nonCompliant: [],
|
|
713
|
+
compliant: 0,
|
|
714
|
+
scannedFiles: 0
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// Find all files to scan
|
|
718
|
+
const filesToScan = [];
|
|
719
|
+
for (const scanDir of SCAN_DIRECTORIES) {
|
|
720
|
+
const fullPath = join(projectDir, scanDir);
|
|
721
|
+
if (existsSync(fullPath)) {
|
|
722
|
+
filesToScan.push(...getFilesRecursive(fullPath, SCAN_EXTENSIONS, EXCLUDE_DIRS));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
result.scannedFiles = filesToScan.length;
|
|
727
|
+
|
|
728
|
+
// Scan each file
|
|
729
|
+
for (const filePath of filesToScan) {
|
|
730
|
+
try {
|
|
731
|
+
const content = readFileSync(filePath, 'utf8');
|
|
732
|
+
const lines = content.split('\n');
|
|
733
|
+
const relativePath = filePath.replace(projectDir + '/', '');
|
|
734
|
+
const fileMatches = [];
|
|
735
|
+
|
|
736
|
+
for (let i = 0; i < lines.length; i++) {
|
|
737
|
+
const line = lines[i];
|
|
738
|
+
const lineNum = i + 1;
|
|
739
|
+
|
|
740
|
+
// Check for Tailwind default colors
|
|
741
|
+
let match;
|
|
742
|
+
const pattern = new RegExp(COLOR_PATTERNS.tailwindDefaults.source, 'g');
|
|
743
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
744
|
+
result.totalReferences++;
|
|
745
|
+
const fullMatch = match[0];
|
|
746
|
+
|
|
747
|
+
// Check if this has a theme-compliant mapping
|
|
748
|
+
const mapping = THEME_VAR_MAP[fullMatch];
|
|
749
|
+
if (mapping) {
|
|
750
|
+
fileMatches.push({
|
|
751
|
+
file: relativePath,
|
|
752
|
+
line: lineNum,
|
|
753
|
+
column: match.index,
|
|
754
|
+
match: fullMatch,
|
|
755
|
+
suggestion: mapping,
|
|
756
|
+
type: 'tailwind-default',
|
|
757
|
+
fixable: true
|
|
758
|
+
});
|
|
759
|
+
result.nonCompliant.push({
|
|
760
|
+
file: relativePath,
|
|
761
|
+
line: lineNum,
|
|
762
|
+
match: fullMatch,
|
|
763
|
+
suggestion: mapping
|
|
764
|
+
});
|
|
765
|
+
} else {
|
|
766
|
+
// Unmapped color - warn only
|
|
767
|
+
fileMatches.push({
|
|
768
|
+
file: relativePath,
|
|
769
|
+
line: lineNum,
|
|
770
|
+
column: match.index,
|
|
771
|
+
match: fullMatch,
|
|
772
|
+
suggestion: null,
|
|
773
|
+
type: 'unmapped',
|
|
774
|
+
fixable: false
|
|
775
|
+
});
|
|
776
|
+
result.nonCompliant.push({
|
|
777
|
+
file: relativePath,
|
|
778
|
+
line: lineNum,
|
|
779
|
+
match: fullMatch,
|
|
780
|
+
suggestion: null
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Check for hex colors in className or style attributes
|
|
786
|
+
const hexPattern = new RegExp(COLOR_PATTERNS.hexColors.source, 'g');
|
|
787
|
+
while ((match = hexPattern.exec(line)) !== null) {
|
|
788
|
+
// Only flag if it appears to be in className or style context
|
|
789
|
+
const before = line.slice(0, match.index);
|
|
790
|
+
if (before.includes('className') || before.includes('style') || before.includes('bg-[') || before.includes('text-[')) {
|
|
791
|
+
result.totalReferences++;
|
|
792
|
+
fileMatches.push({
|
|
793
|
+
file: relativePath,
|
|
794
|
+
line: lineNum,
|
|
795
|
+
column: match.index,
|
|
796
|
+
match: match[0],
|
|
797
|
+
suggestion: 'Use CSS variable (e.g., bg-background)',
|
|
798
|
+
type: 'hex-color',
|
|
799
|
+
fixable: false
|
|
800
|
+
});
|
|
801
|
+
result.nonCompliant.push({
|
|
802
|
+
file: relativePath,
|
|
803
|
+
line: lineNum,
|
|
804
|
+
match: match[0],
|
|
805
|
+
suggestion: 'Use CSS variable'
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (fileMatches.length > 0) {
|
|
812
|
+
result.files.push({
|
|
813
|
+
path: relativePath,
|
|
814
|
+
matches: fileMatches
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
} catch (err) {
|
|
818
|
+
// Skip files that can't be read
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
result.compliant = result.totalReferences - result.nonCompliant.length;
|
|
823
|
+
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Create a theme backup before rebuild
|
|
829
|
+
* @param {string} projectDir - Project root directory
|
|
830
|
+
* @param {string} newThemeId - ID of new theme being applied
|
|
831
|
+
* @returns {Object} { success, backupId, backupPath, error }
|
|
832
|
+
*/
|
|
833
|
+
export function createThemeBackup(projectDir, newThemeId = 'unknown') {
|
|
834
|
+
const designDir = join(projectDir, '.omgkit', 'design');
|
|
835
|
+
const backupsDir = join(designDir, 'backups');
|
|
836
|
+
|
|
837
|
+
// Create backups directory
|
|
838
|
+
mkdirSync(backupsDir, { recursive: true });
|
|
839
|
+
|
|
840
|
+
// Generate backup ID
|
|
841
|
+
const now = new Date();
|
|
842
|
+
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
843
|
+
const backupId = `${timestamp}-${newThemeId}`;
|
|
844
|
+
const backupPath = join(backupsDir, backupId);
|
|
845
|
+
|
|
846
|
+
try {
|
|
847
|
+
mkdirSync(backupPath, { recursive: true });
|
|
848
|
+
|
|
849
|
+
// Get current theme
|
|
850
|
+
const currentTheme = getProjectTheme(projectDir);
|
|
851
|
+
const previousThemeId = currentTheme?.id || 'none';
|
|
852
|
+
|
|
853
|
+
// Create manifest
|
|
854
|
+
const manifest = {
|
|
855
|
+
id: backupId,
|
|
856
|
+
previousTheme: previousThemeId,
|
|
857
|
+
newTheme: newThemeId,
|
|
858
|
+
timestamp: now.toISOString(),
|
|
859
|
+
changedFiles: []
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
// Backup theme.json if exists
|
|
863
|
+
const themeJsonPath = join(designDir, 'theme.json');
|
|
864
|
+
if (existsSync(themeJsonPath)) {
|
|
865
|
+
const content = readFileSync(themeJsonPath, 'utf8');
|
|
866
|
+
writeFileSync(join(backupPath, 'theme.json.bak'), content);
|
|
867
|
+
manifest.changedFiles.push({ path: '.omgkit/design/theme.json', backup: 'theme.json.bak' });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Backup theme.css if exists
|
|
871
|
+
const themeCssPath = join(designDir, 'theme.css');
|
|
872
|
+
if (existsSync(themeCssPath)) {
|
|
873
|
+
const content = readFileSync(themeCssPath, 'utf8');
|
|
874
|
+
writeFileSync(join(backupPath, 'theme.css.bak'), content);
|
|
875
|
+
manifest.changedFiles.push({ path: '.omgkit/design/theme.css', backup: 'theme.css.bak' });
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Backup tailwind.config.ts if exists
|
|
879
|
+
const tailwindConfigPath = join(projectDir, 'tailwind.config.ts');
|
|
880
|
+
if (existsSync(tailwindConfigPath)) {
|
|
881
|
+
const content = readFileSync(tailwindConfigPath, 'utf8');
|
|
882
|
+
writeFileSync(join(backupPath, 'tailwind.config.ts.bak'), content);
|
|
883
|
+
manifest.changedFiles.push({ path: 'tailwind.config.ts', backup: 'tailwind.config.ts.bak' });
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Also check for .js version
|
|
887
|
+
const tailwindConfigJsPath = join(projectDir, 'tailwind.config.js');
|
|
888
|
+
if (existsSync(tailwindConfigJsPath)) {
|
|
889
|
+
const content = readFileSync(tailwindConfigJsPath, 'utf8');
|
|
890
|
+
writeFileSync(join(backupPath, 'tailwind.config.js.bak'), content);
|
|
891
|
+
manifest.changedFiles.push({ path: 'tailwind.config.js', backup: 'tailwind.config.js.bak' });
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Write manifest
|
|
895
|
+
writeFileSync(join(backupPath, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
896
|
+
|
|
897
|
+
return { success: true, backupId, backupPath, manifest };
|
|
898
|
+
} catch (err) {
|
|
899
|
+
return { success: false, error: err.message };
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* List available theme backups
|
|
905
|
+
* @param {string} projectDir - Project root directory
|
|
906
|
+
* @returns {Array} Array of backup info objects
|
|
907
|
+
*/
|
|
908
|
+
export function listThemeBackups(projectDir) {
|
|
909
|
+
const backupsDir = join(projectDir, '.omgkit', 'design', 'backups');
|
|
910
|
+
if (!existsSync(backupsDir)) return [];
|
|
911
|
+
|
|
912
|
+
const backups = [];
|
|
913
|
+
const entries = readdirSync(backupsDir, { withFileTypes: true });
|
|
914
|
+
|
|
915
|
+
for (const entry of entries) {
|
|
916
|
+
if (!entry.isDirectory()) continue;
|
|
917
|
+
|
|
918
|
+
const manifestPath = join(backupsDir, entry.name, 'manifest.json');
|
|
919
|
+
if (!existsSync(manifestPath)) continue;
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
923
|
+
backups.push({
|
|
924
|
+
id: manifest.id,
|
|
925
|
+
previousTheme: manifest.previousTheme,
|
|
926
|
+
newTheme: manifest.newTheme,
|
|
927
|
+
timestamp: manifest.timestamp,
|
|
928
|
+
date: new Date(manifest.timestamp).toLocaleString(),
|
|
929
|
+
filesChanged: manifest.changedFiles.length,
|
|
930
|
+
path: join(backupsDir, entry.name)
|
|
931
|
+
});
|
|
932
|
+
} catch {
|
|
933
|
+
// Skip invalid backup
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Sort by timestamp descending (newest first)
|
|
938
|
+
return backups.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Rollback to a previous theme state
|
|
943
|
+
* @param {string} projectDir - Project root directory
|
|
944
|
+
* @param {string} backupId - Backup ID to restore (optional, defaults to latest)
|
|
945
|
+
* @returns {Object} { success, restoredTheme, restoredFiles, error }
|
|
946
|
+
*/
|
|
947
|
+
export function rollbackTheme(projectDir, backupId = null) {
|
|
948
|
+
const backups = listThemeBackups(projectDir);
|
|
949
|
+
|
|
950
|
+
if (backups.length === 0) {
|
|
951
|
+
return { success: false, error: 'No theme backups found' };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Find backup to restore
|
|
955
|
+
let backup;
|
|
956
|
+
if (backupId) {
|
|
957
|
+
backup = backups.find(b => b.id === backupId);
|
|
958
|
+
if (!backup) {
|
|
959
|
+
return { success: false, error: `Backup not found: ${backupId}` };
|
|
960
|
+
}
|
|
961
|
+
} else {
|
|
962
|
+
backup = backups[0]; // Latest
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
const manifestPath = join(backup.path, 'manifest.json');
|
|
967
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
968
|
+
const restoredFiles = [];
|
|
969
|
+
|
|
970
|
+
// Create a new backup before rollback (safety)
|
|
971
|
+
createThemeBackup(projectDir, `rollback-from-${manifest.newTheme}`);
|
|
972
|
+
|
|
973
|
+
// Restore each file
|
|
974
|
+
for (const file of manifest.changedFiles) {
|
|
975
|
+
const backupFilePath = join(backup.path, file.backup);
|
|
976
|
+
const targetPath = join(projectDir, file.path);
|
|
977
|
+
|
|
978
|
+
if (existsSync(backupFilePath)) {
|
|
979
|
+
const content = readFileSync(backupFilePath, 'utf8');
|
|
980
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
981
|
+
writeFileSync(targetPath, content);
|
|
982
|
+
restoredFiles.push(file.path);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
success: true,
|
|
988
|
+
restoredTheme: manifest.previousTheme,
|
|
989
|
+
restoredFiles,
|
|
990
|
+
backupUsed: backup.id
|
|
991
|
+
};
|
|
992
|
+
} catch (err) {
|
|
993
|
+
return { success: false, error: err.message };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Update file replacing hardcoded colors with theme variables
|
|
999
|
+
* @param {string} filePath - File to update
|
|
1000
|
+
* @param {string} projectDir - Project root directory
|
|
1001
|
+
* @returns {Object} { changed, replacements, content }
|
|
1002
|
+
*/
|
|
1003
|
+
export function updateFileColors(filePath, projectDir) {
|
|
1004
|
+
const fullPath = join(projectDir, filePath);
|
|
1005
|
+
if (!existsSync(fullPath)) {
|
|
1006
|
+
return { changed: false, replacements: [], error: 'File not found' };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
let content = readFileSync(fullPath, 'utf8');
|
|
1010
|
+
const replacements = [];
|
|
1011
|
+
let changed = false;
|
|
1012
|
+
|
|
1013
|
+
// Apply theme variable mappings
|
|
1014
|
+
for (const [pattern, replacement] of Object.entries(THEME_VAR_MAP)) {
|
|
1015
|
+
// Escape special regex characters in pattern
|
|
1016
|
+
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1017
|
+
const regex = new RegExp(`\\b${escapedPattern}\\b`, 'g');
|
|
1018
|
+
|
|
1019
|
+
const matches = content.match(regex);
|
|
1020
|
+
if (matches && matches.length > 0) {
|
|
1021
|
+
content = content.replace(regex, replacement);
|
|
1022
|
+
replacements.push({
|
|
1023
|
+
from: pattern,
|
|
1024
|
+
to: replacement,
|
|
1025
|
+
count: matches.length
|
|
1026
|
+
});
|
|
1027
|
+
changed = true;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return { changed, replacements, content };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Update project's tailwind.config file with new theme
|
|
1036
|
+
* @param {Object} theme - Theme object
|
|
1037
|
+
* @param {string} projectDir - Project root directory
|
|
1038
|
+
* @returns {Object} { success, path, error }
|
|
1039
|
+
*/
|
|
1040
|
+
export function updateProjectTailwindConfig(theme, projectDir) {
|
|
1041
|
+
// Check for tailwind.config.ts first, then .js
|
|
1042
|
+
let configPath = join(projectDir, 'tailwind.config.ts');
|
|
1043
|
+
let isTs = true;
|
|
1044
|
+
|
|
1045
|
+
if (!existsSync(configPath)) {
|
|
1046
|
+
configPath = join(projectDir, 'tailwind.config.js');
|
|
1047
|
+
isTs = false;
|
|
1048
|
+
if (!existsSync(configPath)) {
|
|
1049
|
+
// Create new config
|
|
1050
|
+
configPath = join(projectDir, 'tailwind.config.ts');
|
|
1051
|
+
isTs = true;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
const newConfig = generateTailwindConfig(theme);
|
|
1057
|
+
writeFileSync(configPath, newConfig);
|
|
1058
|
+
return { success: true, path: configPath, isTs };
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
return { success: false, error: err.message };
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Ensure globals.css imports theme.css
|
|
1066
|
+
* @param {string} projectDir - Project root directory
|
|
1067
|
+
* @returns {Object} { updated, path, alreadyImported }
|
|
1068
|
+
*/
|
|
1069
|
+
export function ensureThemeImport(projectDir) {
|
|
1070
|
+
// Look for globals.css in common locations
|
|
1071
|
+
const possiblePaths = [
|
|
1072
|
+
join(projectDir, 'app', 'globals.css'),
|
|
1073
|
+
join(projectDir, 'src', 'app', 'globals.css'),
|
|
1074
|
+
join(projectDir, 'styles', 'globals.css'),
|
|
1075
|
+
join(projectDir, 'src', 'styles', 'globals.css')
|
|
1076
|
+
];
|
|
1077
|
+
|
|
1078
|
+
let globalsPath = null;
|
|
1079
|
+
for (const p of possiblePaths) {
|
|
1080
|
+
if (existsSync(p)) {
|
|
1081
|
+
globalsPath = p;
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (!globalsPath) {
|
|
1087
|
+
return { updated: false, path: null, error: 'globals.css not found' };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
let content = readFileSync(globalsPath, 'utf8');
|
|
1091
|
+
const themeImport = "@import '../.omgkit/design/theme.css';";
|
|
1092
|
+
const altThemeImport = "@import '../../.omgkit/design/theme.css';";
|
|
1093
|
+
|
|
1094
|
+
// Check if already imported
|
|
1095
|
+
if (content.includes('.omgkit/design/theme.css')) {
|
|
1096
|
+
return { updated: false, path: globalsPath, alreadyImported: true };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Add import at the beginning
|
|
1100
|
+
const relativePath = globalsPath.includes('/src/') ? altThemeImport : themeImport;
|
|
1101
|
+
content = `${relativePath}\n${content}`;
|
|
1102
|
+
|
|
1103
|
+
writeFileSync(globalsPath, content);
|
|
1104
|
+
return { updated: true, path: globalsPath, alreadyImported: false };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Rebuild entire project with a new theme
|
|
1109
|
+
* @param {string} projectDir - Project root directory
|
|
1110
|
+
* @param {string} themeId - New theme ID
|
|
1111
|
+
* @param {Object} options - { dryRun, force, fixColors }
|
|
1112
|
+
* @returns {Object} { success, backupPath, changedFiles, warnings, error }
|
|
1113
|
+
*/
|
|
1114
|
+
export function rebuildProjectTheme(projectDir, themeId, options = {}) {
|
|
1115
|
+
const { dryRun = false, force = false, fixColors = true } = options;
|
|
1116
|
+
|
|
1117
|
+
// Validate project has .omgkit
|
|
1118
|
+
if (!existsSync(join(projectDir, '.omgkit'))) {
|
|
1119
|
+
return { success: false, error: 'Not an OMGKIT project. Run: omgkit init' };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Get new theme
|
|
1123
|
+
const newTheme = getThemeById(themeId);
|
|
1124
|
+
if (!newTheme) {
|
|
1125
|
+
return { success: false, error: `Theme not found: ${themeId}. Run /design:themes to see available themes.` };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Validate theme
|
|
1129
|
+
const validation = validateTheme(newTheme);
|
|
1130
|
+
if (!validation.valid) {
|
|
1131
|
+
return { success: false, error: `Invalid theme: ${validation.errors.join(', ')}` };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const result = {
|
|
1135
|
+
success: true,
|
|
1136
|
+
newTheme: themeId,
|
|
1137
|
+
backupId: null,
|
|
1138
|
+
backupPath: null,
|
|
1139
|
+
changedFiles: [],
|
|
1140
|
+
fixedColors: [],
|
|
1141
|
+
warnings: [],
|
|
1142
|
+
dryRun
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
// Step 1: Create backup (unless dry-run)
|
|
1146
|
+
if (!dryRun) {
|
|
1147
|
+
const backup = createThemeBackup(projectDir, themeId);
|
|
1148
|
+
if (!backup.success) {
|
|
1149
|
+
return { success: false, error: `Failed to create backup: ${backup.error}` };
|
|
1150
|
+
}
|
|
1151
|
+
result.backupId = backup.backupId;
|
|
1152
|
+
result.backupPath = backup.backupPath;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Step 2: Apply new theme
|
|
1156
|
+
if (!dryRun) {
|
|
1157
|
+
const applied = applyThemeToProject(newTheme, projectDir);
|
|
1158
|
+
result.changedFiles.push(applied.themeJson.replace(projectDir + '/', ''));
|
|
1159
|
+
result.changedFiles.push(applied.themeCss.replace(projectDir + '/', ''));
|
|
1160
|
+
} else {
|
|
1161
|
+
result.changedFiles.push('.omgkit/design/theme.json');
|
|
1162
|
+
result.changedFiles.push('.omgkit/design/theme.css');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Step 3: Update tailwind config
|
|
1166
|
+
if (!dryRun) {
|
|
1167
|
+
const tailwindResult = updateProjectTailwindConfig(newTheme, projectDir);
|
|
1168
|
+
if (tailwindResult.success) {
|
|
1169
|
+
result.changedFiles.push(tailwindResult.path.replace(projectDir + '/', ''));
|
|
1170
|
+
}
|
|
1171
|
+
} else {
|
|
1172
|
+
result.changedFiles.push('tailwind.config.ts');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Step 4: Ensure theme import in globals.css
|
|
1176
|
+
if (!dryRun) {
|
|
1177
|
+
const importResult = ensureThemeImport(projectDir);
|
|
1178
|
+
if (importResult.updated) {
|
|
1179
|
+
result.changedFiles.push(importResult.path.replace(projectDir + '/', ''));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Step 5: Scan and fix colors (if enabled)
|
|
1184
|
+
if (fixColors) {
|
|
1185
|
+
const scanResult = scanProjectColors(projectDir);
|
|
1186
|
+
|
|
1187
|
+
for (const fileInfo of scanResult.files) {
|
|
1188
|
+
const fixableMatches = fileInfo.matches.filter(m => m.fixable);
|
|
1189
|
+
|
|
1190
|
+
if (fixableMatches.length > 0) {
|
|
1191
|
+
if (!dryRun) {
|
|
1192
|
+
const updateResult = updateFileColors(fileInfo.path, projectDir);
|
|
1193
|
+
if (updateResult.changed) {
|
|
1194
|
+
// Write updated content
|
|
1195
|
+
writeFileSync(join(projectDir, fileInfo.path), updateResult.content);
|
|
1196
|
+
result.changedFiles.push(fileInfo.path);
|
|
1197
|
+
result.fixedColors.push({
|
|
1198
|
+
file: fileInfo.path,
|
|
1199
|
+
replacements: updateResult.replacements
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
} else {
|
|
1203
|
+
// Dry run - just report what would be changed
|
|
1204
|
+
result.fixedColors.push({
|
|
1205
|
+
file: fileInfo.path,
|
|
1206
|
+
replacements: fixableMatches.map(m => ({ from: m.match, to: m.suggestion }))
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Add warnings for unfixable colors
|
|
1212
|
+
const unfixable = fileInfo.matches.filter(m => !m.fixable);
|
|
1213
|
+
for (const u of unfixable) {
|
|
1214
|
+
result.warnings.push(`${u.file}:${u.line} - ${u.match} (manual review needed)`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return result;
|
|
1220
|
+
}
|