screencraft 0.1.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.
Files changed (40) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/.env.example +3 -0
  3. package/MCP_README.md +200 -0
  4. package/README.md +148 -0
  5. package/bin/screencraft.js +61 -0
  6. package/package.json +31 -0
  7. package/src/auth/keystore.js +148 -0
  8. package/src/commands/init.js +119 -0
  9. package/src/commands/launch.js +405 -0
  10. package/src/detectors/detectBrand.js +1222 -0
  11. package/src/detectors/simulator.js +317 -0
  12. package/src/generators/analyzeStyleReference.js +471 -0
  13. package/src/generators/compositePSD.js +682 -0
  14. package/src/generators/copy.js +147 -0
  15. package/src/mcp/index.js +394 -0
  16. package/src/pipeline/aeSwap.js +369 -0
  17. package/src/pipeline/download.js +32 -0
  18. package/src/pipeline/queue.js +101 -0
  19. package/src/server/index.js +627 -0
  20. package/src/server/public/app.js +738 -0
  21. package/src/server/public/index.html +255 -0
  22. package/src/server/public/style.css +751 -0
  23. package/src/server/session.js +36 -0
  24. package/templates/ae/(Footage)/Assets/This Hip-Hop Upbeat (Short version).wav +0 -0
  25. package/templates/ae/(Footage)/Assets/screen_01_raw.png +0 -0
  26. package/templates/ae/(Footage)/Assets/screen_02_raw.png +0 -0
  27. package/templates/ae/(Footage)/Assets/screen_03_raw.png +0 -0
  28. package/templates/ae/(Footage)/Assets/screen_04_raw.png +0 -0
  29. package/templates/ae/(Footage)/Assets/screen_05_raw.png +0 -0
  30. package/templates/ae/(Footage)/Assets/screen_06_raw.png +0 -0
  31. package/templates/ae/Motion Forge Test 1.0 (converted).aep +0 -0
  32. package/templates/ae_swap.jsx +284 -0
  33. package/templates/layouts/minimal.psd +0 -0
  34. package/templates/screencraft.config.example.js +165 -0
  35. package/test/output/layout_test.png +0 -0
  36. package/test/output/style_profile.json +64 -0
  37. package/test/reference.png +0 -0
  38. package/test/test_brand.js +69 -0
  39. package/test/test_psd.js +83 -0
  40. package/test/test_style_analysis.js +114 -0
@@ -0,0 +1,1222 @@
1
+ /**
2
+ * detectBrand.js
3
+ * --------------
4
+ * Reads the project codebase and extracts brand information
5
+ * without any user input. Returns a BrandProfile object.
6
+ *
7
+ * Detection priority:
8
+ * 1. screencraft.config.js overrides (always wins)
9
+ * 2. Framework-specific color files
10
+ * 3. Generic fallbacks (CSS variables, JSON tokens)
11
+ * 4. Built-in defaults
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ // ─────────────────────────────────────────────────────────────────
18
+ // MAIN EXPORT
19
+ // ─────────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * detectBrand(projectRoot, configOverrides)
23
+ * Returns a fully-resolved BrandProfile.
24
+ */
25
+ async function detectBrand(projectRoot, configOverrides = {}) {
26
+ const framework = await detectFramework(projectRoot);
27
+
28
+ console.log(` ◆ Detected framework: ${framework.name}`);
29
+
30
+ const detected = await runDetector(framework, projectRoot);
31
+ const appInfo = await detectAppInfo(framework, projectRoot);
32
+ const icon = await detectAppIcon(framework, projectRoot);
33
+ const font = await detectFont(framework, projectRoot);
34
+ const logo = await detectLogo(framework, projectRoot);
35
+
36
+ // Config overrides win over auto-detection
37
+ const brand = {
38
+ ...detected,
39
+ ...stripNulls(configOverrides.brand || {}),
40
+ appName: configOverrides.app?.name || appInfo.name,
41
+ tagline: configOverrides.app?.tagline || null,
42
+ cta: configOverrides.app?.cta || "Download Now",
43
+ icon: configOverrides.brand?.icon || icon,
44
+ font: configOverrides.brand?.font || font,
45
+ logo: configOverrides.brand?.logo || logo,
46
+ framework: framework.name,
47
+ };
48
+
49
+ console.log(` ◆ App name: ${brand.appName}`);
50
+ console.log(` ◆ Primary: ${brand.primary}`);
51
+ console.log(` ◆ Secondary: ${brand.secondary}`);
52
+ console.log(` ◆ Accent: ${brand.accent}`);
53
+ console.log(` ◆ Icon: ${brand.icon ? 'found' : 'not found'}`);
54
+ console.log(` ◆ Font: ${brand.font?.family || 'not found'}${brand.font?.weight ? ` (${brand.font.weight})` : ''}`);
55
+ console.log(` ◆ Logo: ${brand.logo ? 'found' : 'not found'}`);
56
+
57
+ return brand;
58
+ }
59
+
60
+ module.exports = { detectBrand, detectFramework };
61
+
62
+
63
+ // ─────────────────────────────────────────────────────────────────
64
+ // FRAMEWORK DETECTION
65
+ // ─────────────────────────────────────────────────────────────────
66
+
67
+ async function detectFramework(root) {
68
+ const has = (f) => fs.existsSync(path.join(root, f));
69
+ const read = (f) => {
70
+ try { return fs.readFileSync(path.join(root, f), 'utf8'); }
71
+ catch { return ''; }
72
+ };
73
+
74
+ // Expo (check before React Native — Expo projects also have package.json)
75
+ if (has('app.json') || has('app.config.js') || has('app.config.ts')) {
76
+ const appJson = read('app.json');
77
+ if (appJson.includes('"expo"')) {
78
+ return { name: 'expo', platform: ['ios', 'android'], configFile: 'app.json' };
79
+ }
80
+ }
81
+
82
+ // React Native (bare)
83
+ if (has('package.json')) {
84
+ const pkg = read('package.json');
85
+ if (pkg.includes('"react-native"')) {
86
+ const hasIos = has('ios');
87
+ const hasAndroid = has('android');
88
+ return {
89
+ name: 'react-native',
90
+ platform: [hasIos && 'ios', hasAndroid && 'android'].filter(Boolean),
91
+ configFile: 'package.json'
92
+ };
93
+ }
94
+ }
95
+
96
+ // Flutter
97
+ if (has('pubspec.yaml')) {
98
+ return { name: 'flutter', platform: ['ios', 'android'], configFile: 'pubspec.yaml' };
99
+ }
100
+
101
+ // Swift / SwiftUI — handle two cases:
102
+ // 1. Root is a cross-platform project with an ios/ subfolder
103
+ // 2. Root IS the iOS project (contains .xcodeproj directly)
104
+ if (!has('package.json') && !has('pubspec.yaml')) {
105
+ // Case 1: ios/ subfolder
106
+ if (has('ios')) {
107
+ const xcodeProjs = findFiles(path.join(root, 'ios'), '.xcodeproj', 1);
108
+ if (xcodeProjs.length > 0) {
109
+ return { name: 'swift', platform: ['ios'], configFile: xcodeProjs[0] };
110
+ }
111
+ }
112
+ // Case 2: .xcodeproj in root itself
113
+ const xcodeProjs = findFiles(root, '.xcodeproj', 1);
114
+ if (xcodeProjs.length > 0) {
115
+ return { name: 'swift', platform: ['ios'], configFile: xcodeProjs[0] };
116
+ }
117
+ }
118
+
119
+ // Kotlin / Android native
120
+ if (has('android') && has('android/app/src/main/AndroidManifest.xml')) {
121
+ return { name: 'android-native', platform: ['android'], configFile: 'android/app/src/main/AndroidManifest.xml' };
122
+ }
123
+
124
+ // Next.js / web app (PWA)
125
+ if (has('next.config.js') || has('next.config.ts')) {
126
+ return { name: 'nextjs', platform: ['web'], configFile: 'next.config.js' };
127
+ }
128
+
129
+ return { name: 'unknown', platform: [], configFile: null };
130
+ }
131
+
132
+
133
+ // ─────────────────────────────────────────────────────────────────
134
+ // FRAMEWORK-SPECIFIC DETECTORS
135
+ // ─────────────────────────────────────────────────────────────────
136
+
137
+ async function runDetector(framework, root) {
138
+ switch (framework.name) {
139
+ case 'expo': return detectExpo(root);
140
+ case 'react-native': return detectReactNative(root);
141
+ case 'flutter': return detectFlutter(root);
142
+ case 'swift': return detectSwift(root);
143
+ case 'android-native':return detectAndroidNative(root);
144
+ case 'nextjs': return detectNextjs(root);
145
+ default: return detectGeneric(root);
146
+ }
147
+ }
148
+
149
+ // ── EXPO ──────────────────────────────────────────────────────────
150
+ function detectExpo(root) {
151
+ // Try app.json first
152
+ const appJson = readJson(path.join(root, 'app.json'));
153
+ if (appJson?.expo?.splash?.backgroundColor) {
154
+ // Use splash background as primary if no theme file found
155
+ }
156
+
157
+ // Theme files — common patterns in Expo projects
158
+ const candidates = [
159
+ 'constants/Colors.ts',
160
+ 'constants/Colors.js',
161
+ 'constants/colors.ts',
162
+ 'constants/colors.js',
163
+ 'src/constants/Colors.ts',
164
+ 'src/constants/colors.ts',
165
+ 'src/theme/colors.ts',
166
+ 'src/theme/colors.js',
167
+ 'theme/colors.ts',
168
+ 'theme/colors.js',
169
+ 'styles/colors.ts',
170
+ 'styles/colors.js',
171
+ ];
172
+
173
+ for (const candidate of candidates) {
174
+ const result = parseJsColorFile(path.join(root, candidate));
175
+ if (result) {
176
+ console.log(` ◆ Colors from: ${candidate}`);
177
+ return result;
178
+ }
179
+ }
180
+
181
+ // Fallback: read app.json tint color
182
+ const tint = appJson?.expo?.splash?.backgroundColor
183
+ || appJson?.expo?.primaryColor
184
+ || null;
185
+
186
+ return buildProfile({ primary: tint });
187
+ }
188
+
189
+ // ── REACT NATIVE (BARE) ───────────────────────────────────────────
190
+ function detectReactNative(root) {
191
+ const candidates = [
192
+ 'src/theme/colors.ts',
193
+ 'src/theme/colors.js',
194
+ 'src/constants/colors.ts',
195
+ 'src/constants/colors.js',
196
+ 'app/theme/colors.ts',
197
+ 'app/constants/colors.ts',
198
+ 'constants/Colors.ts',
199
+ 'constants/colors.js',
200
+ 'styles/colors.js',
201
+ 'theme.js',
202
+ 'theme.ts',
203
+ ];
204
+
205
+ for (const candidate of candidates) {
206
+ const result = parseJsColorFile(path.join(root, candidate));
207
+ if (result) {
208
+ console.log(` ◆ Colors from: ${candidate}`);
209
+ return result;
210
+ }
211
+ }
212
+
213
+ // Try reading from iOS native color assets as fallback
214
+ const iosResult = detectSwiftColorAssets(root);
215
+ if (iosResult.primary) return iosResult;
216
+
217
+ return buildProfile({});
218
+ }
219
+
220
+ // ── FLUTTER ───────────────────────────────────────────────────────
221
+ function detectFlutter(root) {
222
+ // Flutter color definitions live in .dart files
223
+ // Common patterns: lib/core/theme/app_colors.dart,
224
+ // lib/config/theme.dart, lib/utils/colors.dart
225
+
226
+ const dartCandidates = [
227
+ 'lib/core/theme/app_colors.dart',
228
+ 'lib/core/theme/colors.dart',
229
+ 'lib/config/theme.dart',
230
+ 'lib/theme/app_theme.dart',
231
+ 'lib/utils/colors.dart',
232
+ 'lib/constants/colors.dart',
233
+ 'lib/app/theme.dart',
234
+ ];
235
+
236
+ for (const candidate of dartCandidates) {
237
+ const result = parseDartColorFile(path.join(root, candidate));
238
+ if (result) {
239
+ console.log(` ◆ Colors from: ${candidate}`);
240
+ return result;
241
+ }
242
+ }
243
+
244
+ // Search recursively for any dart file with "Color(" definitions
245
+ const dartFiles = findFiles(path.join(root, 'lib'), '.dart', 4);
246
+ for (const file of dartFiles) {
247
+ if (file.includes('color') || file.includes('theme')) {
248
+ const result = parseDartColorFile(file);
249
+ if (result) {
250
+ console.log(` ◆ Colors from: ${path.relative(root, file)}`);
251
+ return result;
252
+ }
253
+ }
254
+ }
255
+
256
+ return buildProfile({});
257
+ }
258
+
259
+ // ── SWIFT / SWIFTUI ───────────────────────────────────────────────
260
+ function detectSwift(root) {
261
+ // Handle two cases:
262
+ // A: cross-platform project root with ios/ subfolder
263
+ // B: root IS the iOS project (contains .xcodeproj directly)
264
+ const iosRoot = fs.existsSync(path.join(root, 'ios')) ? path.join(root, 'ios') : root;
265
+
266
+ // Collect colors from multiple sources, merge at the end
267
+ const collected = {};
268
+
269
+ // 1. Color.xcassets (modern SwiftUI)
270
+ const xcassetsColors = detectSwiftColorAssetsRaw(iosRoot);
271
+ if (Object.keys(xcassetsColors).length > 0) {
272
+ console.log(` ◆ Colors from: Color.xcassets`);
273
+ Object.assign(collected, xcassetsColors);
274
+ }
275
+
276
+ // 2. Swift files with color/theme/brand/style in filename
277
+ const swiftCandidates = findFiles(iosRoot, '.swift', 8)
278
+ .filter(f => {
279
+ const lc = f.toLowerCase();
280
+ return lc.includes('color') || lc.includes('theme') || lc.includes('style') || lc.includes('brand');
281
+ });
282
+
283
+ for (const file of swiftCandidates) {
284
+ const result = parseSwiftColorFile(file);
285
+ if (result) {
286
+ console.log(` ◆ Colors from: ${path.relative(root, file)}`);
287
+ // Merge: Swift file fills gaps left by xcassets
288
+ if (!collected.primary && result.primary !== '#1A1A2E') collected.primary = result.primary;
289
+ if (!collected.secondary && result.secondary !== '#F5F5F5') collected.secondary = result.secondary;
290
+ if (!collected.accent && result.accent !== '#E94560') collected.accent = result.accent;
291
+ if (!collected.background && result.background !== '#FFFFFF') collected.background = result.background;
292
+ break;
293
+ }
294
+ }
295
+
296
+ // 3. Fallback: scan all Swift files if still missing primary
297
+ if (!collected.primary) {
298
+ for (const file of findFiles(iosRoot, '.swift', 8)) {
299
+ const result = parseSwiftColorFile(file);
300
+ if (result) {
301
+ console.log(` ◆ Colors from: ${path.relative(root, file)}`);
302
+ if (!collected.primary && result.primary !== '#1A1A2E') collected.primary = result.primary;
303
+ if (!collected.secondary && result.secondary !== '#F5F5F5') collected.secondary = result.secondary;
304
+ if (!collected.accent && result.accent !== '#E94560') collected.accent = result.accent;
305
+ if (!collected.background && result.background !== '#FFFFFF') collected.background = result.background;
306
+ break;
307
+ }
308
+ }
309
+ }
310
+
311
+ return buildProfile(collected);
312
+ }
313
+
314
+ // ── ANDROID NATIVE ────────────────────────────────────────────────
315
+ function detectAndroidNative(root) {
316
+ // Android colors live in res/values/colors.xml
317
+ const colorsXml = path.join(root, 'android/app/src/main/res/values/colors.xml');
318
+ const result = parseAndroidColorsXml(colorsXml);
319
+ if (result.primary) {
320
+ console.log(` ◆ Colors from: res/values/colors.xml`);
321
+ return result;
322
+ }
323
+
324
+ // Also try themes.xml
325
+ const themesXml = path.join(root, 'android/app/src/main/res/values/themes.xml');
326
+ const themeResult = parseAndroidThemesXml(themesXml);
327
+ if (themeResult.primary) {
328
+ console.log(` ◆ Colors from: res/values/themes.xml`);
329
+ return themeResult;
330
+ }
331
+
332
+ return buildProfile({});
333
+ }
334
+
335
+ // ── NEXT.JS ───────────────────────────────────────────────────────
336
+ function detectNextjs(root) {
337
+ // 1. Tailwind config
338
+ const tailwindResult = parseTailwindConfig(root);
339
+ if (tailwindResult.primary) {
340
+ console.log(` ◆ Colors from: tailwind.config.js`);
341
+ return tailwindResult;
342
+ }
343
+
344
+ // 2. CSS variables in globals.css
345
+ const cssResult = parseCssVariables(
346
+ path.join(root, 'styles/globals.css') ||
347
+ path.join(root, 'app/globals.css')
348
+ );
349
+ if (cssResult.primary) {
350
+ console.log(` ◆ Colors from: globals.css`);
351
+ return cssResult;
352
+ }
353
+
354
+ // 3. Design tokens
355
+ return detectGeneric(root);
356
+ }
357
+
358
+ // ── GENERIC FALLBACK ──────────────────────────────────────────────
359
+ function detectGeneric(root) {
360
+ // Design token files
361
+ const tokenCandidates = [
362
+ 'tokens.json',
363
+ 'design-tokens.json',
364
+ 'src/tokens.json',
365
+ 'src/design-tokens.json',
366
+ 'styles/tokens.json',
367
+ ];
368
+
369
+ for (const candidate of tokenCandidates) {
370
+ const result = parseDesignTokens(path.join(root, candidate));
371
+ if (result.primary) return result;
372
+ }
373
+
374
+ return buildProfile({});
375
+ }
376
+
377
+
378
+ // ─────────────────────────────────────────────────────────────────
379
+ // PARSERS
380
+ // ─────────────────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * Parse JS/TS color files.
384
+ * Handles: export const primary = '#...'
385
+ * Colors.primary = '#...'
386
+ * light: { primary: '#...' }
387
+ */
388
+ function parseJsColorFile(filePath) {
389
+ if (!fs.existsSync(filePath)) return null;
390
+ const content = fs.readFileSync(filePath, 'utf8');
391
+
392
+ const colors = {};
393
+
394
+ // Match: primary: '#hex' or primary: "#hex"
395
+ const patterns = [
396
+ { key: 'primary', regex: /primary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
397
+ { key: 'secondary', regex: /secondary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
398
+ { key: 'accent', regex: /accent['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
399
+ { key: 'tint', regex: /tint['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
400
+ { key: 'background',regex: /background['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
401
+ ];
402
+
403
+ for (const { key, regex } of patterns) {
404
+ const match = content.match(regex);
405
+ if (match) colors[key] = match[1];
406
+ }
407
+
408
+ // Resolve tint → secondary if no explicit secondary
409
+ if (!colors.secondary && colors.tint) {
410
+ colors.secondary = colors.tint;
411
+ }
412
+
413
+ if (!colors.primary) return null;
414
+ return buildProfile(colors);
415
+ }
416
+
417
+ /**
418
+ * Parse Dart color files.
419
+ * Handles: static const Color primary = Color(0xFF1B3A6B);
420
+ * Color get primary => const Color(0xFF1B3A6B);
421
+ */
422
+ function parseDartColorFile(filePath) {
423
+ if (!fs.existsSync(filePath)) return null;
424
+ const content = fs.readFileSync(filePath, 'utf8');
425
+
426
+ const colors = {};
427
+
428
+ // Match Color(0xFFRRGGBB) or Color(0xAARRGGBB)
429
+ const patterns = [
430
+ { key: 'primary', regex: /primary[^=\n]*=?[^(]*Color\(0x[A-Fa-f0-9]{2}([A-Fa-f0-9]{6})\)/i },
431
+ { key: 'secondary', regex: /secondary[^=\n]*=?[^(]*Color\(0x[A-Fa-f0-9]{2}([A-Fa-f0-9]{6})\)/i },
432
+ { key: 'accent', regex: /accent[^=\n]*=?[^(]*Color\(0x[A-Fa-f0-9]{2}([A-Fa-f0-9]{6})\)/i },
433
+ ];
434
+
435
+ for (const { key, regex } of patterns) {
436
+ const match = content.match(regex);
437
+ if (match) colors[key] = '#' + match[1];
438
+ }
439
+
440
+ if (!colors.primary) return null;
441
+ return buildProfile(colors);
442
+ }
443
+
444
+ /**
445
+ * Parse Swift color files.
446
+ * Handles:
447
+ * static let primary = UIColor(hex: "#1B3A6B")
448
+ * static let brandPurple = Color(hex: "7068B1")
449
+ * Color("PrimaryColor") → reads from xcassets
450
+ */
451
+ function parseSwiftColorFile(filePath) {
452
+ if (!fs.existsSync(filePath)) return null;
453
+ const content = fs.readFileSync(filePath, 'utf8');
454
+
455
+ const colors = {};
456
+
457
+ // Pass 1: exact name matches (primary, secondary, accent)
458
+ const exactPatterns = [
459
+ { key: 'primary', regex: /primary[^=\n]*=.*?(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/i },
460
+ { key: 'secondary', regex: /secondary[^=\n]*=.*?(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/i },
461
+ { key: 'accent', regex: /accent[^=\n]*=.*?(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/i },
462
+ ];
463
+
464
+ for (const { key, regex } of exactPatterns) {
465
+ const match = content.match(regex);
466
+ if (match) colors[key] = '#' + match[1];
467
+ }
468
+
469
+ if (colors.primary) return buildProfile(colors);
470
+
471
+ // Pass 2: collect all Color(hex: "...") definitions with their variable names
472
+ // Handles: static let brandPurple = Color(hex: "7068B1")
473
+ const allColors = [];
474
+ const colorRegex = /(?:static\s+)?(?:let|var)\s+(\w+)\s*=\s*(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/gi;
475
+ let m;
476
+ while ((m = colorRegex.exec(content)) !== null) {
477
+ allColors.push({ name: m[1].toLowerCase(), hex: '#' + m[2] });
478
+ }
479
+
480
+ if (allColors.length === 0) return null;
481
+
482
+ // Assign by name heuristics
483
+ for (const c of allColors) {
484
+ if (!colors.background && (c.name.includes('background') || c.name.includes('bg') || c.name.includes('deep'))) {
485
+ colors.background = c.hex;
486
+ } else if (!colors.primary && (c.name.includes('brand') || c.name.includes('primary') || c.name.includes('main'))) {
487
+ colors.primary = c.hex;
488
+ } else if (!colors.accent && (c.name.includes('accent') || c.name.includes('tint') || c.name.includes('highlight') || c.name.includes('mint'))) {
489
+ colors.accent = c.hex;
490
+ } else if (!colors.secondary && (c.name.includes('secondary') || c.name.includes('teal') || c.name.includes('blue'))) {
491
+ colors.secondary = c.hex;
492
+ }
493
+ }
494
+
495
+ // If still no primary, use the first non-background color
496
+ if (!colors.primary) {
497
+ const first = allColors.find(c => c.hex !== colors.background);
498
+ if (first) colors.primary = first.hex;
499
+ }
500
+
501
+ // If still no secondary, use next unused color
502
+ if (!colors.secondary) {
503
+ const used = new Set([colors.primary, colors.background, colors.accent]);
504
+ const next = allColors.find(c => !used.has(c.hex));
505
+ if (next) colors.secondary = next.hex;
506
+ }
507
+
508
+ if (!colors.primary) return null;
509
+ return buildProfile(colors);
510
+ }
511
+
512
+ /**
513
+ * Parse Color.xcassets — returns raw colors object (no defaults).
514
+ * Used by detectSwift for merging with Swift file colors.
515
+ */
516
+ function detectSwiftColorAssetsRaw(root) {
517
+ const xcassetDirs = findFiles(root, '.xcassets', 3);
518
+ const colors = {};
519
+
520
+ for (const xcassetDir of xcassetDirs) {
521
+ const colorsets = findFiles(xcassetDir, '.colorset', 3);
522
+ for (const colorset of colorsets) {
523
+ const name = path.basename(colorset, '.colorset').toLowerCase();
524
+ const contentsPath = path.join(colorset, 'Contents.json');
525
+ if (!fs.existsSync(contentsPath)) continue;
526
+
527
+ try {
528
+ const json = JSON.parse(fs.readFileSync(contentsPath, 'utf8'));
529
+ const components = json?.colors?.[0]?.color?.components;
530
+ if (!components) continue;
531
+
532
+ const hex = componentsToHex(components);
533
+ if (!hex) continue;
534
+
535
+ if (name.includes('primary') && !colors.primary) colors.primary = hex;
536
+ if (name.includes('secondary') && !colors.secondary) colors.secondary = hex;
537
+ if (name.includes('accent') && !colors.accent) colors.accent = hex;
538
+ if (name.includes('tint') && !colors.secondary) colors.secondary = hex;
539
+ } catch {}
540
+ }
541
+ }
542
+
543
+ return colors;
544
+ }
545
+
546
+ /**
547
+ * Parse Color.xcassets — returns full profile with defaults.
548
+ * Used by detectReactNative as fallback.
549
+ */
550
+ function detectSwiftColorAssets(root) {
551
+ return buildProfile(detectSwiftColorAssetsRaw(root));
552
+ }
553
+
554
+ /**
555
+ * Parse Android res/values/colors.xml
556
+ */
557
+ function parseAndroidColorsXml(filePath) {
558
+ if (!fs.existsSync(filePath)) return buildProfile({});
559
+ const content = fs.readFileSync(filePath, 'utf8');
560
+ const colors = {};
561
+
562
+ const matches = content.matchAll(/<color name="([^"]+)">#?([0-9A-Fa-f]{6,8})<\/color>/g);
563
+ for (const [, name, hex] of matches) {
564
+ const n = name.toLowerCase();
565
+ const h = '#' + hex.slice(-6); // Strip alpha if present
566
+ if (n.includes('primary') && !colors.primary) colors.primary = h;
567
+ if (n.includes('secondary') && !colors.secondary) colors.secondary = h;
568
+ if (n.includes('accent') && !colors.accent) colors.accent = h;
569
+ }
570
+
571
+ return buildProfile(colors);
572
+ }
573
+
574
+ /**
575
+ * Parse Android res/values/themes.xml
576
+ * Looks for colorPrimary, colorSecondary, colorAccent attributes.
577
+ */
578
+ function parseAndroidThemesXml(filePath) {
579
+ if (!fs.existsSync(filePath)) return buildProfile({});
580
+ const content = fs.readFileSync(filePath, 'utf8');
581
+ const colors = {};
582
+
583
+ const patterns = [
584
+ { key: 'primary', regex: /colorPrimary[^>]*>#?([0-9A-Fa-f]{6,8})/i },
585
+ { key: 'secondary', regex: /colorSecondary[^>]*>#?([0-9A-Fa-f]{6,8})/i },
586
+ { key: 'accent', regex: /colorAccent[^>]*>#?([0-9A-Fa-f]{6,8})/i },
587
+ ];
588
+
589
+ for (const { key, regex } of patterns) {
590
+ const match = content.match(regex);
591
+ if (match) colors[key] = '#' + match[1].slice(-6);
592
+ }
593
+
594
+ return buildProfile(colors);
595
+ }
596
+
597
+ /**
598
+ * Parse Tailwind config for brand colors.
599
+ */
600
+ function parseTailwindConfig(root) {
601
+ const candidates = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'];
602
+ for (const c of candidates) {
603
+ const filePath = path.join(root, c);
604
+ if (!fs.existsSync(filePath)) continue;
605
+ const content = fs.readFileSync(filePath, 'utf8');
606
+ const colors = {};
607
+
608
+ const patterns = [
609
+ { key: 'primary', regex: /primary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
610
+ { key: 'secondary', regex: /secondary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
611
+ { key: 'accent', regex: /accent['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
612
+ ];
613
+
614
+ for (const { key, regex } of patterns) {
615
+ const match = content.match(regex);
616
+ if (match) colors[key] = match[1];
617
+ }
618
+
619
+ if (colors.primary) return buildProfile(colors);
620
+ }
621
+ return buildProfile({});
622
+ }
623
+
624
+ /**
625
+ * Parse CSS custom properties (variables).
626
+ * Handles: --color-primary: #hex; --primary: #hex;
627
+ */
628
+ function parseCssVariables(filePath) {
629
+ if (!fs.existsSync(filePath)) return buildProfile({});
630
+ const content = fs.readFileSync(filePath, 'utf8');
631
+ const colors = {};
632
+
633
+ const patterns = [
634
+ { key: 'primary', regex: /--(?:color-)?primary\s*:\s*(#[0-9A-Fa-f]{6})/i },
635
+ { key: 'secondary', regex: /--(?:color-)?secondary\s*:\s*(#[0-9A-Fa-f]{6})/i },
636
+ { key: 'accent', regex: /--(?:color-)?accent\s*:\s*(#[0-9A-Fa-f]{6})/i },
637
+ ];
638
+
639
+ for (const { key, regex } of patterns) {
640
+ const match = content.match(regex);
641
+ if (match) colors[key] = match[1];
642
+ }
643
+
644
+ return buildProfile(colors);
645
+ }
646
+
647
+ /**
648
+ * Parse design token JSON files.
649
+ * Handles W3C token format and Style Dictionary format.
650
+ */
651
+ function parseDesignTokens(filePath) {
652
+ if (!fs.existsSync(filePath)) return buildProfile({});
653
+ try {
654
+ const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
655
+ const colors = {};
656
+
657
+ // W3C format: { "color": { "primary": { "$value": "#hex" } } }
658
+ const primary = json?.color?.primary?.$value || json?.colors?.primary?.$value;
659
+ const secondary = json?.color?.secondary?.$value || json?.colors?.secondary?.$value;
660
+ const accent = json?.color?.accent?.$value || json?.colors?.accent?.$value;
661
+
662
+ if (primary) colors.primary = primary;
663
+ if (secondary) colors.secondary = secondary;
664
+ if (accent) colors.accent = accent;
665
+
666
+ return buildProfile(colors);
667
+ } catch {
668
+ return buildProfile({});
669
+ }
670
+ }
671
+
672
+
673
+ // ─────────────────────────────────────────────────────────────────
674
+ // APP INFO DETECTION
675
+ // ─────────────────────────────────────────────────────────────────
676
+
677
+ async function detectAppInfo(framework, root) {
678
+ switch (framework.name) {
679
+ case 'expo': {
680
+ const appJson = readJson(path.join(root, 'app.json'));
681
+ return {
682
+ name: appJson?.expo?.name || appJson?.name || null,
683
+ bundleId: appJson?.expo?.ios?.bundleIdentifier || null,
684
+ version: appJson?.expo?.version || null,
685
+ };
686
+ }
687
+ case 'react-native': {
688
+ // Read from ios/Info.plist
689
+ const plist = readPlist(path.join(root, 'ios'));
690
+ const pkg = readJson(path.join(root, 'package.json'));
691
+ return {
692
+ name: plist?.CFBundleDisplayName || plist?.CFBundleName || pkg?.name || null,
693
+ version: plist?.CFBundleShortVersionString || pkg?.version || null,
694
+ };
695
+ }
696
+ case 'flutter': {
697
+ const pubspec = readYaml(path.join(root, 'pubspec.yaml'));
698
+ return {
699
+ name: pubspec?.name || null,
700
+ version: pubspec?.version || null,
701
+ };
702
+ }
703
+ case 'swift': {
704
+ const plist = readPlist(path.join(root, 'ios'));
705
+ return {
706
+ name: plist?.CFBundleDisplayName || plist?.CFBundleName || null,
707
+ version: plist?.CFBundleShortVersionString || null,
708
+ };
709
+ }
710
+ case 'android-native': {
711
+ const manifest = fs.readFileSync(
712
+ path.join(root, 'android/app/src/main/AndroidManifest.xml'), 'utf8'
713
+ );
714
+ const match = manifest.match(/android:label="@string\/([^"]+)"/);
715
+ return { name: match ? match[1] : null };
716
+ }
717
+ default:
718
+ return { name: readJson(path.join(root, 'package.json'))?.name || null };
719
+ }
720
+ }
721
+
722
+
723
+ // ─────────────────────────────────────────────────────────────────
724
+ // APP ICON DETECTION
725
+ // ─────────────────────────────────────────────────────────────────
726
+
727
+ async function detectAppIcon(framework, root) {
728
+ // Quick-check common fixed paths first
729
+ const candidates = [
730
+ // Expo / React Native
731
+ 'assets/icon.png',
732
+ 'assets/images/icon.png',
733
+ 'src/assets/icon.png',
734
+ // Android
735
+ 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png',
736
+ 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png',
737
+ // Generic
738
+ 'icon.png',
739
+ 'app-icon.png',
740
+ ];
741
+
742
+ for (const candidate of candidates) {
743
+ const full = path.join(root, candidate);
744
+ if (fs.existsSync(full)) return full;
745
+ }
746
+
747
+ // Deep search: find .appiconset directories and read Contents.json
748
+ // to locate the largest icon (works regardless of filename conventions)
749
+ const appiconsets = findFiles(root, '.appiconset', 8);
750
+ for (const dir of appiconsets) {
751
+ const icon = findLargestIconInSet(dir);
752
+ if (icon) return icon;
753
+ }
754
+
755
+ // Last resort: any large PNG named *icon* anywhere in the project
756
+ const allPngs = findFiles(root, '.png', 6);
757
+ const iconPng = allPngs.find(f => {
758
+ const name = path.basename(f).toLowerCase();
759
+ return name.includes('icon') && (name.includes('1024') || name.includes('large') || name.includes('appicon'));
760
+ });
761
+ if (iconPng) return iconPng;
762
+
763
+ return null;
764
+ }
765
+
766
+ /**
767
+ * Read Contents.json inside an .appiconset and return the path
768
+ * to the largest available icon image.
769
+ */
770
+ function findLargestIconInSet(appiconsetDir) {
771
+ const contentsPath = path.join(appiconsetDir, 'Contents.json');
772
+ if (!fs.existsSync(contentsPath)) {
773
+ // No Contents.json — just grab the largest PNG by name
774
+ const pngs = findFiles(appiconsetDir, '.png', 1);
775
+ return pngs.length > 0 ? pngs[0] : null;
776
+ }
777
+
778
+ try {
779
+ const json = JSON.parse(fs.readFileSync(contentsPath, 'utf8'));
780
+ const images = json?.images || [];
781
+
782
+ // Find the largest icon by parsing the "size" field (e.g., "1024x1024")
783
+ let best = null;
784
+ let bestSize = 0;
785
+ for (const img of images) {
786
+ if (!img.filename) continue;
787
+ const sizeStr = img.size || '0x0';
788
+ const dim = parseInt(sizeStr.split('x')[0], 10) || 0;
789
+ const scale = parseFloat((img.scale || '1x').replace('x', '')) || 1;
790
+ const effective = dim * scale;
791
+ if (effective > bestSize) {
792
+ const full = path.join(appiconsetDir, img.filename);
793
+ if (fs.existsSync(full)) {
794
+ best = full;
795
+ bestSize = effective;
796
+ }
797
+ }
798
+ }
799
+ return best;
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+
805
+
806
+ // ─────────────────────────────────────────────────────────────────
807
+ // FONT DETECTION
808
+ // ─────────────────────────────────────────────────────────────────
809
+
810
+ async function detectFont(framework, root) {
811
+ switch (framework.name) {
812
+ case 'swift': return detectFontSwift(root);
813
+ case 'expo': return detectFontExpoRN(root);
814
+ case 'react-native': return detectFontExpoRN(root);
815
+ case 'flutter': return detectFontFlutter(root);
816
+ case 'android-native':return detectFontAndroid(root);
817
+ case 'nextjs': return detectFontWeb(root);
818
+ default: return detectFontGeneric(root);
819
+ }
820
+ }
821
+
822
+ // ── Swift / iOS ──────────────────────────────────────────────────
823
+ function detectFontSwift(root) {
824
+ const iosRoot = fs.existsSync(path.join(root, 'ios')) ? path.join(root, 'ios') : root;
825
+
826
+ // 1. Look for bundled font files
827
+ const fontFiles = [
828
+ ...findFiles(iosRoot, '.ttf', 6),
829
+ ...findFiles(iosRoot, '.otf', 6),
830
+ ];
831
+
832
+ if (fontFiles.length > 0) {
833
+ const picked = pickBestFontFile(fontFiles);
834
+ return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
835
+ }
836
+
837
+ // 2. Parse Swift files for .custom("FontName", ...) usage
838
+ const swiftFiles = findFiles(iosRoot, '.swift', 6);
839
+ for (const file of swiftFiles) {
840
+ try {
841
+ const content = fs.readFileSync(file, 'utf8');
842
+ const match = content.match(/\.custom\(\s*["']([^"']+)["']/);
843
+ if (match) return { family: match[1], file: null, weight: null };
844
+ } catch {}
845
+ }
846
+
847
+ return { family: null, file: null, weight: null };
848
+ }
849
+
850
+ // ── Expo / React Native ──────────────────────────────────────────
851
+ function detectFontExpoRN(root) {
852
+ // 1. Check common font directories
853
+ const fontDirs = [
854
+ 'assets/fonts', 'src/assets/fonts', 'app/assets/fonts',
855
+ 'assets', 'src/fonts', 'fonts',
856
+ ];
857
+
858
+ for (const dir of fontDirs) {
859
+ const full = path.join(root, dir);
860
+ if (!fs.existsSync(full)) continue;
861
+ const fonts = [
862
+ ...findFiles(full, '.ttf', 1),
863
+ ...findFiles(full, '.otf', 1),
864
+ ];
865
+ if (fonts.length > 0) {
866
+ const picked = pickBestFontFile(fonts);
867
+ return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
868
+ }
869
+ }
870
+
871
+ // 2. Check for @expo-google-fonts in package.json
872
+ const pkg = readJson(path.join(root, 'package.json'));
873
+ if (pkg?.dependencies) {
874
+ const googleFont = Object.keys(pkg.dependencies).find(d => d.startsWith('@expo-google-fonts/'));
875
+ if (googleFont) {
876
+ // e.g. "@expo-google-fonts/inter" → "Inter"
877
+ const name = googleFont.split('/')[1].split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
878
+ return { family: name, file: null, weight: null };
879
+ }
880
+ }
881
+
882
+ return { family: null, file: null, weight: null };
883
+ }
884
+
885
+ // ── Flutter ──────────────────────────────────────────────────────
886
+ function detectFontFlutter(root) {
887
+ // 1. Parse pubspec.yaml for fonts section
888
+ const pubspecPath = path.join(root, 'pubspec.yaml');
889
+ if (fs.existsSync(pubspecPath)) {
890
+ const content = fs.readFileSync(pubspecPath, 'utf8');
891
+ // Match: " fonts:\n - family: Inter"
892
+ const familyMatch = content.match(/fonts:\s*\n\s*-\s*family:\s*(.+)/);
893
+ if (familyMatch) {
894
+ const family = familyMatch[1].trim();
895
+ // Try to find the actual font file
896
+ const fontFiles = [
897
+ ...findFiles(root, '.ttf', 4),
898
+ ...findFiles(root, '.otf', 4),
899
+ ];
900
+ const matchingFile = fontFiles.find(f => path.basename(f).toLowerCase().includes(family.toLowerCase()));
901
+ const picked = matchingFile || (fontFiles.length > 0 ? pickBestFontFile(fontFiles) : null);
902
+ return { family, file: picked, weight: picked ? fontWeightFromFile(picked) : null };
903
+ }
904
+ }
905
+
906
+ // 2. Fallback: find font files in common Flutter font locations
907
+ const fontFiles = [
908
+ ...findFiles(path.join(root, 'assets', 'fonts'), '.ttf', 2),
909
+ ...findFiles(path.join(root, 'assets', 'fonts'), '.otf', 2),
910
+ ...findFiles(path.join(root, 'fonts'), '.ttf', 2),
911
+ ...findFiles(path.join(root, 'fonts'), '.otf', 2),
912
+ ];
913
+
914
+ if (fontFiles.length > 0) {
915
+ const picked = pickBestFontFile(fontFiles);
916
+ return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
917
+ }
918
+
919
+ return { family: null, file: null, weight: null };
920
+ }
921
+
922
+ // ── Android Native ───────────────────────────────────────────────
923
+ function detectFontAndroid(root) {
924
+ const fontDir = path.join(root, 'android/app/src/main/res/font');
925
+ const fontFiles = [
926
+ ...findFiles(fontDir, '.ttf', 1),
927
+ ...findFiles(fontDir, '.otf', 1),
928
+ ];
929
+
930
+ if (fontFiles.length > 0) {
931
+ const picked = pickBestFontFile(fontFiles);
932
+ return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
933
+ }
934
+
935
+ // Also check assets/fonts
936
+ const assetsDir = path.join(root, 'android/app/src/main/assets/fonts');
937
+ const assetFonts = [
938
+ ...findFiles(assetsDir, '.ttf', 1),
939
+ ...findFiles(assetsDir, '.otf', 1),
940
+ ];
941
+
942
+ if (assetFonts.length > 0) {
943
+ const picked = pickBestFontFile(assetFonts);
944
+ return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
945
+ }
946
+
947
+ return { family: null, file: null, weight: null };
948
+ }
949
+
950
+ // ── Next.js / Web ────────────────────────────────────────────────
951
+ function detectFontWeb(root) {
952
+ // 1. Check for next/font imports in layout/page files
953
+ const layoutCandidates = [
954
+ 'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
955
+ 'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.js',
956
+ 'src/app/layout.tsx', 'src/app/layout.jsx',
957
+ ];
958
+
959
+ for (const candidate of layoutCandidates) {
960
+ const filePath = path.join(root, candidate);
961
+ if (!fs.existsSync(filePath)) continue;
962
+ try {
963
+ const content = fs.readFileSync(filePath, 'utf8');
964
+ // Match: import { Inter } from 'next/font/google'
965
+ const nextFontMatch = content.match(/import\s*\{?\s*(\w+)\s*\}?\s*from\s*['"]next\/font\/google['"]/);
966
+ if (nextFontMatch) {
967
+ const name = nextFontMatch[1].replace(/_/g, ' ');
968
+ return { family: name, file: null, weight: null };
969
+ }
970
+ // Match: import localFont from 'next/font/local' ... src: './fonts/MyFont.woff2'
971
+ const localFontMatch = content.match(/src:\s*['"]([^'"]+\.(?:ttf|otf|woff2?))['"]/);
972
+ if (localFontMatch) {
973
+ const fontFile = path.resolve(path.dirname(filePath), localFontMatch[1]);
974
+ if (fs.existsSync(fontFile)) {
975
+ return { family: fontFamilyFromFile(fontFile), file: fontFile, weight: fontWeightFromFile(fontFile) };
976
+ }
977
+ }
978
+ } catch {}
979
+ }
980
+
981
+ // 2. Tailwind fontFamily
982
+ const tailwindCandidates = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'];
983
+ for (const c of tailwindCandidates) {
984
+ const filePath = path.join(root, c);
985
+ if (!fs.existsSync(filePath)) continue;
986
+ try {
987
+ const content = fs.readFileSync(filePath, 'utf8');
988
+ // Match: sans: ['Inter', ...] or heading: ['Poppins', ...]
989
+ const fontMatch = content.match(/(?:sans|heading|body|display)\s*:\s*\[\s*['"]([^'"]+)['"]/);
990
+ if (fontMatch) return { family: fontMatch[1], file: null, weight: null };
991
+ } catch {}
992
+ }
993
+
994
+ // 3. CSS @font-face
995
+ const cssCandidates = [
996
+ 'styles/globals.css', 'app/globals.css', 'src/styles/globals.css',
997
+ 'styles/global.css', 'app/global.css',
998
+ ];
999
+ for (const candidate of cssCandidates) {
1000
+ const filePath = path.join(root, candidate);
1001
+ if (!fs.existsSync(filePath)) continue;
1002
+ try {
1003
+ const content = fs.readFileSync(filePath, 'utf8');
1004
+ const faceMatch = content.match(/@font-face\s*\{[^}]*font-family:\s*['"]?([^'";]+)/);
1005
+ if (faceMatch) {
1006
+ const srcMatch = content.match(/@font-face\s*\{[^}]*src:[^}]*url\(['"]?([^'")\s]+\.(?:ttf|otf|woff2?))/);
1007
+ const fontFile = srcMatch ? path.resolve(path.dirname(filePath), srcMatch[1]) : null;
1008
+ return {
1009
+ family: faceMatch[1].trim(),
1010
+ file: fontFile && fs.existsSync(fontFile) ? fontFile : null,
1011
+ weight: null,
1012
+ };
1013
+ }
1014
+ } catch {}
1015
+ }
1016
+
1017
+ return detectFontGeneric(root);
1018
+ }
1019
+
1020
+ // ── Generic Fallback ─────────────────────────────────────────────
1021
+ function detectFontGeneric(root) {
1022
+ const fontDirs = ['fonts', 'assets/fonts', 'static/fonts', 'src/fonts', 'public/fonts'];
1023
+
1024
+ for (const dir of fontDirs) {
1025
+ const full = path.join(root, dir);
1026
+ if (!fs.existsSync(full)) continue;
1027
+ const fonts = [
1028
+ ...findFiles(full, '.ttf', 2),
1029
+ ...findFiles(full, '.otf', 2),
1030
+ ];
1031
+ if (fonts.length > 0) {
1032
+ const picked = pickBestFontFile(fonts);
1033
+ return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
1034
+ }
1035
+ }
1036
+
1037
+ return { family: null, file: null, weight: null };
1038
+ }
1039
+
1040
+ /**
1041
+ * From a list of font files, pick the best one for headlines.
1042
+ * Prefers: Bold > SemiBold > Medium > Regular. Prefers shorter family names.
1043
+ */
1044
+ function pickBestFontFile(files) {
1045
+ const weights = ['bold', 'semibold', 'semi-bold', 'medium', 'regular', 'book'];
1046
+ for (const w of weights) {
1047
+ const match = files.find(f => path.basename(f).toLowerCase().includes(w));
1048
+ if (match) return match;
1049
+ }
1050
+ return files[0];
1051
+ }
1052
+
1053
+ /**
1054
+ * Extract font family name from filename.
1055
+ * "Inter-Bold.ttf" → "Inter"
1056
+ * "WorkSans-SemiBold.otf" → "Work Sans"
1057
+ */
1058
+ function fontFamilyFromFile(filePath) {
1059
+ let name = path.basename(filePath).replace(/\.(ttf|otf|woff2?)$/i, '');
1060
+ // Strip weight/style suffixes
1061
+ name = name.replace(/[-_]?(Thin|ExtraLight|Light|Regular|Medium|SemiBold|Semi-Bold|Bold|ExtraBold|Black|Heavy|Italic|Variable|VF)$/gi, '');
1062
+ name = name.replace(/[-_]?(thin|extralight|light|regular|medium|semibold|bold|extrabold|black|heavy|italic|variable|vf)$/gi, '');
1063
+ // Convert CamelCase to spaces: "WorkSans" → "Work Sans"
1064
+ name = name.replace(/([a-z])([A-Z])/g, '$1 $2');
1065
+ // Clean up separators
1066
+ name = name.replace(/[-_]/g, ' ').trim();
1067
+ return name || null;
1068
+ }
1069
+
1070
+ /**
1071
+ * Extract weight hint from filename.
1072
+ * "Inter-Bold.ttf" → "Bold"
1073
+ */
1074
+ function fontWeightFromFile(filePath) {
1075
+ const basename = path.basename(filePath).toLowerCase();
1076
+ const weights = [
1077
+ 'thin', 'extralight', 'light', 'regular', 'medium',
1078
+ 'semibold', 'semi-bold', 'bold', 'extrabold', 'black', 'heavy',
1079
+ ];
1080
+ for (const w of weights) {
1081
+ if (basename.includes(w)) return w.charAt(0).toUpperCase() + w.slice(1);
1082
+ }
1083
+ return null;
1084
+ }
1085
+
1086
+
1087
+ // ─────────────────────────────────────────────────────────────────
1088
+ // LOGO DETECTION
1089
+ // ─────────────────────────────────────────────────────────────────
1090
+
1091
+ async function detectLogo(framework, root) {
1092
+ // 1. Check common logo filenames across typical directories
1093
+ const dirs = ['', 'assets', 'src/assets', 'images', 'src/images', 'public', 'static', 'resources'];
1094
+ const names = ['logo', 'logo-full', 'logo-dark', 'logo-light', 'wordmark', 'brand-logo', 'app-logo'];
1095
+ // Prefer vector > raster
1096
+ const exts = ['.svg', '.png', '.pdf', '.jpg'];
1097
+
1098
+ for (const dir of dirs) {
1099
+ for (const name of names) {
1100
+ for (const ext of exts) {
1101
+ const full = path.join(root, dir, name + ext);
1102
+ if (fs.existsSync(full)) return full;
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ // 2. Framework-specific splash/launch images
1108
+ if (framework.name === 'expo') {
1109
+ const appJson = readJson(path.join(root, 'app.json'));
1110
+ const splashImage = appJson?.expo?.splash?.image;
1111
+ if (splashImage) {
1112
+ const full = path.join(root, splashImage);
1113
+ if (fs.existsSync(full)) return full;
1114
+ }
1115
+ }
1116
+
1117
+ // 3. Search xcassets for .imageset directories with "logo" in the name
1118
+ const imagesets = findFiles(root, '.imageset', 8);
1119
+ for (const dir of imagesets) {
1120
+ if (path.basename(dir).toLowerCase().includes('logo')) {
1121
+ const pngs = findFiles(dir, '.png', 1);
1122
+ if (pngs.length > 0) return pngs[0];
1123
+ const svgs = findFiles(dir, '.svg', 1);
1124
+ if (svgs.length > 0) return svgs[0];
1125
+ }
1126
+ }
1127
+
1128
+ // 4. Deep search for any file with "logo" in the name
1129
+ const allFiles = [
1130
+ ...findFiles(root, '.svg', 8),
1131
+ ...findFiles(root, '.png', 8),
1132
+ ];
1133
+ const logoFile = allFiles.find(f => {
1134
+ const name = path.basename(f).toLowerCase();
1135
+ return name.includes('logo') && !f.includes('node_modules');
1136
+ });
1137
+ if (logoFile) return logoFile;
1138
+
1139
+ return null;
1140
+ }
1141
+
1142
+
1143
+ // ─────────────────────────────────────────────────────────────────
1144
+ // HELPERS
1145
+ // ─────────────────────────────────────────────────────────────────
1146
+
1147
+ function buildProfile({ primary, secondary, accent, background } = {}) {
1148
+ return {
1149
+ primary: primary || '#1A1A2E', // Deep navy default
1150
+ secondary: secondary || '#F5F5F5', // Light gray default
1151
+ accent: accent || '#E94560', // Red accent default
1152
+ background: background || '#FFFFFF',
1153
+ };
1154
+ }
1155
+
1156
+ function stripNulls(obj) {
1157
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== null));
1158
+ }
1159
+
1160
+ function readJson(filePath) {
1161
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
1162
+ catch { return null; }
1163
+ }
1164
+
1165
+ function readYaml(filePath) {
1166
+ // Minimal YAML reader for pubspec.yaml — just extracts top-level keys
1167
+ if (!fs.existsSync(filePath)) return null;
1168
+ const content = fs.readFileSync(filePath, 'utf8');
1169
+ const result = {};
1170
+ for (const line of content.split('\n')) {
1171
+ const match = line.match(/^(\w+):\s*(.+)/);
1172
+ if (match) result[match[1]] = match[2].trim();
1173
+ }
1174
+ return result;
1175
+ }
1176
+
1177
+ function readPlist(iosDir) {
1178
+ // Reads Info.plist as text — returns key/value map for simple string values
1179
+ const plistFiles = findFiles(iosDir, 'Info.plist', 3);
1180
+ if (!plistFiles.length) return null;
1181
+
1182
+ try {
1183
+ const content = fs.readFileSync(plistFiles[0], 'utf8');
1184
+ const result = {};
1185
+ const keys = content.matchAll(/<key>([^<]+)<\/key>\s*<string>([^<]+)<\/string>/g);
1186
+ for (const [, key, value] of keys) result[key] = value;
1187
+ return result;
1188
+ } catch { return null; }
1189
+ }
1190
+
1191
+ function findFiles(dir, ext, maxDepth = 3, depth = 0) {
1192
+ if (depth > maxDepth || !fs.existsSync(dir)) return [];
1193
+ const results = [];
1194
+ try {
1195
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1196
+ const full = path.join(dir, entry.name);
1197
+ if (entry.isDirectory()) {
1198
+ // Some extensions (.xcodeproj, .xcassets, .colorset, .appiconset) are directories
1199
+ if (entry.name.endsWith(ext)) {
1200
+ results.push(full);
1201
+ } else if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
1202
+ results.push(...findFiles(full, ext, maxDepth, depth + 1));
1203
+ }
1204
+ } else if (entry.name.endsWith(ext)) {
1205
+ results.push(full);
1206
+ }
1207
+ }
1208
+ } catch {}
1209
+ return results;
1210
+ }
1211
+
1212
+ function componentsToHex(components) {
1213
+ try {
1214
+ // Components can be 0-1 floats or 0-255 integers
1215
+ let r = parseFloat(components.red);
1216
+ let g = parseFloat(components.green);
1217
+ let b = parseFloat(components.blue);
1218
+ if (r <= 1 && g <= 1 && b <= 1) { r *= 255; g *= 255; b *= 255; }
1219
+ const toHex = n => Math.round(n).toString(16).padStart(2, '0');
1220
+ return '#' + toHex(r) + toHex(g) + toHex(b);
1221
+ } catch { return null; }
1222
+ }