sonance-brand-mcp 1.3.51 → 1.3.55
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.
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Discovery Module for Sonance DevTools
|
|
3
|
+
*
|
|
4
|
+
* Dynamically discovers and parses theme/design system files from target codebases.
|
|
5
|
+
* Extracts CSS variables, Tailwind colors, and available semantic tokens to provide
|
|
6
|
+
* context to the LLM for intelligent styling decisions.
|
|
7
|
+
*
|
|
8
|
+
* NEW: Includes WCAG contrast ratio calculation to warn about bad color combinations.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// TYPE DEFINITIONS
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* RGB color representation
|
|
20
|
+
*/
|
|
21
|
+
interface RGB {
|
|
22
|
+
r: number;
|
|
23
|
+
g: number;
|
|
24
|
+
b: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Contrast warning for a failing color combination
|
|
29
|
+
*/
|
|
30
|
+
interface ContrastWarning {
|
|
31
|
+
background: string;
|
|
32
|
+
text: string;
|
|
33
|
+
ratio: string;
|
|
34
|
+
suggestion: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Safe color pattern that passes WCAG
|
|
39
|
+
*/
|
|
40
|
+
interface SafePattern {
|
|
41
|
+
background: string;
|
|
42
|
+
text: string;
|
|
43
|
+
ratio: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Result of contrast analysis
|
|
48
|
+
*/
|
|
49
|
+
interface ContrastAnalysis {
|
|
50
|
+
warnings: ContrastWarning[];
|
|
51
|
+
safePatterns: SafePattern[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Discovered theme information from a codebase
|
|
56
|
+
*/
|
|
57
|
+
export interface DiscoveredTheme {
|
|
58
|
+
// Raw CSS variable definitions found (e.g., { "--accent": "#00D3C8" })
|
|
59
|
+
cssVariables: Record<string, string>;
|
|
60
|
+
|
|
61
|
+
// Tailwind custom colors from config (if found)
|
|
62
|
+
tailwindColors: Record<string, string>;
|
|
63
|
+
|
|
64
|
+
// Available semantic class tokens discovered
|
|
65
|
+
availableTokens: string[];
|
|
66
|
+
|
|
67
|
+
// Full raw theme CSS content for additional context
|
|
68
|
+
rawThemeCSS: string;
|
|
69
|
+
|
|
70
|
+
// Files that were discovered and parsed
|
|
71
|
+
discoveredFiles: string[];
|
|
72
|
+
|
|
73
|
+
// Contrast analysis results
|
|
74
|
+
contrastAnalysis?: ContrastAnalysis;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// COLOR PARSING FUNCTIONS
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert HSL values to RGB
|
|
83
|
+
* Handles formats: "195 100% 41%" or "hsl(195, 100%, 41%)" or "195deg 100% 41%"
|
|
84
|
+
*/
|
|
85
|
+
function hslToRgb(h: number, s: number, l: number): RGB {
|
|
86
|
+
// Normalize h to 0-360, s and l to 0-1
|
|
87
|
+
h = ((h % 360) + 360) % 360;
|
|
88
|
+
s = Math.max(0, Math.min(1, s / 100));
|
|
89
|
+
l = Math.max(0, Math.min(1, l / 100));
|
|
90
|
+
|
|
91
|
+
const k = (n: number) => (n + h / 30) % 12;
|
|
92
|
+
const a = s * Math.min(l, 1 - l);
|
|
93
|
+
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
r: Math.round(f(0) * 255),
|
|
97
|
+
g: Math.round(f(8) * 255),
|
|
98
|
+
b: Math.round(f(4) * 255),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse hex color to RGB
|
|
104
|
+
* Handles: #fff, #ffffff, fff, ffffff
|
|
105
|
+
*/
|
|
106
|
+
function hexToRgb(hex: string): RGB | null {
|
|
107
|
+
// Remove # if present
|
|
108
|
+
hex = hex.replace(/^#/, "");
|
|
109
|
+
|
|
110
|
+
// Expand shorthand (e.g., "fff" -> "ffffff")
|
|
111
|
+
if (hex.length === 3) {
|
|
112
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (hex.length !== 6) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
120
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
121
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
122
|
+
|
|
123
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { r, g, b };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse CSS color value to RGB
|
|
132
|
+
* Handles multiple formats:
|
|
133
|
+
* - Hex: #fff, #ffffff
|
|
134
|
+
* - RGB: rgb(255, 255, 255), rgb(255 255 255)
|
|
135
|
+
* - HSL: hsl(195, 100%, 41%), hsl(195 100% 41%)
|
|
136
|
+
* - Space-separated HSL: 195 100% 41% (common in Tailwind/shadcn)
|
|
137
|
+
* - oklch: oklch(0.7 0.15 195) - approximated
|
|
138
|
+
*/
|
|
139
|
+
function parseColorToRgb(value: string): RGB | null {
|
|
140
|
+
if (!value || typeof value !== "string") {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const trimmed = value.trim().toLowerCase();
|
|
145
|
+
|
|
146
|
+
// Skip CSS variable references - we can't resolve these without runtime
|
|
147
|
+
if (trimmed.includes("var(")) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle hex colors
|
|
152
|
+
if (trimmed.startsWith("#") || /^[0-9a-f]{3,6}$/i.test(trimmed)) {
|
|
153
|
+
return hexToRgb(trimmed);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Handle rgb() and rgba()
|
|
157
|
+
const rgbMatch = trimmed.match(/rgba?\s*\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
|
|
158
|
+
if (rgbMatch) {
|
|
159
|
+
return {
|
|
160
|
+
r: parseInt(rgbMatch[1], 10),
|
|
161
|
+
g: parseInt(rgbMatch[2], 10),
|
|
162
|
+
b: parseInt(rgbMatch[3], 10),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Handle hsl() and hsla()
|
|
167
|
+
const hslFuncMatch = trimmed.match(/hsla?\s*\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/);
|
|
168
|
+
if (hslFuncMatch) {
|
|
169
|
+
const h = parseFloat(hslFuncMatch[1]);
|
|
170
|
+
const s = parseFloat(hslFuncMatch[2]);
|
|
171
|
+
const l = parseFloat(hslFuncMatch[3]);
|
|
172
|
+
return hslToRgb(h, s, l);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle space-separated HSL (common in Tailwind/shadcn): "195 100% 41%"
|
|
176
|
+
const hslSpaceMatch = trimmed.match(/^([\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/);
|
|
177
|
+
if (hslSpaceMatch) {
|
|
178
|
+
const h = parseFloat(hslSpaceMatch[1]);
|
|
179
|
+
const s = parseFloat(hslSpaceMatch[2]);
|
|
180
|
+
const l = parseFloat(hslSpaceMatch[3]);
|
|
181
|
+
return hslToRgb(h, s, l);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle oklch() - approximate conversion (not perfect but gives reasonable results)
|
|
185
|
+
const oklchMatch = trimmed.match(/oklch\s*\(\s*([\d.]+)(?:%?)\s+([\d.]+)\s+([\d.]+)/);
|
|
186
|
+
if (oklchMatch) {
|
|
187
|
+
// Simplified oklch to RGB approximation
|
|
188
|
+
// L is lightness (0-1 or 0-100%), C is chroma, H is hue
|
|
189
|
+
let l = parseFloat(oklchMatch[1]);
|
|
190
|
+
if (l > 1) l = l / 100; // Handle percentage format
|
|
191
|
+
const c = parseFloat(oklchMatch[2]);
|
|
192
|
+
const h = parseFloat(oklchMatch[3]);
|
|
193
|
+
|
|
194
|
+
// Very rough approximation: treat as HSL with adjusted values
|
|
195
|
+
// This won't be perfect but gives a reasonable estimate for contrast
|
|
196
|
+
const approxS = Math.min(100, c * 300); // Chroma to saturation approximation
|
|
197
|
+
const approxL = l * 100;
|
|
198
|
+
return hslToRgb(h, approxS, approxL);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Handle named colors (common ones)
|
|
202
|
+
const namedColors: Record<string, RGB> = {
|
|
203
|
+
white: { r: 255, g: 255, b: 255 },
|
|
204
|
+
black: { r: 0, g: 0, b: 0 },
|
|
205
|
+
transparent: { r: 0, g: 0, b: 0 }, // Treat as black for contrast purposes
|
|
206
|
+
red: { r: 255, g: 0, b: 0 },
|
|
207
|
+
green: { r: 0, g: 128, b: 0 },
|
|
208
|
+
blue: { r: 0, g: 0, b: 255 },
|
|
209
|
+
yellow: { r: 255, g: 255, b: 0 },
|
|
210
|
+
cyan: { r: 0, g: 255, b: 255 },
|
|
211
|
+
magenta: { r: 255, g: 0, b: 255 },
|
|
212
|
+
gray: { r: 128, g: 128, b: 128 },
|
|
213
|
+
grey: { r: 128, g: 128, b: 128 },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (namedColors[trimmed]) {
|
|
217
|
+
return namedColors[trimmed];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// WCAG CONTRAST CALCULATION
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Calculate relative luminance using WCAG formula
|
|
229
|
+
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
|
230
|
+
*/
|
|
231
|
+
function getLuminance(r: number, g: number, b: number): number {
|
|
232
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
233
|
+
c /= 255;
|
|
234
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
235
|
+
});
|
|
236
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Calculate WCAG contrast ratio between two colors
|
|
241
|
+
* Returns ratio like 4.5, 7.0, 21.0 (max), etc.
|
|
242
|
+
* WCAG AA requires 4.5:1 for normal text, 3:1 for large text
|
|
243
|
+
* WCAG AAA requires 7:1 for normal text, 4.5:1 for large text
|
|
244
|
+
*/
|
|
245
|
+
function getContrastRatio(rgb1: RGB, rgb2: RGB): number {
|
|
246
|
+
const l1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
|
247
|
+
const l2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
|
248
|
+
const lighter = Math.max(l1, l2);
|
|
249
|
+
const darker = Math.min(l1, l2);
|
|
250
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Determine which alternative (white or black) provides better contrast
|
|
255
|
+
*/
|
|
256
|
+
function getBestAlternative(bgRgb: RGB): { color: string; ratio: number } {
|
|
257
|
+
const whiteRgb: RGB = { r: 255, g: 255, b: 255 };
|
|
258
|
+
const blackRgb: RGB = { r: 0, g: 0, b: 0 };
|
|
259
|
+
|
|
260
|
+
const whiteRatio = getContrastRatio(bgRgb, whiteRgb);
|
|
261
|
+
const blackRatio = getContrastRatio(bgRgb, blackRgb);
|
|
262
|
+
|
|
263
|
+
if (whiteRatio >= blackRatio) {
|
|
264
|
+
return { color: "text-white", ratio: whiteRatio };
|
|
265
|
+
} else {
|
|
266
|
+
return { color: "text-black", ratio: blackRatio };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============================================================================
|
|
271
|
+
// THEME FILE DISCOVERY
|
|
272
|
+
// ============================================================================
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Patterns for finding theme-related files
|
|
276
|
+
*/
|
|
277
|
+
const THEME_FILE_PATTERNS = [
|
|
278
|
+
// CSS files
|
|
279
|
+
"src/app/globals.css",
|
|
280
|
+
"app/globals.css",
|
|
281
|
+
"src/styles/globals.css",
|
|
282
|
+
"styles/globals.css",
|
|
283
|
+
"src/styles/global.css",
|
|
284
|
+
"styles/global.css",
|
|
285
|
+
"src/styles/variables.css",
|
|
286
|
+
"styles/variables.css",
|
|
287
|
+
"src/styles/theme.css",
|
|
288
|
+
"styles/theme.css",
|
|
289
|
+
"src/styles/brand-overrides.css",
|
|
290
|
+
"styles/brand-overrides.css",
|
|
291
|
+
// Tailwind config files
|
|
292
|
+
"tailwind.config.js",
|
|
293
|
+
"tailwind.config.ts",
|
|
294
|
+
"tailwind.config.mjs",
|
|
295
|
+
"tailwind.config.cjs",
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Find all theme-related files in a project
|
|
300
|
+
*/
|
|
301
|
+
export function findThemeFiles(projectRoot: string): string[] {
|
|
302
|
+
const foundFiles: string[] = [];
|
|
303
|
+
|
|
304
|
+
for (const pattern of THEME_FILE_PATTERNS) {
|
|
305
|
+
const fullPath = path.join(projectRoot, pattern);
|
|
306
|
+
if (fs.existsSync(fullPath)) {
|
|
307
|
+
foundFiles.push(pattern);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Also search for any additional CSS files in common locations
|
|
312
|
+
const additionalDirs = ["src/styles", "styles", "src/app", "app"];
|
|
313
|
+
for (const dir of additionalDirs) {
|
|
314
|
+
const dirPath = path.join(projectRoot, dir);
|
|
315
|
+
if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
|
|
316
|
+
try {
|
|
317
|
+
const files = fs.readdirSync(dirPath);
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
if (file.endsWith(".css") && !foundFiles.includes(path.join(dir, file))) {
|
|
320
|
+
foundFiles.push(path.join(dir, file));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
// Ignore read errors
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return foundFiles;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Parse CSS content and extract variable definitions
|
|
334
|
+
* Extracts patterns like: --primary: #333F48; or --accent: hsl(180 100% 50%);
|
|
335
|
+
*/
|
|
336
|
+
export function parseCSSVariables(cssContent: string): Record<string, string> {
|
|
337
|
+
const variables: Record<string, string> = {};
|
|
338
|
+
|
|
339
|
+
// Match CSS custom property definitions
|
|
340
|
+
// Handles: --name: value; with various value formats
|
|
341
|
+
const regex = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
|
|
342
|
+
|
|
343
|
+
let match;
|
|
344
|
+
while ((match = regex.exec(cssContent)) !== null) {
|
|
345
|
+
const name = match[1].trim();
|
|
346
|
+
const value = match[2].trim();
|
|
347
|
+
variables[`--${name}`] = value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return variables;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Parse Tailwind config file and extract custom color definitions
|
|
355
|
+
* Handles both JS and TS config formats
|
|
356
|
+
*/
|
|
357
|
+
export function parseTailwindConfig(configPath: string, projectRoot: string): Record<string, string> {
|
|
358
|
+
const colors: Record<string, string> = {};
|
|
359
|
+
|
|
360
|
+
const fullPath = path.join(projectRoot, configPath);
|
|
361
|
+
if (!fs.existsSync(fullPath)) {
|
|
362
|
+
return colors;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
367
|
+
|
|
368
|
+
// Extract color definitions from the config
|
|
369
|
+
// This is a simplified parser that handles common patterns
|
|
370
|
+
|
|
371
|
+
// Pattern 1: colors: { name: "value" } or colors: { name: { DEFAULT: "value" } }
|
|
372
|
+
const colorBlockMatch = content.match(/colors\s*:\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/s);
|
|
373
|
+
if (colorBlockMatch) {
|
|
374
|
+
const colorBlock = colorBlockMatch[1];
|
|
375
|
+
|
|
376
|
+
// Simple key-value pairs: name: "value" or name: 'value'
|
|
377
|
+
const simpleColorRegex = /(\w+[-\w]*)\s*:\s*["']([^"']+)["']/g;
|
|
378
|
+
let match;
|
|
379
|
+
while ((match = simpleColorRegex.exec(colorBlock)) !== null) {
|
|
380
|
+
colors[match[1]] = match[2];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Nested objects with DEFAULT: name: { DEFAULT: "value" }
|
|
384
|
+
const nestedColorRegex = /(\w+[-\w]*)\s*:\s*\{\s*DEFAULT\s*:\s*["']([^"']+)["']/g;
|
|
385
|
+
while ((match = nestedColorRegex.exec(colorBlock)) !== null) {
|
|
386
|
+
colors[match[1]] = match[2];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Pattern 2: extend: { colors: { ... } }
|
|
391
|
+
const extendColorMatch = content.match(/extend\s*:\s*\{[^}]*colors\s*:\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/s);
|
|
392
|
+
if (extendColorMatch) {
|
|
393
|
+
const extendBlock = extendColorMatch[1];
|
|
394
|
+
|
|
395
|
+
const simpleColorRegex = /(\w+[-\w]*)\s*:\s*["']([^"']+)["']/g;
|
|
396
|
+
let match;
|
|
397
|
+
while ((match = simpleColorRegex.exec(extendBlock)) !== null) {
|
|
398
|
+
colors[match[1]] = match[2];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// CSS variable references: name: "var(--name)" or "hsl(var(--name))"
|
|
402
|
+
const varRefRegex = /(\w+[-\w]*)\s*:\s*["'](?:hsl\()?var\(--([^)]+)\)(?:\))?["']/g;
|
|
403
|
+
while ((match = varRefRegex.exec(extendBlock)) !== null) {
|
|
404
|
+
colors[match[1]] = `var(--${match[2]})`;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
} catch (error) {
|
|
409
|
+
// Silently fail - config parsing is best-effort
|
|
410
|
+
console.warn(`[Theme Discovery] Failed to parse Tailwind config: ${error}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return colors;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Extract available semantic tokens from CSS content
|
|
418
|
+
* Finds class-like patterns that suggest available Tailwind/utility classes
|
|
419
|
+
*/
|
|
420
|
+
export function extractAvailableTokens(cssVariables: Record<string, string>): string[] {
|
|
421
|
+
const tokens: string[] = [];
|
|
422
|
+
|
|
423
|
+
// Generate bg-* and text-* tokens from CSS variable names
|
|
424
|
+
for (const varName of Object.keys(cssVariables)) {
|
|
425
|
+
// Remove -- prefix
|
|
426
|
+
const name = varName.replace(/^--/, "");
|
|
427
|
+
|
|
428
|
+
// Skip numeric or utility variables
|
|
429
|
+
if (/^\d/.test(name) || name.includes("radius") || name.includes("shadow")) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Common color-related variable patterns
|
|
434
|
+
if (
|
|
435
|
+
name.includes("background") ||
|
|
436
|
+
name.includes("foreground") ||
|
|
437
|
+
name.includes("primary") ||
|
|
438
|
+
name.includes("secondary") ||
|
|
439
|
+
name.includes("accent") ||
|
|
440
|
+
name.includes("muted") ||
|
|
441
|
+
name.includes("destructive") ||
|
|
442
|
+
name.includes("success") ||
|
|
443
|
+
name.includes("warning") ||
|
|
444
|
+
name.includes("border") ||
|
|
445
|
+
name.includes("ring") ||
|
|
446
|
+
name.includes("card") ||
|
|
447
|
+
name.includes("popover")
|
|
448
|
+
) {
|
|
449
|
+
// Add as potential class token
|
|
450
|
+
tokens.push(name);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Always include common utility tokens
|
|
455
|
+
const commonTokens = [
|
|
456
|
+
"text-white",
|
|
457
|
+
"text-black",
|
|
458
|
+
"bg-white",
|
|
459
|
+
"bg-black",
|
|
460
|
+
"bg-transparent",
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
return [...new Set([...tokens, ...commonTokens])];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ============================================================================
|
|
467
|
+
// CONTRAST ANALYSIS
|
|
468
|
+
// ============================================================================
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Analyze CSS variables to identify contrast relationships
|
|
472
|
+
* Calculates actual WCAG contrast ratios and returns warnings for failing combinations
|
|
473
|
+
*/
|
|
474
|
+
export function analyzeContrastRelationships(
|
|
475
|
+
cssVariables: Record<string, string>
|
|
476
|
+
): ContrastAnalysis {
|
|
477
|
+
const warnings: ContrastWarning[] = [];
|
|
478
|
+
const safePatterns: SafePattern[] = [];
|
|
479
|
+
|
|
480
|
+
// Track which backgrounds we've analyzed to add white/black alternatives
|
|
481
|
+
const analyzedBackgrounds: { name: string; rgb: RGB }[] = [];
|
|
482
|
+
|
|
483
|
+
// Find all bg/foreground pairs automatically
|
|
484
|
+
for (const [varName, value] of Object.entries(cssVariables)) {
|
|
485
|
+
// Skip if this is a foreground variable
|
|
486
|
+
if (varName.includes("foreground")) continue;
|
|
487
|
+
|
|
488
|
+
// Skip non-color variables
|
|
489
|
+
if (
|
|
490
|
+
varName.includes("radius") ||
|
|
491
|
+
varName.includes("shadow") ||
|
|
492
|
+
varName.includes("ring") ||
|
|
493
|
+
varName.includes("border") && !varName.match(/border$/)
|
|
494
|
+
) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const baseName = varName.replace(/^--/, "");
|
|
499
|
+
const foregroundVar = `--${baseName}-foreground`;
|
|
500
|
+
|
|
501
|
+
// Parse background color
|
|
502
|
+
const bgRgb = parseColorToRgb(value);
|
|
503
|
+
if (!bgRgb) continue;
|
|
504
|
+
|
|
505
|
+
// Track this background for alternative suggestions
|
|
506
|
+
analyzedBackgrounds.push({ name: baseName, rgb: bgRgb });
|
|
507
|
+
|
|
508
|
+
// Check if there's a corresponding foreground variable
|
|
509
|
+
if (cssVariables[foregroundVar]) {
|
|
510
|
+
const fgRgb = parseColorToRgb(cssVariables[foregroundVar]);
|
|
511
|
+
|
|
512
|
+
if (fgRgb) {
|
|
513
|
+
const ratio = getContrastRatio(bgRgb, fgRgb);
|
|
514
|
+
const passes = ratio >= 4.5; // WCAG AA for normal text
|
|
515
|
+
|
|
516
|
+
if (!passes) {
|
|
517
|
+
// Find the best alternative
|
|
518
|
+
const alternative = getBestAlternative(bgRgb);
|
|
519
|
+
|
|
520
|
+
warnings.push({
|
|
521
|
+
background: `bg-${baseName}`,
|
|
522
|
+
text: `text-${baseName}-foreground`,
|
|
523
|
+
ratio: ratio.toFixed(1),
|
|
524
|
+
suggestion: `Use ${alternative.color} instead (${alternative.ratio.toFixed(1)}:1 contrast)`,
|
|
525
|
+
});
|
|
526
|
+
} else {
|
|
527
|
+
safePatterns.push({
|
|
528
|
+
background: `bg-${baseName}`,
|
|
529
|
+
text: `text-${baseName}-foreground`,
|
|
530
|
+
ratio: ratio.toFixed(1),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Add white/black alternatives for all analyzed backgrounds
|
|
538
|
+
const whiteRgb: RGB = { r: 255, g: 255, b: 255 };
|
|
539
|
+
const blackRgb: RGB = { r: 0, g: 0, b: 0 };
|
|
540
|
+
|
|
541
|
+
for (const { name, rgb } of analyzedBackgrounds) {
|
|
542
|
+
// Only add alternatives for semantic color names (primary, accent, etc.)
|
|
543
|
+
if (
|
|
544
|
+
name.includes("primary") ||
|
|
545
|
+
name.includes("secondary") ||
|
|
546
|
+
name.includes("accent") ||
|
|
547
|
+
name.includes("destructive") ||
|
|
548
|
+
name.includes("success") ||
|
|
549
|
+
name.includes("warning") ||
|
|
550
|
+
name.includes("muted")
|
|
551
|
+
) {
|
|
552
|
+
const whiteRatio = getContrastRatio(rgb, whiteRgb);
|
|
553
|
+
const blackRatio = getContrastRatio(rgb, blackRgb);
|
|
554
|
+
|
|
555
|
+
if (whiteRatio >= 4.5) {
|
|
556
|
+
safePatterns.push({
|
|
557
|
+
background: `bg-${name}`,
|
|
558
|
+
text: "text-white",
|
|
559
|
+
ratio: whiteRatio.toFixed(1),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (blackRatio >= 4.5) {
|
|
564
|
+
safePatterns.push({
|
|
565
|
+
background: `bg-${name}`,
|
|
566
|
+
text: "text-black",
|
|
567
|
+
ratio: blackRatio.toFixed(1),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return { warnings, safePatterns };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ============================================================================
|
|
577
|
+
// MAIN ENTRY POINTS
|
|
578
|
+
// ============================================================================
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Main entry point: Discover theme from a project
|
|
582
|
+
*/
|
|
583
|
+
export async function discoverTheme(projectRoot: string): Promise<DiscoveredTheme> {
|
|
584
|
+
const result: DiscoveredTheme = {
|
|
585
|
+
cssVariables: {},
|
|
586
|
+
tailwindColors: {},
|
|
587
|
+
availableTokens: [],
|
|
588
|
+
rawThemeCSS: "",
|
|
589
|
+
discoveredFiles: [],
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// Find theme files
|
|
593
|
+
const themeFiles = findThemeFiles(projectRoot);
|
|
594
|
+
result.discoveredFiles = themeFiles;
|
|
595
|
+
|
|
596
|
+
// Parse each file
|
|
597
|
+
for (const file of themeFiles) {
|
|
598
|
+
const fullPath = path.join(projectRoot, file);
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
if (file.endsWith(".css")) {
|
|
602
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
603
|
+
|
|
604
|
+
// Accumulate raw CSS
|
|
605
|
+
result.rawThemeCSS += `\n/* === ${file} === */\n${content}\n`;
|
|
606
|
+
|
|
607
|
+
// Parse variables
|
|
608
|
+
const variables = parseCSSVariables(content);
|
|
609
|
+
Object.assign(result.cssVariables, variables);
|
|
610
|
+
|
|
611
|
+
} else if (file.includes("tailwind.config")) {
|
|
612
|
+
// Parse Tailwind config
|
|
613
|
+
const colors = parseTailwindConfig(file, projectRoot);
|
|
614
|
+
Object.assign(result.tailwindColors, colors);
|
|
615
|
+
}
|
|
616
|
+
} catch (error) {
|
|
617
|
+
console.warn(`[Theme Discovery] Error reading ${file}:`, error);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Extract available tokens
|
|
622
|
+
result.availableTokens = extractAvailableTokens(result.cssVariables);
|
|
623
|
+
|
|
624
|
+
// Run contrast analysis
|
|
625
|
+
result.contrastAnalysis = analyzeContrastRelationships(result.cssVariables);
|
|
626
|
+
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Format discovered theme as context for the LLM prompt
|
|
632
|
+
* Includes contrast warnings to prevent bad color combinations
|
|
633
|
+
*/
|
|
634
|
+
export function formatThemeForPrompt(theme: DiscoveredTheme): string {
|
|
635
|
+
const lines: string[] = [];
|
|
636
|
+
|
|
637
|
+
lines.push("**TARGET CODEBASE THEME (discovered):**");
|
|
638
|
+
lines.push("");
|
|
639
|
+
|
|
640
|
+
// CSS Variables
|
|
641
|
+
if (Object.keys(theme.cssVariables).length > 0) {
|
|
642
|
+
lines.push("CSS Variables:");
|
|
643
|
+
|
|
644
|
+
// Group by category
|
|
645
|
+
const categories: Record<string, string[]> = {
|
|
646
|
+
colors: [],
|
|
647
|
+
other: [],
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
for (const [name, value] of Object.entries(theme.cssVariables)) {
|
|
651
|
+
const entry = ` ${name}: ${value}`;
|
|
652
|
+
if (
|
|
653
|
+
name.includes("background") ||
|
|
654
|
+
name.includes("foreground") ||
|
|
655
|
+
name.includes("primary") ||
|
|
656
|
+
name.includes("secondary") ||
|
|
657
|
+
name.includes("accent") ||
|
|
658
|
+
name.includes("muted") ||
|
|
659
|
+
name.includes("destructive") ||
|
|
660
|
+
name.includes("success") ||
|
|
661
|
+
name.includes("warning") ||
|
|
662
|
+
name.includes("border") ||
|
|
663
|
+
name.includes("card")
|
|
664
|
+
) {
|
|
665
|
+
categories.colors.push(entry);
|
|
666
|
+
} else {
|
|
667
|
+
categories.other.push(entry);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Only include color-related variables (most relevant for styling)
|
|
672
|
+
lines.push(...categories.colors.slice(0, 30)); // Limit to avoid prompt bloat
|
|
673
|
+
|
|
674
|
+
if (categories.colors.length > 30) {
|
|
675
|
+
lines.push(` ... and ${categories.colors.length - 30} more color variables`);
|
|
676
|
+
}
|
|
677
|
+
lines.push("");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Tailwind Colors
|
|
681
|
+
if (Object.keys(theme.tailwindColors).length > 0) {
|
|
682
|
+
lines.push("Tailwind Custom Colors:");
|
|
683
|
+
for (const [name, value] of Object.entries(theme.tailwindColors)) {
|
|
684
|
+
lines.push(` ${name}: ${value}`);
|
|
685
|
+
}
|
|
686
|
+
lines.push("");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ========== CONTRAST ANALYSIS (NEW) ==========
|
|
690
|
+
// Run contrast analysis if not already done
|
|
691
|
+
const contrastAnalysis = theme.contrastAnalysis || analyzeContrastRelationships(theme.cssVariables);
|
|
692
|
+
|
|
693
|
+
// Show warnings first (most important for LLM)
|
|
694
|
+
if (contrastAnalysis.warnings.length > 0) {
|
|
695
|
+
lines.push("");
|
|
696
|
+
lines.push("⚠️ CONTRAST WARNINGS (these combinations FAIL WCAG in this codebase - DO NOT USE):");
|
|
697
|
+
for (const w of contrastAnalysis.warnings) {
|
|
698
|
+
lines.push(` ❌ ${w.background} + ${w.text}: ${w.ratio}:1 → ${w.suggestion}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Show safe patterns
|
|
703
|
+
if (contrastAnalysis.safePatterns.length > 0) {
|
|
704
|
+
lines.push("");
|
|
705
|
+
lines.push("✓ SAFE COLOR COMBINATIONS (use these instead):");
|
|
706
|
+
|
|
707
|
+
// Deduplicate and sort by background, prioritize semantic foregrounds
|
|
708
|
+
const seen = new Set<string>();
|
|
709
|
+
const sorted = contrastAnalysis.safePatterns
|
|
710
|
+
.filter((p) => {
|
|
711
|
+
const key = `${p.background}+${p.text}`;
|
|
712
|
+
if (seen.has(key)) return false;
|
|
713
|
+
seen.add(key);
|
|
714
|
+
return true;
|
|
715
|
+
})
|
|
716
|
+
.sort((a, b) => {
|
|
717
|
+
// Sort by background name, then prioritize -foreground over white/black
|
|
718
|
+
if (a.background !== b.background) {
|
|
719
|
+
return a.background.localeCompare(b.background);
|
|
720
|
+
}
|
|
721
|
+
// Prioritize -foreground variants
|
|
722
|
+
const aIsForeground = a.text.includes("-foreground");
|
|
723
|
+
const bIsForeground = b.text.includes("-foreground");
|
|
724
|
+
if (aIsForeground && !bIsForeground) return -1;
|
|
725
|
+
if (!aIsForeground && bIsForeground) return 1;
|
|
726
|
+
return 0;
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
for (const s of sorted.slice(0, 15)) { // Limit to avoid prompt bloat
|
|
730
|
+
lines.push(` ✓ ${s.background} + ${s.text}: ${s.ratio}:1`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (sorted.length > 15) {
|
|
734
|
+
lines.push(` ... and ${sorted.length - 15} more safe combinations`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
lines.push("");
|
|
739
|
+
|
|
740
|
+
// Available Tokens
|
|
741
|
+
if (theme.availableTokens.length > 0) {
|
|
742
|
+
lines.push(`Available Semantic Tokens: ${theme.availableTokens.slice(0, 20).join(", ")}`);
|
|
743
|
+
if (theme.availableTokens.length > 20) {
|
|
744
|
+
lines.push(` ... and ${theme.availableTokens.length - 20} more`);
|
|
745
|
+
}
|
|
746
|
+
lines.push("");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Discovered Files
|
|
750
|
+
if (theme.discoveredFiles.length > 0) {
|
|
751
|
+
lines.push(`Theme Files Found: ${theme.discoveredFiles.join(", ")}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return lines.join("\n");
|
|
755
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sonance-brand-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.55",
|
|
4
4
|
"description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|