This is page 3 of 4. Use http://codebase.md/mhmzdev/figma-flutter-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ └── config.json
├── .github
│ └── workflows
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CONTRIBUTING.md
├── docs
│ ├── cursor_rules_example.md
│ ├── figma-flutter-mcp.md
│ ├── figma-framework-mcp.md
│ ├── getting-started.md
│ └── images
│ ├── button.png
│ ├── figma-flutter-mcp.png
│ ├── screen.png
│ ├── svg.gif
│ ├── svgs_clean.gif
│ ├── text-style-frame.png
│ └── theme-frame.png
├── LICENSE.md
├── package.json
├── README.ja.md
├── README.ko.md
├── README.md
├── README.zh-cn.md
├── README.zh-tw.md
├── src
│ ├── cli.ts
│ ├── config.ts
│ ├── extractors
│ │ ├── colors
│ │ │ ├── core.ts
│ │ │ ├── extractor.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── components
│ │ │ ├── core.ts
│ │ │ ├── deduplicated-extractor.ts
│ │ │ ├── extractor.ts
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ └── variant-analyzer.ts
│ │ ├── flutter
│ │ │ ├── global-vars.ts
│ │ │ ├── index.ts
│ │ │ ├── style-library.ts
│ │ │ └── style-merger.ts
│ │ ├── screens
│ │ │ ├── core.ts
│ │ │ ├── extractor.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ └── typography
│ │ ├── core.ts
│ │ ├── extractor.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── figma-config.ts
│ ├── server.ts
│ ├── services
│ │ └── figma.ts
│ ├── tools
│ │ ├── flutter
│ │ │ ├── assets
│ │ │ │ ├── asset-manager.ts
│ │ │ │ ├── assets.ts
│ │ │ │ └── svg-assets.ts
│ │ │ ├── components
│ │ │ │ ├── component-tool.ts
│ │ │ │ ├── deduplicated-helpers.ts
│ │ │ │ └── helpers.ts
│ │ │ ├── index.ts
│ │ │ ├── screens
│ │ │ │ ├── helpers.ts
│ │ │ │ └── screen-tool.ts
│ │ │ ├── semantic-detection.ts
│ │ │ ├── theme
│ │ │ │ ├── colors
│ │ │ │ │ ├── theme-generator.ts
│ │ │ │ │ └── theme-tool.ts
│ │ │ │ └── typography
│ │ │ │ ├── typography-generator.ts
│ │ │ │ └── typography-tool.ts
│ │ │ └── visual-context.ts
│ │ └── index.ts
│ ├── types
│ │ ├── errors.ts
│ │ ├── figma.ts
│ │ └── flutter.ts
│ └── utils
│ ├── figma-url-parser.ts
│ ├── helpers.ts
│ ├── logger.ts
│ ├── retry.ts
│ └── validation.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/extractors/flutter/style-library.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/extractors/flutter/style-library.mts
2 |
3 | import { Logger } from '../../utils/logger.js';
4 |
5 | export interface FlutterStyleDefinition {
6 | id: string;
7 | category: 'decoration' | 'text' | 'layout' | 'padding';
8 | properties: Record<string, any>;
9 | flutterCode: string;
10 | hash: string;
11 | semanticHash: string;
12 | usageCount: number;
13 | parentId?: string;
14 | childIds: string[];
15 | variance?: number; // How different from parent (0-1)
16 | }
17 |
18 | export interface StyleRelationship {
19 | parentId?: string;
20 | childIds: string[];
21 | variance: number; // How different from parent (0-1)
22 | }
23 |
24 | export interface OptimizationReport {
25 | totalStyles: number;
26 | duplicatesRemoved: number;
27 | variantsCreated: number;
28 | hierarchyDepth: number;
29 | memoryReduction: string;
30 | }
31 |
32 | export class FlutterStyleLibrary {
33 | private static instance: FlutterStyleLibrary;
34 | private styles = new Map<string, FlutterStyleDefinition>();
35 | private hashToId = new Map<string, string>();
36 | private semanticHashToId = new Map<string, string>();
37 | private autoOptimizeEnabled = true;
38 | private optimizationThreshold = 20; // Auto-optimize after every N styles
39 | private lastOptimizationCount = 0;
40 |
41 | static getInstance(): FlutterStyleLibrary {
42 | if (!this.instance) {
43 | this.instance = new FlutterStyleLibrary();
44 | }
45 | return this.instance;
46 | }
47 |
48 | addStyle(category: string, properties: any, context?: string): string {
49 | const hash = this.generateHash(properties);
50 | const semanticHash = this.generateSemanticHash(properties);
51 |
52 | Logger.info(`🎨 Adding ${category} style with properties:`, JSON.stringify(properties, null, 2));
53 | Logger.info(`📝 Generated hashes - Exact: ${hash.substring(0, 20)}..., Semantic: ${semanticHash.substring(0, 20)}...`);
54 |
55 | // Check for exact matches first
56 | if (this.hashToId.has(hash)) {
57 | const existingId = this.hashToId.get(hash)!;
58 | const style = this.styles.get(existingId)!;
59 | style.usageCount++;
60 | Logger.info(`✅ Exact match found! Reusing style ${existingId} (usage: ${style.usageCount})`);
61 | return existingId;
62 | }
63 |
64 | // Check for semantic equivalents
65 | if (this.semanticHashToId.has(semanticHash)) {
66 | const existingId = this.semanticHashToId.get(semanticHash)!;
67 | const style = this.styles.get(existingId)!;
68 | style.usageCount++;
69 | Logger.info(`🔍 Semantic match found! Reusing style ${existingId} (usage: ${style.usageCount})`);
70 | return existingId;
71 | }
72 |
73 | // Check if this should be a variant of existing style
74 | const parentStyle = this.findPotentialParent(properties);
75 |
76 | if (parentStyle) {
77 | const variance = this.calculateVariance(properties, parentStyle.properties);
78 | Logger.info(`🌳 Parent style found: ${parentStyle.id} (variance: ${(variance * 100).toFixed(1)}%)`);
79 | }
80 |
81 | const generatedId = this.generateId();
82 | const styleId = `${category}${generatedId.charAt(0).toUpperCase()}${generatedId.slice(1)}`;
83 | const definition: FlutterStyleDefinition = {
84 | id: styleId,
85 | category: category as any,
86 | properties,
87 | flutterCode: this.generateFlutterCode(category, properties),
88 | hash,
89 | semanticHash,
90 | usageCount: 1,
91 | parentId: parentStyle?.id,
92 | childIds: [],
93 | variance: parentStyle ? this.calculateVariance(properties, parentStyle.properties) : undefined
94 | };
95 |
96 | // Update parent-child relationships
97 | if (parentStyle) {
98 | parentStyle.childIds.push(styleId);
99 | Logger.info(`🔗 Updated parent ${parentStyle.id} with child ${styleId}`);
100 | }
101 |
102 | Logger.info(`✨ Created new style: ${styleId} (total styles: ${this.styles.size + 1})`);
103 |
104 | this.styles.set(styleId, definition);
105 | this.hashToId.set(hash, styleId);
106 | this.semanticHashToId.set(semanticHash, styleId);
107 |
108 | // Auto-optimize if threshold is reached
109 | this.checkAutoOptimization();
110 |
111 | return styleId;
112 | }
113 |
114 | getStyle(id: string): FlutterStyleDefinition | undefined {
115 | return this.styles.get(id);
116 | }
117 |
118 | getAllStyles(): FlutterStyleDefinition[] {
119 | return Array.from(this.styles.values());
120 | }
121 |
122 | findSimilarStyles(properties: any, threshold: number = 0.8): string[] {
123 | const similarStyles: string[] = [];
124 |
125 | for (const [id, style] of this.styles) {
126 | const similarity = this.calculateSimilarity(properties, style.properties);
127 | if (similarity >= threshold && similarity < 1.0) {
128 | similarStyles.push(id);
129 | }
130 | }
131 |
132 | return similarStyles;
133 | }
134 |
135 | getStyleHierarchy(): Record<string, StyleRelationship> {
136 | const hierarchy: Record<string, StyleRelationship> = {};
137 |
138 | for (const [id, style] of this.styles) {
139 | hierarchy[id] = {
140 | parentId: style.parentId,
141 | childIds: style.childIds,
142 | variance: style.variance || 0
143 | };
144 | }
145 |
146 | return hierarchy;
147 | }
148 |
149 | optimizeLibrary(): OptimizationReport {
150 | const beforeCount = this.styles.size;
151 | let duplicatesRemoved = 0;
152 | let variantsCreated = 0;
153 | let hierarchyDepth = 0;
154 |
155 | // Find and merge exact duplicates (shouldn't happen with current logic, but safety check)
156 | const hashGroups = new Map<string, string[]>();
157 | for (const [id, style] of this.styles) {
158 | const group = hashGroups.get(style.hash) || [];
159 | group.push(id);
160 | hashGroups.set(style.hash, group);
161 | }
162 |
163 | // Remove duplicates (keep first, redirect others)
164 | for (const [hash, ids] of hashGroups) {
165 | if (ids.length > 1) {
166 | const keepId = ids[0];
167 | const keepStyle = this.styles.get(keepId)!;
168 |
169 | for (let i = 1; i < ids.length; i++) {
170 | const removeId = ids[i];
171 | const removeStyle = this.styles.get(removeId)!;
172 |
173 | // Merge usage counts
174 | keepStyle.usageCount += removeStyle.usageCount;
175 |
176 | // Remove duplicate
177 | this.styles.delete(removeId);
178 | this.hashToId.delete(removeStyle.hash);
179 | this.semanticHashToId.delete(removeStyle.semanticHash);
180 | duplicatesRemoved++;
181 | }
182 | }
183 | }
184 |
185 | // Calculate hierarchy depth
186 | for (const style of this.styles.values()) {
187 | if (style.childIds.length > 0) {
188 | variantsCreated += style.childIds.length;
189 | }
190 |
191 | // Calculate depth from this node
192 | let depth = 0;
193 | let currentStyle = style;
194 | while (currentStyle.parentId) {
195 | depth++;
196 | currentStyle = this.styles.get(currentStyle.parentId)!;
197 | if (!currentStyle) break; // Safety check
198 | }
199 | hierarchyDepth = Math.max(hierarchyDepth, depth);
200 | }
201 |
202 | const afterCount = this.styles.size;
203 | const memoryReduction = beforeCount > 0
204 | ? `${((beforeCount - afterCount) / beforeCount * 100).toFixed(1)}%`
205 | : '0%';
206 |
207 | return {
208 | totalStyles: afterCount,
209 | duplicatesRemoved,
210 | variantsCreated,
211 | hierarchyDepth,
212 | memoryReduction
213 | };
214 | }
215 |
216 | reset(): void {
217 | this.styles.clear();
218 | this.hashToId.clear();
219 | this.semanticHashToId.clear();
220 | this.lastOptimizationCount = 0;
221 | }
222 |
223 | setAutoOptimization(enabled: boolean, threshold: number = 20): void {
224 | this.autoOptimizeEnabled = enabled;
225 | this.optimizationThreshold = threshold;
226 | Logger.info(`⚙️ Auto-optimization ${enabled ? 'enabled' : 'disabled'} (threshold: ${threshold})`);
227 | }
228 |
229 | private checkAutoOptimization(): void {
230 | if (!this.autoOptimizeEnabled) return;
231 |
232 | const currentCount = this.styles.size;
233 | const stylesSinceLastOptimization = currentCount - this.lastOptimizationCount;
234 |
235 | if (stylesSinceLastOptimization >= this.optimizationThreshold) {
236 | Logger.info(`🚀 Auto-optimization triggered! (${stylesSinceLastOptimization} new styles since last optimization)`);
237 | this.runAutoOptimization();
238 | this.lastOptimizationCount = currentCount;
239 | }
240 | }
241 |
242 | private runAutoOptimization(): OptimizationReport {
243 | Logger.info(`⚡ Running auto-optimization...`);
244 | const report = this.optimizeLibrary();
245 | Logger.info(`✅ Auto-optimization complete:`, {
246 | totalStyles: report.totalStyles,
247 | duplicatesRemoved: report.duplicatesRemoved,
248 | variantsCreated: report.variantsCreated,
249 | hierarchyDepth: report.hierarchyDepth,
250 | memoryReduction: report.memoryReduction
251 | });
252 | return report;
253 | }
254 |
255 | private generateHash(properties: any): string {
256 | // Use a more robust hash generation that preserves nested object properties
257 | return JSON.stringify(properties, (key, value) => {
258 | if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
259 | // Sort object keys for consistent hashing
260 | const sortedObj: any = {};
261 | Object.keys(value).sort().forEach(k => {
262 | sortedObj[k] = value[k];
263 | });
264 | return sortedObj;
265 | }
266 | return value;
267 | });
268 | }
269 |
270 | private generateSemanticHash(properties: any): string {
271 | // Normalize property values before hashing
272 | const normalized = this.normalizeProperties(properties);
273 | Logger.info(`🔄 Normalized properties:`, JSON.stringify(normalized, null, 2));
274 |
275 | // Create semantic fingerprint that catches equivalent styles
276 | const semanticKey = this.createSemanticKey(normalized);
277 | Logger.info(`🔑 Semantic key:`, JSON.stringify(semanticKey, null, 2));
278 |
279 | return this.hashObject(semanticKey);
280 | }
281 |
282 | private normalizeProperties(properties: any): any {
283 | const normalized = JSON.parse(JSON.stringify(properties)); // Deep copy
284 |
285 | // Normalize color representations
286 | if (normalized.fills) {
287 | normalized.fills = normalized.fills.map((fill: any) => {
288 | if (fill.hex) {
289 | // Normalize hex colors (e.g., #000000 -> #000000, #000 -> #000000)
290 | let hex = fill.hex.toLowerCase();
291 | if (hex === '#000') hex = '#000000';
292 | if (hex === '#fff') hex = '#ffffff';
293 |
294 | const normalizedFill = { ...fill, hex };
295 | if (hex === '#000000') normalizedFill.normalized = 'black';
296 | if (hex === '#ffffff') normalizedFill.normalized = 'white';
297 |
298 | return normalizedFill;
299 | }
300 | return fill;
301 | });
302 | }
303 |
304 | // Normalize padding representations
305 | if (normalized.padding) {
306 | const p = normalized.padding;
307 | // EdgeInsets.all(8) === EdgeInsets.fromLTRB(8,8,8,8)
308 | if (p.top === p.right && p.right === p.bottom && p.bottom === p.left) {
309 | normalized.padding = { uniform: p.top, isUniform: true };
310 | }
311 | }
312 |
313 | // Normalize border radius
314 | if (normalized.cornerRadius && typeof normalized.cornerRadius === 'object') {
315 | const r = normalized.cornerRadius;
316 | if (r.topLeft === r.topRight && r.topRight === r.bottomLeft && r.bottomLeft === r.bottomRight) {
317 | normalized.cornerRadius = r.topLeft;
318 | }
319 | }
320 |
321 | return normalized;
322 | }
323 |
324 | private createSemanticKey(properties: any): any {
325 | // Create a semantic representation that focuses on visual impact
326 | const key: any = {};
327 |
328 | // Group similar properties - be more specific to avoid false matches
329 | if (properties.fills && properties.fills.length > 0) {
330 | // Use the actual hex value to distinguish different colors
331 | key.color = properties.fills[0].hex?.toLowerCase();
332 | }
333 |
334 | if (properties.cornerRadius !== undefined) {
335 | key.borderRadius = typeof properties.cornerRadius === 'number'
336 | ? properties.cornerRadius
337 | : JSON.stringify(properties.cornerRadius);
338 | }
339 |
340 | if (properties.padding) {
341 | key.padding = properties.padding.isUniform
342 | ? properties.padding.uniform
343 | : JSON.stringify(properties.padding);
344 | }
345 |
346 | if (properties.effects?.dropShadows?.length > 0) {
347 | key.hasShadow = true;
348 | key.shadowIntensity = properties.effects.dropShadows.length;
349 | // Include shadow details for more specificity
350 | key.shadowDetails = properties.effects.dropShadows.map((s: any) => ({
351 | color: s.hex,
352 | blur: s.radius,
353 | offset: s.offset
354 | }));
355 | }
356 |
357 | return key;
358 | }
359 |
360 | private hashObject(obj: any): string {
361 | return JSON.stringify(obj, Object.keys(obj).sort());
362 | }
363 |
364 | private findPotentialParent(properties: any, threshold: number = 0.8): FlutterStyleDefinition | undefined {
365 | const allStyles = Array.from(this.styles.values());
366 |
367 | for (const style of allStyles) {
368 | const similarity = this.calculateSimilarity(properties, style.properties);
369 | if (similarity >= threshold && similarity < 1.0) {
370 | return style;
371 | }
372 | }
373 |
374 | return undefined;
375 | }
376 |
377 | private calculateSimilarity(props1: any, props2: any): number {
378 | const keys1 = new Set(Object.keys(props1));
379 | const keys2 = new Set(Object.keys(props2));
380 | const allKeys = new Set([...keys1, ...keys2]);
381 |
382 | let matches = 0;
383 | let total = allKeys.size;
384 |
385 | for (const key of allKeys) {
386 | if (keys1.has(key) && keys2.has(key)) {
387 | // Both have the key, check if values are similar
388 | if (this.areValuesSimilar(props1[key], props2[key])) {
389 | matches++;
390 | }
391 | }
392 | // If only one has the key, it's a difference (no match)
393 | }
394 |
395 | return total > 0 ? matches / total : 0;
396 | }
397 |
398 | private areValuesSimilar(val1: any, val2: any): boolean {
399 | if (val1 === val2) return true;
400 |
401 | // Handle arrays (like fills)
402 | if (Array.isArray(val1) && Array.isArray(val2)) {
403 | if (val1.length !== val2.length) return false;
404 | return val1.every((item, index) => this.areValuesSimilar(item, val2[index]));
405 | }
406 |
407 | // Handle objects
408 | if (typeof val1 === 'object' && typeof val2 === 'object' && val1 !== null && val2 !== null) {
409 | const keys1 = Object.keys(val1);
410 | const keys2 = Object.keys(val2);
411 | if (keys1.length !== keys2.length) return false;
412 | return keys1.every(key => this.areValuesSimilar(val1[key], val2[key]));
413 | }
414 |
415 | // Handle numbers with tolerance
416 | if (typeof val1 === 'number' && typeof val2 === 'number') {
417 | return Math.abs(val1 - val2) < 0.01;
418 | }
419 |
420 | return false;
421 | }
422 |
423 | private calculateVariance(childProps: any, parentProps: any): number {
424 | const similarity = this.calculateSimilarity(childProps, parentProps);
425 | return 1 - similarity; // Variance is inverse of similarity
426 | }
427 |
428 | private generateId(): string {
429 | return Date.now().toString(36) + Math.random().toString(36).substr(2, 4);
430 | }
431 |
432 | private generateFlutterCode(category: string, properties: any): string {
433 | switch (category) {
434 | case 'decoration':
435 | return FlutterCodeGenerator.generateDecoration(properties);
436 | case 'text':
437 | return FlutterCodeGenerator.generateTextStyle(properties);
438 | case 'padding':
439 | return FlutterCodeGenerator.generatePadding(properties);
440 | case 'layout':
441 | // Layout code generation can be added later
442 | return `// ${category} implementation`;
443 | default:
444 | return `// ${category} implementation`;
445 | }
446 | }
447 | }
448 |
449 | export class FlutterCodeGenerator {
450 | static generateDecoration(properties: any): string {
451 | let code = 'BoxDecoration(\n';
452 |
453 | if (properties.fills?.length > 0) {
454 | const fill = properties.fills[0];
455 | if (fill.hex) {
456 | code += ` color: Color(0xFF${fill.hex.substring(1)}),\n`;
457 | }
458 | }
459 |
460 | if (properties.cornerRadius !== undefined) {
461 | if (typeof properties.cornerRadius === 'number') {
462 | code += ` borderRadius: BorderRadius.circular(${properties.cornerRadius}),\n`;
463 | } else {
464 | const r = properties.cornerRadius;
465 | code += ` borderRadius: BorderRadius.only(\n`;
466 | code += ` topLeft: Radius.circular(${r.topLeft}),\n`;
467 | code += ` topRight: Radius.circular(${r.topRight}),\n`;
468 | code += ` bottomLeft: Radius.circular(${r.bottomLeft}),\n`;
469 | code += ` bottomRight: Radius.circular(${r.bottomRight}),\n`;
470 | code += ` ),\n`;
471 | }
472 | }
473 |
474 | if (properties.effects?.dropShadows?.length > 0) {
475 | code += ` boxShadow: [\n`;
476 | properties.effects.dropShadows.forEach((shadow: any) => {
477 | code += ` BoxShadow(\n`;
478 | code += ` color: Color(0xFF${shadow.hex.substring(1)}).withOpacity(${shadow.opacity}),\n`;
479 | code += ` offset: Offset(${shadow.offset.x}, ${shadow.offset.y}),\n`;
480 | code += ` blurRadius: ${shadow.radius},\n`;
481 | if (shadow.spread) {
482 | code += ` spreadRadius: ${shadow.spread},\n`;
483 | }
484 | code += ` ),\n`;
485 | });
486 | code += ` ],\n`;
487 | }
488 |
489 | code += ')';
490 | return code;
491 | }
492 |
493 | static generatePadding(properties: any): string {
494 | const p = properties.padding;
495 | if (!p) return 'EdgeInsets.zero';
496 |
497 | if (p.isUniform) {
498 | return `EdgeInsets.all(${p.top})`;
499 | }
500 | return `EdgeInsets.fromLTRB(${p.left}, ${p.top}, ${p.right}, ${p.bottom})`;
501 | }
502 |
503 | static generateTextStyle(properties: any): string {
504 | const parts: string[] = [];
505 |
506 | if (properties.fontFamily) {
507 | parts.push(`fontFamily: '${properties.fontFamily}'`);
508 | }
509 | if (properties.fontSize) {
510 | parts.push(`fontSize: ${properties.fontSize}`);
511 | }
512 | if (properties.fontWeight && properties.fontWeight !== 400) {
513 | const weight = properties.fontWeight >= 700 ? 'FontWeight.bold' :
514 | properties.fontWeight >= 600 ? 'FontWeight.w600' :
515 | properties.fontWeight >= 500 ? 'FontWeight.w500' :
516 | 'FontWeight.normal';
517 | parts.push(`fontWeight: ${weight}`);
518 | }
519 |
520 | return `TextStyle(${parts.join(', ')})`;
521 | }
522 | }
523 |
```
--------------------------------------------------------------------------------
/src/tools/flutter/screens/helpers.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/tools/flutter/screens/helpers.mts
2 |
3 | import type {ScreenAnalysis, ScreenSection, NavigationElement, ScreenAssetInfo} from "../../../extractors/screens/types.js";
4 | import {generateScreenVisualContext} from "../visual-context.js";
5 |
6 | /**
7 | * Generate comprehensive screen analysis report
8 | */
9 | export function generateScreenAnalysisReport(
10 | analysis: ScreenAnalysis,
11 | parsedInput?: any
12 | ): string {
13 | let output = `Screen Analysis Report\n\n`;
14 |
15 | // Screen metadata
16 | output += `Screen: ${analysis.metadata.name}\n`;
17 | output += `Type: ${analysis.metadata.type}\n`;
18 | output += `Node ID: ${analysis.metadata.nodeId}\n`;
19 | output += `Device Type: ${analysis.metadata.deviceType}\n`;
20 | output += `Orientation: ${analysis.metadata.orientation}\n`;
21 | output += `Dimensions: ${Math.round(analysis.metadata.dimensions.width)}×${Math.round(analysis.metadata.dimensions.height)}px\n`;
22 | if (parsedInput) {
23 | output += `Source: ${parsedInput.source === 'url' ? 'Figma URL' : 'Direct input'}\n`;
24 | }
25 | output += `\n`;
26 |
27 | // Screen layout information
28 | output += `Screen Layout:\n`;
29 | output += `- Layout Type: ${analysis.layout.type}\n`;
30 | if (analysis.layout.scrollable) {
31 | output += `- Scrollable: Yes\n`;
32 | }
33 | if (analysis.layout.hasHeader) {
34 | output += `- Has Header: Yes\n`;
35 | }
36 | if (analysis.layout.hasFooter) {
37 | output += `- Has Footer: Yes\n`;
38 | }
39 | if (analysis.layout.hasNavigation) {
40 | output += `- Has Navigation: Yes\n`;
41 | }
42 | if (analysis.layout.contentArea) {
43 | const area = analysis.layout.contentArea;
44 | output += `- Content Area: ${Math.round(area.width)}×${Math.round(area.height)}px at (${Math.round(area.x)}, ${Math.round(area.y)})\n`;
45 | }
46 | output += `\n`;
47 |
48 | // Screen sections
49 | if (analysis.sections.length > 0) {
50 | output += `Screen Sections (${analysis.sections.length} identified):\n`;
51 | analysis.sections.forEach((section, index) => {
52 | output += `${index + 1}. ${section.name} (${section.type.toUpperCase()})\n`;
53 | output += ` Priority: ${section.importance}/10\n`;
54 |
55 | if (section.layout.dimensions) {
56 | const dims = section.layout.dimensions;
57 | output += ` Size: ${Math.round(dims.width)}×${Math.round(dims.height)}px\n`;
58 | }
59 |
60 | if (section.children.length > 0) {
61 | output += ` Contains: ${section.children.length} elements\n`;
62 | }
63 |
64 | if (section.components.length > 0) {
65 | output += ` Components: ${section.components.length} nested component(s)\n`;
66 | }
67 | });
68 | output += `\n`;
69 | }
70 |
71 | // Navigation information
72 | if (analysis.navigation.navigationElements.length > 0) {
73 | output += `Navigation Elements:\n`;
74 |
75 | if (analysis.navigation.hasTabBar) output += `- Has Tab Bar\n`;
76 | if (analysis.navigation.hasAppBar) output += `- Has App Bar\n`;
77 | if (analysis.navigation.hasDrawer) output += `- Has Drawer\n`;
78 | if (analysis.navigation.hasBottomSheet) output += `- Has Bottom Sheet\n`;
79 |
80 | output += `\nNavigation Items (${analysis.navigation.navigationElements.length}):\n`;
81 | analysis.navigation.navigationElements.forEach((nav, index) => {
82 | const activeMark = nav.isActive ? ' [ACTIVE]' : '';
83 | const iconMark = nav.icon ? ' 🎯' : '';
84 | output += `${index + 1}. ${nav.name} (${nav.type.toUpperCase()})${activeMark}${iconMark}\n`;
85 | if (nav.text) {
86 | output += ` Text: "${nav.text}"\n`;
87 | }
88 | });
89 | output += `\n`;
90 | }
91 |
92 | // Assets information
93 | if (analysis.assets.length > 0) {
94 | output += `Screen Assets (${analysis.assets.length} found):\n`;
95 |
96 | const assetsByType = groupAssetsByType(analysis.assets);
97 | Object.entries(assetsByType).forEach(([type, assets]) => {
98 | output += `${type.toUpperCase()} (${assets.length}):\n`;
99 | assets.forEach(asset => {
100 | output += `- ${asset.name} (${asset.size}, ${asset.usage})\n`;
101 | });
102 | });
103 | output += `\n`;
104 | }
105 |
106 | // Nested components for separate analysis
107 | if (analysis.components.length > 0) {
108 | output += `Nested Components Found (${analysis.components.length}):\n`;
109 | output += `These components should be analyzed separately:\n`;
110 | analysis.components.forEach((comp, index) => {
111 | output += `${index + 1}. ${comp.name}\n`;
112 | output += ` Node ID: ${comp.nodeId}\n`;
113 | output += ` Type: ${comp.instanceType || 'COMPONENT'}\n`;
114 | if (comp.componentKey) {
115 | output += ` Component Key: ${comp.componentKey}\n`;
116 | }
117 | });
118 | output += `\n`;
119 | }
120 |
121 | // Skipped nodes report
122 | if (analysis.skippedNodes && analysis.skippedNodes.length > 0) {
123 | output += `Analysis Limitations:\n`;
124 |
125 | const deviceUISkipped = analysis.skippedNodes.filter(node => node.reason === 'device_ui_element');
126 | const limitSkipped = analysis.skippedNodes.filter(node => node.reason === 'max_sections');
127 |
128 | if (deviceUISkipped.length > 0) {
129 | output += `${deviceUISkipped.length} device UI elements were automatically filtered out:\n`;
130 | deviceUISkipped.forEach((skipped, index) => {
131 | output += `${index + 1}. ${skipped.name} (${skipped.type}) - device UI placeholder\n`;
132 | });
133 | output += `\n`;
134 | }
135 |
136 | if (limitSkipped.length > 0) {
137 | output += `${limitSkipped.length} sections were skipped due to limits:\n`;
138 | limitSkipped.forEach((skipped, index) => {
139 | output += `${index + 1}. ${skipped.name} (${skipped.type}) - ${skipped.reason}\n`;
140 | });
141 | output += `\nTo analyze all sections, increase the maxSections parameter.\n`;
142 | }
143 |
144 | output += `\n`;
145 | }
146 |
147 | // Visual context for AI implementation
148 | if (parsedInput?.source === 'url') {
149 | // Reconstruct the Figma URL from the parsed input
150 | const figmaUrl = `https://www.figma.com/design/${parsedInput.fileId}/?node-id=${parsedInput.nodeId}`;
151 | output += generateScreenVisualContext(analysis, figmaUrl, parsedInput.nodeId);
152 | output += `\n`;
153 | }
154 |
155 | // Flutter implementation guidance
156 | output += generateFlutterScreenGuidance(analysis);
157 |
158 | return output;
159 | }
160 |
161 | /**
162 | * Generate screen structure inspection report
163 | */
164 | export function generateScreenStructureReport(node: any, showAllSections: boolean): string {
165 | let output = `Screen Structure Inspection\n\n`;
166 |
167 | output += `Screen: ${node.name}\n`;
168 | output += `Type: ${node.type}\n`;
169 | output += `Node ID: ${node.id}\n`;
170 | output += `Sections: ${node.children?.length || 0}\n`;
171 |
172 | if (node.absoluteBoundingBox) {
173 | const bbox = node.absoluteBoundingBox;
174 | output += `Dimensions: ${Math.round(bbox.width)}×${Math.round(bbox.height)}px\n`;
175 |
176 | // Device type detection
177 | const deviceType = bbox.width > bbox.height ? 'Landscape' : 'Portrait';
178 | const screenSize = Math.max(bbox.width, bbox.height) > 1200 ? 'Desktop' :
179 | Math.max(bbox.width, bbox.height) > 800 ? 'Tablet' : 'Mobile';
180 | output += `Device: ${screenSize} ${deviceType}\n`;
181 | }
182 |
183 | output += `\n`;
184 |
185 | if (!node.children || node.children.length === 0) {
186 | output += `This screen has no sections.\n`;
187 | return output;
188 | }
189 |
190 | output += `Screen Structure:\n`;
191 |
192 | const sectionsToShow = showAllSections ? node.children : node.children.slice(0, 20);
193 | const hasMore = node.children.length > sectionsToShow.length;
194 |
195 | sectionsToShow.forEach((section: any, index: number) => {
196 | const isComponent = section.type === 'COMPONENT' || section.type === 'INSTANCE';
197 | const componentMark = isComponent ? ' [COMPONENT]' : '';
198 | const hiddenMark = section.visible === false ? ' [HIDDEN]' : '';
199 |
200 | // Detect section type
201 | const sectionType = detectSectionTypeFromName(section.name);
202 | const typeMark = sectionType !== 'content' ? ` [${sectionType.toUpperCase()}]` : '';
203 |
204 | output += `${index + 1}. ${section.name} (${section.type})${componentMark}${typeMark}${hiddenMark}\n`;
205 |
206 | if (section.absoluteBoundingBox) {
207 | const bbox = section.absoluteBoundingBox;
208 | output += ` Size: ${Math.round(bbox.width)}×${Math.round(bbox.height)}px\n`;
209 | output += ` Position: (${Math.round(bbox.x)}, ${Math.round(bbox.y)})\n`;
210 | }
211 |
212 | if (section.children && section.children.length > 0) {
213 | output += ` Contains: ${section.children.length} child elements\n`;
214 |
215 | // Show component count
216 | const componentCount = section.children.filter((child: any) =>
217 | child.type === 'COMPONENT' || child.type === 'INSTANCE'
218 | ).length;
219 | if (componentCount > 0) {
220 | output += ` Components: ${componentCount} nested component(s)\n`;
221 | }
222 | }
223 |
224 | // Show basic styling info
225 | if (section.fills && section.fills.length > 0) {
226 | const fill = section.fills[0];
227 | if (fill.color) {
228 | const hex = rgbaToHex(fill.color);
229 | output += ` Background: ${hex}\n`;
230 | }
231 | }
232 | });
233 |
234 | if (hasMore) {
235 | output += `\n... and ${node.children.length - sectionsToShow.length} more sections.\n`;
236 | output += `Use showAllSections: true to see all sections.\n`;
237 | }
238 |
239 | // Analysis recommendations
240 | output += `\nAnalysis Recommendations:\n`;
241 |
242 | const componentSections = node.children.filter((section: any) =>
243 | section.type === 'COMPONENT' || section.type === 'INSTANCE'
244 | );
245 | if (componentSections.length > 0) {
246 | output += `- Found ${componentSections.length} component sections for separate analysis\n`;
247 | }
248 |
249 | const largeSections = node.children.filter((section: any) => {
250 | const bbox = section.absoluteBoundingBox;
251 | return bbox && (bbox.width * bbox.height) > 20000;
252 | });
253 | if (largeSections.length > 5) {
254 | output += `- Screen has ${largeSections.length} large sections - consider increasing maxSections\n`;
255 | }
256 |
257 | // Detect navigation elements
258 | const navSections = node.children.filter((section: any) => {
259 | const name = section.name.toLowerCase();
260 | return name.includes('nav') || name.includes('tab') || name.includes('menu') ||
261 | name.includes('header') || name.includes('footer');
262 | });
263 | if (navSections.length > 0) {
264 | output += `- Found ${navSections.length} navigation-related sections\n`;
265 | }
266 |
267 | return output;
268 | }
269 |
270 | /**
271 | * Generate Flutter screen implementation guidance
272 | */
273 | export function generateFlutterScreenGuidance(analysis: ScreenAnalysis): string {
274 | let guidance = `Flutter Screen Implementation Guidance:\n\n`;
275 |
276 | // Widget composition best practices
277 | guidance += `🏗️ Widget Composition Best Practices:\n`;
278 | guidance += `- Start by building the complete screen widget tree in a single build() method\n`;
279 | guidance += `- Keep composing widgets inline until you reach ~250 lines of code\n`;
280 | guidance += `- Only then break down into private StatelessWidget classes for sections\n`;
281 | guidance += `- Use private widgets (prefix with _) for internal screen component breakdown\n`;
282 | guidance += `- Avoid functional widgets - always use StatelessWidget classes\n\n`;
283 |
284 | guidance += `📱 Device UI Filtering:\n`;
285 | guidance += `- Status bars, battery icons, wifi indicators are automatically filtered out\n`;
286 | guidance += `- Home indicators, notches, and device bezels are ignored during analysis\n`;
287 | guidance += `- Only actual app design content is analyzed for Flutter implementation\n`;
288 | guidance += `- Use SafeArea widget in Flutter to handle device-specific insets\n\n`;
289 |
290 | // Main scaffold structure
291 | guidance += `Main Screen Structure:\n`;
292 | guidance += `Scaffold(\n`;
293 |
294 | // App bar
295 | if (analysis.navigation.hasAppBar) {
296 | guidance += ` appBar: AppBar(\n`;
297 | guidance += ` title: Text('${analysis.metadata.name}'),\n`;
298 | guidance += ` // Add app bar actions and styling\n`;
299 | guidance += ` ),\n`;
300 | }
301 |
302 | // Drawer
303 | if (analysis.navigation.hasDrawer) {
304 | guidance += ` drawer: Drawer(\n`;
305 | guidance += ` // Add drawer content\n`;
306 | guidance += ` ),\n`;
307 | }
308 |
309 | // Body structure
310 | guidance += ` body: `;
311 |
312 | if (analysis.layout.scrollable) {
313 | guidance += `SingleChildScrollView(\n`;
314 | guidance += ` child: Column(\n`;
315 | guidance += ` children: [\n`;
316 | } else {
317 | guidance += `Column(\n`;
318 | guidance += ` children: [\n`;
319 | }
320 |
321 | // Add sections
322 | analysis.sections.forEach((section, index) => {
323 | const widgetName = toPascalCase(section.name);
324 | guidance += ` ${widgetName}(), // ${section.type} section\n`;
325 | });
326 |
327 | guidance += ` ],\n`;
328 | guidance += ` ),\n`;
329 |
330 | if (analysis.layout.scrollable) {
331 | guidance += ` ),\n`;
332 | }
333 |
334 | // Bottom navigation
335 | if (analysis.navigation.hasTabBar) {
336 | guidance += ` bottomNavigationBar: BottomNavigationBar(\n`;
337 | guidance += ` items: [\n`;
338 |
339 | const tabItems = analysis.navigation.navigationElements.filter(nav => nav.type === 'tab');
340 | tabItems.slice(0, 5).forEach(tab => {
341 | guidance += ` BottomNavigationBarItem(\n`;
342 | guidance += ` icon: Icon(Icons.${tab.icon ? 'placeholder' : 'home'}),\n`;
343 | guidance += ` label: '${tab.text || tab.name}',\n`;
344 | guidance += ` ),\n`;
345 | });
346 |
347 | guidance += ` ],\n`;
348 | guidance += ` ),\n`;
349 | }
350 |
351 | guidance += `)\n\n`;
352 |
353 | // Section widgets guidance
354 | if (analysis.sections.length > 0) {
355 | guidance += `Section Widgets:\n`;
356 | analysis.sections.forEach((section, index) => {
357 | const widgetName = toPascalCase(section.name);
358 | guidance += `${index + 1}. ${widgetName}() - ${section.type} section\n`;
359 | guidance += ` Elements: ${section.children.length} child elements\n`;
360 | if (section.components.length > 0) {
361 | guidance += ` Components: ${section.components.length} nested components\n`;
362 | }
363 | });
364 | guidance += `\n`;
365 | }
366 |
367 | // Navigation guidance
368 | if (analysis.navigation.navigationElements.length > 0) {
369 | guidance += `Navigation Implementation:\n`;
370 |
371 | const buttons = analysis.navigation.navigationElements.filter(nav => nav.type === 'button');
372 | const tabs = analysis.navigation.navigationElements.filter(nav => nav.type === 'tab');
373 | const links = analysis.navigation.navigationElements.filter(nav => nav.type === 'link');
374 |
375 | if (buttons.length > 0) {
376 | guidance += `Buttons (${buttons.length}):\n`;
377 | buttons.forEach(button => {
378 | guidance += `- ElevatedButton(onPressed: () {}, child: Text('${button.text || button.name}'))\n`;
379 | });
380 | }
381 |
382 | if (tabs.length > 0) {
383 | guidance += `Tab Navigation (${tabs.length}):\n`;
384 | guidance += `- Use TabBar with ${tabs.length} tabs\n`;
385 | guidance += `- Consider TabBarView for content switching\n`;
386 | }
387 |
388 | if (links.length > 0) {
389 | guidance += `Links (${links.length}):\n`;
390 | links.forEach(link => {
391 | guidance += `- TextButton(onPressed: () {}, child: Text('${link.text || link.name}'))\n`;
392 | });
393 | }
394 |
395 | guidance += `\n`;
396 | }
397 |
398 | // Asset guidance
399 | if (analysis.assets.length > 0) {
400 | guidance += `Assets Implementation:\n`;
401 |
402 | const images = analysis.assets.filter(asset => asset.type === 'image');
403 | const icons = analysis.assets.filter(asset => asset.type === 'icon');
404 | const illustrations = analysis.assets.filter(asset => asset.type === 'illustration');
405 |
406 | if (images.length > 0) {
407 | guidance += `Images (${images.length}): Use Image.asset() or Image.network()\n`;
408 | }
409 |
410 | if (icons.length > 0) {
411 | guidance += `Icons (${icons.length}): Use Icon() widget with appropriate IconData\n`;
412 | }
413 |
414 | if (illustrations.length > 0) {
415 | guidance += `Illustrations (${illustrations.length}): Use SvgPicture or Image.asset()\n`;
416 | }
417 |
418 | guidance += `\n`;
419 | }
420 |
421 | // Responsive design guidance
422 | guidance += `Responsive Design:\n`;
423 | guidance += `- Device Type: ${analysis.metadata.deviceType}\n`;
424 | guidance += `- Orientation: ${analysis.metadata.orientation}\n`;
425 |
426 | if (analysis.metadata.deviceType === 'mobile') {
427 | guidance += `- Optimize for mobile: Use SingleChildScrollView, consider bottom navigation\n`;
428 | } else if (analysis.metadata.deviceType === 'tablet') {
429 | guidance += `- Tablet layout: Consider using NavigationRail or side navigation\n`;
430 | } else if (analysis.metadata.deviceType === 'desktop') {
431 | guidance += `- Desktop layout: Use NavigationRail, consider multi-column layouts\n`;
432 | }
433 |
434 | return guidance;
435 | }
436 |
437 | // Helper functions
438 | function groupAssetsByType(assets: ScreenAssetInfo[]): Record<string, ScreenAssetInfo[]> {
439 | return assets.reduce((acc, asset) => {
440 | if (!acc[asset.type]) {
441 | acc[asset.type] = [];
442 | }
443 | acc[asset.type].push(asset);
444 | return acc;
445 | }, {} as Record<string, ScreenAssetInfo[]>);
446 | }
447 |
448 | function detectSectionTypeFromName(name: string): string {
449 | const lowerName = name.toLowerCase();
450 |
451 | if (lowerName.includes('header') || lowerName.includes('app bar')) return 'header';
452 | if (lowerName.includes('footer') || lowerName.includes('bottom')) return 'footer';
453 | if (lowerName.includes('nav') || lowerName.includes('menu')) return 'navigation';
454 | if (lowerName.includes('modal') || lowerName.includes('dialog')) return 'modal';
455 | if (lowerName.includes('sidebar')) return 'sidebar';
456 |
457 | return 'content';
458 | }
459 |
460 | function toPascalCase(str: string): string {
461 | return str
462 | .replace(/[^a-zA-Z0-9]/g, ' ')
463 | .replace(/\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
464 | .replace(/\s/g, '');
465 | }
466 |
467 | function rgbaToHex(color: {r: number; g: number; b: number; a?: number}): string {
468 | const r = Math.round(color.r * 255);
469 | const g = Math.round(color.g * 255);
470 | const b = Math.round(color.b * 255);
471 |
472 | return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase();
473 | }
474 |
```
--------------------------------------------------------------------------------
/src/tools/flutter/visual-context.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/tools/flutter/visual-context.ts
2 |
3 | import type { ComponentAnalysis } from '../../extractors/components/types.js';
4 | import type { ScreenAnalysis } from '../../extractors/screens/types.js';
5 |
6 | /**
7 | * Generate visual context for component analysis
8 | */
9 | export function generateComponentVisualContext(
10 | analysis: ComponentAnalysis,
11 | figmaUrl?: string,
12 | nodeId?: string
13 | ): string {
14 | let context = `📐 Visual Context for AI Implementation:\n`;
15 | context += `${'='.repeat(50)}\n\n`;
16 |
17 | // Design reference
18 | if (figmaUrl) {
19 | context += `🎨 Design Reference:\n`;
20 | context += ` • Figma URL: ${figmaUrl}\n`;
21 | if (nodeId) {
22 | context += ` • Node ID: ${nodeId}\n`;
23 | }
24 | context += ` • Component: ${analysis.metadata.name}\n`;
25 | context += ` • Type: ${analysis.metadata.type}\n\n`;
26 | }
27 |
28 | // ASCII layout representation
29 | context += `📏 Layout Structure:\n`;
30 | context += generateComponentAsciiLayout(analysis);
31 | context += `\n`;
32 |
33 | // Spatial relationships
34 | context += `📍 Spatial Relationships:\n`;
35 | context += generateComponentSpatialDescription(analysis);
36 | context += `\n`;
37 |
38 | // Visual patterns
39 | context += `🎯 Visual Design Patterns:\n`;
40 | context += generateComponentPatternDescription(analysis);
41 | context += `\n`;
42 |
43 | // Implementation guidance
44 | context += `💡 Implementation Guidance:\n`;
45 | context += generateComponentImplementationHints(analysis);
46 |
47 | // Semantic detection information
48 | context += `\n🧠 Enhanced Semantic Detection:\n`;
49 | context += ` • Multi-factor analysis with confidence scoring\n`;
50 | context += ` • Context-aware classification using position and parent information\n`;
51 | context += ` • Design pattern recognition for improved accuracy\n`;
52 | context += ` • Fallback to legacy detection for low-confidence classifications\n`;
53 | context += ` • Reduced false positives through evidence-based classification\n`;
54 |
55 | return context;
56 | }
57 |
58 | /**
59 | * Generate visual context for screen analysis
60 | */
61 | export function generateScreenVisualContext(
62 | analysis: ScreenAnalysis,
63 | figmaUrl?: string,
64 | nodeId?: string
65 | ): string {
66 | let context = `📱 Screen Visual Context for AI Implementation:\n`;
67 | context += `${'='.repeat(55)}\n\n`;
68 |
69 | // Design reference
70 | if (figmaUrl) {
71 | context += `🎨 Design Reference:\n`;
72 | context += ` • Figma URL: ${figmaUrl}\n`;
73 | if (nodeId) {
74 | context += ` • Node ID: ${nodeId}\n`;
75 | }
76 | context += ` • Screen: ${analysis.metadata.name}\n`;
77 | context += ` • Device: ${analysis.metadata.deviceType} (${analysis.metadata.orientation})\n\n`;
78 | }
79 |
80 | // Screen layout with ASCII representation
81 | context += `📏 Screen Layout Structure:\n`;
82 | context += generateScreenAsciiLayout(analysis);
83 | context += `\n`;
84 |
85 | // Visual hierarchy
86 | context += `📊 Visual Hierarchy (top to bottom):\n`;
87 | context += generateScreenHierarchy(analysis);
88 | context += `\n`;
89 |
90 | // Spatial relationships
91 | context += `📍 Component Spatial Relationships:\n`;
92 | context += generateScreenSpatialDescription(analysis);
93 | context += `\n`;
94 |
95 | // Design patterns
96 | context += `🎯 Visual Design Patterns Detected:\n`;
97 | context += generateScreenPatternDescription(analysis);
98 | context += `\n`;
99 |
100 | // Implementation guidance
101 | context += `💡 Flutter Implementation Strategy:\n`;
102 | context += generateScreenImplementationHints(analysis);
103 |
104 | // Semantic detection information
105 | context += `\n🧠 Enhanced Semantic Detection:\n`;
106 | context += ` • Advanced section type detection with confidence scoring\n`;
107 | context += ` • Multi-factor analysis for text element classification\n`;
108 | context += ` • Context-aware position and styling analysis\n`;
109 | context += ` • Improved navigation and interactive element detection\n`;
110 | context += ` • Reduced misclassification through evidence-based decisions\n`;
111 |
112 | if (figmaUrl) {
113 | context += `\n🔗 Reference for Verification:\n`;
114 | context += ` View the original design at: ${figmaUrl}\n`;
115 | context += ` Use this to verify your implementation matches the intended visual design.\n`;
116 | }
117 |
118 | return context;
119 | }
120 |
121 | /**
122 | * Generate ASCII layout for component
123 | */
124 | function generateComponentAsciiLayout(analysis: ComponentAnalysis): string {
125 | const width = Math.min(Math.max(Math.round(analysis.layout.dimensions.width / 20), 10), 50);
126 | const height = Math.min(Math.max(Math.round(analysis.layout.dimensions.height / 20), 3), 10);
127 |
128 | let ascii = `┌${'─'.repeat(width)}┐\n`;
129 |
130 | // Add component name in the middle
131 | const nameLines = Math.floor(height / 2);
132 | for (let i = 0; i < height; i++) {
133 | if (i === nameLines) {
134 | const name = analysis.metadata.name.substring(0, width - 2);
135 | const padding = Math.max(0, Math.floor((width - name.length) / 2));
136 | ascii += `│${' '.repeat(padding)}${name}${' '.repeat(width - padding - name.length)}│\n`;
137 | } else {
138 | ascii += `│${' '.repeat(width)}│\n`;
139 | }
140 | }
141 |
142 | ascii += `└${'─'.repeat(width)}┘\n`;
143 | ascii += `Dimensions: ${Math.round(analysis.layout.dimensions.width)}×${Math.round(analysis.layout.dimensions.height)}px\n`;
144 |
145 | // Add layout type indicator
146 | if (analysis.layout.type === 'auto-layout') {
147 | const direction = analysis.layout.direction === 'horizontal' ? '↔' : '↕';
148 | ascii += `Layout: Auto-layout ${direction} (${analysis.layout.direction})\n`;
149 | if (analysis.layout.spacing) {
150 | ascii += `Spacing: ${analysis.layout.spacing}px\n`;
151 | }
152 | }
153 |
154 | return ascii;
155 | }
156 |
157 | /**
158 | * Generate ASCII layout for screen
159 | */
160 | function generateScreenAsciiLayout(analysis: ScreenAnalysis): string {
161 | const screenWidth = Math.min(Math.max(Math.round(analysis.metadata.dimensions.width / 30), 15), 40);
162 | const screenHeight = Math.min(Math.max(Math.round(analysis.metadata.dimensions.height / 40), 8), 20);
163 |
164 | let ascii = `📱 Screen Layout Map:\n`;
165 | ascii += `┌${'─'.repeat(screenWidth)}┐\n`;
166 |
167 | // Categorize sections by position
168 | const headerSections = analysis.sections.filter(s => s.type === 'header');
169 | const contentSections = analysis.sections.filter(s => s.type === 'content');
170 | const footerSections = analysis.sections.filter(s => s.type === 'footer');
171 | const navSections = analysis.sections.filter(s => s.type === 'navigation');
172 |
173 | let currentLine = 0;
174 |
175 | // Header area
176 | if (headerSections.length > 0) {
177 | const headerLines = Math.ceil(screenHeight * 0.15);
178 | for (let i = 0; i < headerLines && currentLine < screenHeight; i++) {
179 | if (i === Math.floor(headerLines / 2)) {
180 | const text = 'HEADER';
181 | const padding = Math.max(0, Math.floor((screenWidth - text.length) / 2));
182 | ascii += `│${' '.repeat(padding)}${text}${' '.repeat(screenWidth - padding - text.length)}│\n`;
183 | } else {
184 | ascii += `│${' '.repeat(screenWidth)}│\n`;
185 | }
186 | currentLine++;
187 | }
188 | }
189 |
190 | // Content area
191 | const contentLines = screenHeight - currentLine - (footerSections.length > 0 ? Math.ceil(screenHeight * 0.15) : 0);
192 | for (let i = 0; i < contentLines && currentLine < screenHeight; i++) {
193 | if (i === Math.floor(contentLines / 2)) {
194 | const text = 'CONTENT';
195 | const padding = Math.max(0, Math.floor((screenWidth - text.length) / 2));
196 | ascii += `│${' '.repeat(padding)}${text}${' '.repeat(screenWidth - padding - text.length)}│\n`;
197 | } else {
198 | ascii += `│${' '.repeat(screenWidth)}│\n`;
199 | }
200 | currentLine++;
201 | }
202 |
203 | // Footer area
204 | if (footerSections.length > 0) {
205 | const remainingLines = screenHeight - currentLine;
206 | for (let i = 0; i < remainingLines; i++) {
207 | if (i === Math.floor(remainingLines / 2)) {
208 | const text = navSections.length > 0 ? 'NAVIGATION' : 'FOOTER';
209 | const padding = Math.max(0, Math.floor((screenWidth - text.length) / 2));
210 | ascii += `│${' '.repeat(padding)}${text}${' '.repeat(screenWidth - padding - text.length)}│\n`;
211 | } else {
212 | ascii += `│${' '.repeat(screenWidth)}│\n`;
213 | }
214 | }
215 | }
216 |
217 | ascii += `└${'─'.repeat(screenWidth)}┘\n`;
218 | ascii += `Screen: ${Math.round(analysis.metadata.dimensions.width)}×${Math.round(analysis.metadata.dimensions.height)}px (${analysis.metadata.deviceType})\n`;
219 |
220 | return ascii;
221 | }
222 |
223 | /**
224 | * Generate component spatial description
225 | */
226 | function generateComponentSpatialDescription(analysis: ComponentAnalysis): string {
227 | let description = '';
228 |
229 | // Layout analysis
230 | if (analysis.layout.type === 'auto-layout') {
231 | description += ` • Layout flow: ${analysis.layout.direction} auto-layout\n`;
232 | if (analysis.layout.spacing) {
233 | description += ` • Element spacing: ${analysis.layout.spacing}px consistent\n`;
234 | }
235 | if (analysis.layout.alignItems) {
236 | description += ` • Cross-axis alignment: ${analysis.layout.alignItems}\n`;
237 | }
238 | if (analysis.layout.justifyContent) {
239 | description += ` • Main-axis alignment: ${analysis.layout.justifyContent}\n`;
240 | }
241 | } else {
242 | description += ` • Layout flow: absolute positioning\n`;
243 | }
244 |
245 | // Padding analysis
246 | if (analysis.layout.padding) {
247 | const p = analysis.layout.padding;
248 | if (p.isUniform) {
249 | description += ` • Internal padding: ${p.top}px uniform\n`;
250 | } else {
251 | description += ` • Internal padding: ${p.top}px ${p.right}px ${p.bottom}px ${p.left}px (TRBL)\n`;
252 | }
253 | }
254 |
255 | // Children positioning
256 | if (analysis.children.length > 0) {
257 | description += ` • Contains ${analysis.children.length} child elements\n`;
258 | const highImportanceChildren = analysis.children.filter(c => c.visualImportance >= 7);
259 | if (highImportanceChildren.length > 0) {
260 | description += ` • ${highImportanceChildren.length} high-priority elements (visual weight ≥7)\n`;
261 | }
262 | }
263 |
264 | return description;
265 | }
266 |
267 | /**
268 | * Generate screen spatial description
269 | */
270 | function generateScreenSpatialDescription(analysis: ScreenAnalysis): string {
271 | let description = '';
272 |
273 | // Screen zones
274 | const screenHeight = analysis.metadata.dimensions.height;
275 | description += ` • Header zone: Y < ${Math.round(screenHeight * 0.15)}px (top 15%)\n`;
276 | description += ` • Content zone: Y ${Math.round(screenHeight * 0.15)}px - ${Math.round(screenHeight * 0.85)}px\n`;
277 | description += ` • Footer zone: Y > ${Math.round(screenHeight * 0.85)}px (bottom 15%)\n`;
278 |
279 | // Content area
280 | if (analysis.layout.contentArea) {
281 | const area = analysis.layout.contentArea;
282 | description += ` • Primary content area: ${Math.round(area.width)}×${Math.round(area.height)}px\n`;
283 | description += ` • Content position: (${Math.round(area.x)}, ${Math.round(area.y)})\n`;
284 | }
285 |
286 | // Section distribution
287 | if (analysis.sections.length > 0) {
288 | const sectionsByType = analysis.sections.reduce((acc, section) => {
289 | acc[section.type] = (acc[section.type] || 0) + 1;
290 | return acc;
291 | }, {} as Record<string, number>);
292 |
293 | description += ` • Section distribution: `;
294 | Object.entries(sectionsByType).forEach(([type, count], index) => {
295 | description += `${count} ${type}${index < Object.keys(sectionsByType).length - 1 ? ', ' : ''}`;
296 | });
297 | description += `\n`;
298 | }
299 |
300 | return description;
301 | }
302 |
303 | /**
304 | * Generate screen hierarchy
305 | */
306 | function generateScreenHierarchy(analysis: ScreenAnalysis): string {
307 | let hierarchy = '';
308 |
309 | // Sort sections by importance and position
310 | const sortedSections = [...analysis.sections].sort((a, b) => {
311 | // First by type priority (header > content > footer)
312 | const typePriority = { header: 3, navigation: 2, content: 1, footer: 0, sidebar: 1, modal: 2, other: 0 };
313 | const aPriority = typePriority[a.type] || 0;
314 | const bPriority = typePriority[b.type] || 0;
315 | if (aPriority !== bPriority) return bPriority - aPriority;
316 |
317 | // Then by importance score
318 | return b.importance - a.importance;
319 | });
320 |
321 | sortedSections.forEach((section, index) => {
322 | const position = section.layout.dimensions ?
323 | `${Math.round(section.layout.dimensions.width)}×${Math.round(section.layout.dimensions.height)}px` :
324 | 'auto';
325 | hierarchy += ` ${index + 1}. ${section.name} (${section.type.toUpperCase()}) - ${position}\n`;
326 | hierarchy += ` Priority: ${section.importance}/10`;
327 | if (section.children.length > 0) {
328 | hierarchy += `, Contains: ${section.children.length} elements`;
329 | }
330 | if (section.components.length > 0) {
331 | hierarchy += `, Components: ${section.components.length}`;
332 | }
333 | hierarchy += `\n`;
334 | });
335 |
336 | return hierarchy;
337 | }
338 |
339 | /**
340 | * Generate component pattern description
341 | */
342 | function generateComponentPatternDescription(analysis: ComponentAnalysis): string {
343 | let patterns = '';
344 |
345 | // Layout pattern
346 | patterns += ` • Layout type: ${analysis.layout.type}\n`;
347 |
348 | // Spacing pattern
349 | if (analysis.layout.spacing !== undefined) {
350 | patterns += ` • Spacing system: ${analysis.layout.spacing}px consistent\n`;
351 | }
352 |
353 | // Visual styling patterns
354 | if (analysis.styling.fills && analysis.styling.fills.length > 0) {
355 | const primaryColor = analysis.styling.fills[0].hex;
356 | patterns += ` • Color pattern: Primary ${primaryColor}\n`;
357 | }
358 |
359 | if (analysis.styling.cornerRadius !== undefined) {
360 | const radius = typeof analysis.styling.cornerRadius === 'number'
361 | ? analysis.styling.cornerRadius
362 | : `${analysis.styling.cornerRadius.topLeft}px mixed`;
363 | patterns += ` • Border radius: ${radius}px consistent\n`;
364 | }
365 |
366 | // Component grouping
367 | if (analysis.children.length > 0) {
368 | const componentChildren = analysis.children.filter(c => c.isNestedComponent).length;
369 | if (componentChildren > 0) {
370 | patterns += ` • Component composition: ${componentChildren}/${analysis.children.length} nested components\n`;
371 | }
372 | }
373 |
374 | // Visual weight
375 | const textElements = analysis.children.filter(c => c.type === 'TEXT').length;
376 | const visualElements = analysis.children.length - textElements;
377 | patterns += ` • Content balance: ${textElements} text, ${visualElements} visual elements\n`;
378 |
379 | return patterns;
380 | }
381 |
382 | /**
383 | * Generate screen pattern description
384 | */
385 | function generateScreenPatternDescription(analysis: ScreenAnalysis): string {
386 | let patterns = '';
387 |
388 | // Overall layout pattern
389 | patterns += ` • Layout type: ${analysis.layout.type}\n`;
390 | if (analysis.layout.scrollable) {
391 | patterns += ` • Scroll behavior: vertical scrolling enabled\n`;
392 | }
393 |
394 | // Section patterns
395 | const sectionTypes = analysis.sections.map(s => s.type);
396 | const hasStandardLayout = sectionTypes.includes('header') && sectionTypes.includes('content');
397 | patterns += ` • Screen structure: ${hasStandardLayout ? 'standard' : 'custom'} layout pattern\n`;
398 |
399 | // Navigation patterns
400 | if (analysis.navigation.hasTabBar) patterns += ` • Navigation: bottom tab bar\n`;
401 | if (analysis.navigation.hasAppBar) patterns += ` • Navigation: top app bar\n`;
402 | if (analysis.navigation.hasDrawer) patterns += ` • Navigation: side drawer\n`;
403 |
404 | // Visual weight distribution
405 | const headerSections = analysis.sections.filter(s => s.type === 'header').length;
406 | const contentSections = analysis.sections.filter(s => s.type === 'content').length;
407 | const footerSections = analysis.sections.filter(s => s.type === 'footer' || s.type === 'navigation').length;
408 |
409 | if (headerSections > contentSections) {
410 | patterns += ` • Visual weight: header-heavy design\n`;
411 | } else if (footerSections > contentSections) {
412 | patterns += ` • Visual weight: bottom-heavy design\n`;
413 | } else {
414 | patterns += ` • Visual weight: content-focused design\n`;
415 | }
416 |
417 | return patterns;
418 | }
419 |
420 | /**
421 | * Generate component implementation hints
422 | */
423 | function generateComponentImplementationHints(analysis: ComponentAnalysis): string {
424 | let hints = '';
425 |
426 | // Main container suggestion
427 | if (analysis.layout.type === 'auto-layout') {
428 | const widget = analysis.layout.direction === 'horizontal' ? 'Row' : 'Column';
429 | hints += ` • Main container: Use ${widget}() for ${analysis.layout.direction} layout\n`;
430 | if (analysis.layout.spacing) {
431 | hints += ` • Spacing: Add SizedBox gaps of ${analysis.layout.spacing}px\n`;
432 | }
433 | } else {
434 | hints += ` • Main container: Use Stack() or Container() for absolute positioning\n`;
435 | }
436 |
437 | // Styling approach
438 | if (analysis.styling.fills || analysis.styling.strokes || analysis.styling.cornerRadius !== undefined) {
439 | hints += ` • Styling: Implement BoxDecoration for visual styling\n`;
440 | }
441 |
442 | // Text handling
443 | const textChildren = analysis.children.filter(c => c.type === 'TEXT');
444 | if (textChildren.length > 0) {
445 | hints += ` • Text elements: ${textChildren.length} Text() widgets with custom styling\n`;
446 | }
447 |
448 | // Component composition
449 | const nestedComponents = analysis.children.filter(c => c.isNestedComponent);
450 | if (nestedComponents.length > 0) {
451 | hints += ` • Component structure: Break down ${nestedComponents.length} nested components\n`;
452 | }
453 |
454 | // Responsive considerations
455 | if (analysis.layout.dimensions.width > 400) {
456 | hints += ` • Responsive: Consider MediaQuery for larger screens\n`;
457 | }
458 |
459 | return hints;
460 | }
461 |
462 | /**
463 | * Generate screen implementation hints
464 | */
465 | function generateScreenImplementationHints(analysis: ScreenAnalysis): string {
466 | let hints = '';
467 |
468 | // Main scaffold structure
469 | hints += ` • Main structure: Scaffold with systematic layout\n`;
470 |
471 | // App bar recommendation
472 | if (analysis.navigation.hasAppBar) {
473 | hints += ` • App bar: AppBar widget for top navigation\n`;
474 | }
475 |
476 | // Body structure
477 | if (analysis.layout.scrollable) {
478 | hints += ` • Body: SingleChildScrollView with Column layout\n`;
479 | } else {
480 | hints += ` • Body: Column layout for fixed content\n`;
481 | }
482 |
483 | // Navigation recommendation
484 | if (analysis.navigation.hasTabBar) {
485 | hints += ` • Bottom navigation: BottomNavigationBar for tab switching\n`;
486 | }
487 | if (analysis.navigation.hasDrawer) {
488 | hints += ` • Side navigation: Drawer widget for menu access\n`;
489 | }
490 |
491 | // Section breakdown
492 | if (analysis.sections.length > 3) {
493 | hints += ` • Widget organization: Break into ${analysis.sections.length} section widgets\n`;
494 | }
495 |
496 | // Device considerations
497 | if (analysis.metadata.deviceType === 'mobile') {
498 | hints += ` • Mobile optimization: Use SafeArea and responsive sizing\n`;
499 | } else if (analysis.metadata.deviceType === 'tablet') {
500 | hints += ` • Tablet layout: Consider NavigationRail for wider screens\n`;
501 | }
502 |
503 | // Asset handling
504 | if (analysis.assets.length > 0) {
505 | hints += ` • Assets: ${analysis.assets.length} image/icon assets to implement\n`;
506 | }
507 |
508 | return hints;
509 | }
510 |
```
--------------------------------------------------------------------------------
/src/tools/flutter/components/helpers.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type {ComponentVariant} from "../../../extractors/components/types.js";
2 | import type {ComponentAnalysis} from "../../../extractors/components/types.js";
3 | import {generateFlutterTextWidget} from "../../../extractors/components/extractor.js";
4 | import {generateComponentVisualContext} from "../visual-context.js";
5 |
6 | /**
7 | * Generate variant selection prompt when there are more than 3 variants
8 | */
9 | export function generateVariantSelectionPrompt(
10 | componentName: string,
11 | selectionInfo: any,
12 | variants: ComponentVariant[]
13 | ): string {
14 | let output = `Component Set "${componentName}" has ${selectionInfo.totalCount} variants.\n\n`;
15 | output += `Since there are more than 3 variants, please specify which ones to analyze.\n\n`;
16 |
17 | output += `Available variants:\n`;
18 | variants.forEach((variant, index) => {
19 | const defaultMark = variant.isDefault ? ' (default)' : '';
20 | output += `${index + 1}. ${variant.name}${defaultMark}\n`;
21 | });
22 |
23 | output += `\nVariant properties:\n`;
24 | Object.entries(selectionInfo.variantProperties).forEach(([prop, values]: [string, any]) => {
25 | output += `- ${prop}: ${Array.from(values).join(', ')}\n`;
26 | });
27 |
28 | if (selectionInfo.defaultVariant) {
29 | output += `\nDefault variant: ${selectionInfo.defaultVariant.name}\n`;
30 | }
31 |
32 | output += `\nTo analyze specific variants, run the tool again with:\n`;
33 | output += `variantSelection: ["variant name 1", "variant name 2"]\n\n`;
34 | output += `Or to analyze all variants (may be token-intensive):\n`;
35 | output += `variantSelection: ${JSON.stringify(variants.slice(0, 3).map(v => v.name))}\n`;
36 |
37 | return output;
38 | }
39 |
40 | /**
41 | * Generate comprehensive component analysis report
42 | */
43 | export function generateComponentAnalysisReport(
44 | analysis: ComponentAnalysis,
45 | variantAnalysis?: ComponentVariant[],
46 | selectedVariants?: ComponentVariant[],
47 | parsedInput?: any
48 | ): string {
49 | let output = `Component Analysis Report\n\n`;
50 |
51 | // Component metadata
52 | output += `Component: ${analysis.metadata.name}\n`;
53 | output += `Type: ${analysis.metadata.type}\n`;
54 | output += `Node ID: ${analysis.metadata.nodeId}\n`;
55 | if (parsedInput) {
56 | output += `Source: ${parsedInput.source === 'url' ? 'Figma URL' : 'Direct input'}\n`;
57 | }
58 | output += `\n`;
59 |
60 | // Variant information
61 | if (variantAnalysis && variantAnalysis.length > 0) {
62 | output += `Variants Analysis:\n`;
63 | if (selectedVariants && selectedVariants.length > 0) {
64 | output += `Analyzed variants (${selectedVariants.length} of ${variantAnalysis.length}):\n`;
65 | selectedVariants.forEach(variant => {
66 | const defaultMark = variant.isDefault ? ' (default)' : '';
67 | output += `- ${variant.name}${defaultMark}\n`;
68 | });
69 | } else {
70 | output += `Total variants: ${variantAnalysis.length}\n`;
71 | }
72 | output += `\n`;
73 | }
74 |
75 | // Layout information
76 | output += `Layout Structure:\n`;
77 | output += `- Type: ${analysis.layout.type}\n`;
78 | output += `- Dimensions: ${Math.round(analysis.layout.dimensions.width)}×${Math.round(analysis.layout.dimensions.height)}px\n`;
79 |
80 | if (analysis.layout.direction) {
81 | output += `- Direction: ${analysis.layout.direction}\n`;
82 | }
83 | if (analysis.layout.spacing !== undefined) {
84 | output += `- Spacing: ${analysis.layout.spacing}px\n`;
85 | }
86 | if (analysis.layout.padding) {
87 | const p = analysis.layout.padding;
88 | if (p.isUniform) {
89 | output += `- Padding: ${p.top}px (uniform)\n`;
90 | } else {
91 | output += `- Padding: ${p.top}px ${p.right}px ${p.bottom}px ${p.left}px\n`;
92 | }
93 | }
94 | if (analysis.layout.alignItems) {
95 | output += `- Align Items: ${analysis.layout.alignItems}\n`;
96 | }
97 | if (analysis.layout.justifyContent) {
98 | output += `- Justify Content: ${analysis.layout.justifyContent}\n`;
99 | }
100 | output += `\n`;
101 |
102 | // Styling information
103 | output += `Visual Styling:\n`;
104 | if (analysis.styling.fills && analysis.styling.fills.length > 0) {
105 | const fill = analysis.styling.fills[0];
106 | output += `- Background: ${fill.hex || fill.type}`;
107 | if (fill.opacity && fill.opacity !== 1) {
108 | output += ` (${Math.round(fill.opacity * 100)}% opacity)`;
109 | }
110 | output += `\n`;
111 | }
112 | if (analysis.styling.strokes && analysis.styling.strokes.length > 0) {
113 | const stroke = analysis.styling.strokes[0];
114 | output += `- Border: ${stroke.weight}px solid ${stroke.hex}\n`;
115 | }
116 | if (analysis.styling.cornerRadius !== undefined) {
117 | if (typeof analysis.styling.cornerRadius === 'number') {
118 | output += `- Corner radius: ${analysis.styling.cornerRadius}px\n`;
119 | } else {
120 | const r = analysis.styling.cornerRadius;
121 | output += `- Corner radius: ${r.topLeft}px ${r.topRight}px ${r.bottomRight}px ${r.bottomLeft}px\n`;
122 | }
123 | }
124 | if (analysis.styling.opacity && analysis.styling.opacity !== 1) {
125 | output += `- Opacity: ${Math.round(analysis.styling.opacity * 100)}%\n`;
126 | }
127 |
128 | // Effects (shadows, blurs)
129 | if (analysis.styling.effects) {
130 | const effects = analysis.styling.effects;
131 | if (effects.dropShadows.length > 0) {
132 | effects.dropShadows.forEach((shadow, index) => {
133 | output += `- Drop shadow ${index + 1}: ${shadow.hex} offset(${shadow.offset.x}, ${shadow.offset.y}) blur ${shadow.radius}px`;
134 | if (shadow.spread) {
135 | output += ` spread ${shadow.spread}px`;
136 | }
137 | output += `\n`;
138 | });
139 | }
140 | if (effects.innerShadows.length > 0) {
141 | output += `- Inner shadows: ${effects.innerShadows.length} effect(s)\n`;
142 | }
143 | if (effects.blurs.length > 0) {
144 | output += `- Blur effects: ${effects.blurs.length} effect(s)\n`;
145 | }
146 | }
147 | output += `\n`;
148 |
149 | // Children information
150 | if (analysis.children.length > 0) {
151 | output += `Child Elements (${analysis.children.length} analyzed):\n`;
152 | analysis.children.forEach((child, index) => {
153 | const componentMark = child.isNestedComponent ? ' [COMPONENT]' : '';
154 | const importanceMark = ` (priority: ${child.visualImportance}/10)`;
155 | output += `${index + 1}. ${child.name} (${child.type})${componentMark}${importanceMark}\n`;
156 |
157 | if (child.basicInfo?.layout?.dimensions) {
158 | const dims = child.basicInfo.layout.dimensions;
159 | output += ` Size: ${Math.round(dims.width)}×${Math.round(dims.height)}px\n`;
160 | }
161 |
162 | if (child.basicInfo?.styling?.fills && child.basicInfo.styling.fills.length > 0) {
163 | output += ` Background: ${child.basicInfo.styling.fills[0].hex}\n`;
164 | }
165 |
166 | if (child.basicInfo?.text) {
167 | const textInfo = child.basicInfo.text;
168 | const placeholderMark = textInfo.isPlaceholder ? ' [PLACEHOLDER]' : '';
169 | const semanticMark = textInfo.semanticType && textInfo.semanticType !== 'other' ? ` [${textInfo.semanticType.toUpperCase()}]` : '';
170 |
171 | output += ` Text Content: "${textInfo.content}"${placeholderMark}${semanticMark}\n`;
172 |
173 | if (textInfo.fontFamily || textInfo.fontSize || textInfo.fontWeight) {
174 | const fontParts = [];
175 | if (textInfo.fontFamily) fontParts.push(textInfo.fontFamily);
176 | if (textInfo.fontSize) fontParts.push(`${textInfo.fontSize}px`);
177 | if (textInfo.fontWeight && textInfo.fontWeight !== 400) fontParts.push(`weight: ${textInfo.fontWeight}`);
178 | output += ` Typography: ${fontParts.join(' ')}\n`;
179 | }
180 |
181 | if (textInfo.textCase && textInfo.textCase !== 'mixed') {
182 | output += ` Text Case: ${textInfo.textCase}\n`;
183 | }
184 | }
185 | });
186 | output += `\n`;
187 | }
188 |
189 | // Nested components for separate analysis
190 | if (analysis.nestedComponents.length > 0) {
191 | output += `Nested Components Found (${analysis.nestedComponents.length}):\n`;
192 | output += `These components should be analyzed separately to maintain reusability:\n`;
193 | analysis.nestedComponents.forEach((comp, index) => {
194 | output += `${index + 1}. ${comp.name}\n`;
195 | output += ` Node ID: ${comp.nodeId}\n`;
196 | output += ` Type: ${comp.instanceType || 'COMPONENT'}\n`;
197 | if (comp.componentKey) {
198 | output += ` Component Key: ${comp.componentKey}\n`;
199 | }
200 | });
201 | output += `\n`;
202 | }
203 |
204 | // Skipped nodes report
205 | if (analysis.skippedNodes && analysis.skippedNodes.length > 0) {
206 | output += `Analysis Limitations:\n`;
207 | output += `${analysis.skippedNodes.length} nodes were skipped due to the maxChildNodes limit:\n`;
208 | analysis.skippedNodes.forEach((skipped, index) => {
209 | output += `${index + 1}. ${skipped.name} (${skipped.type})\n`;
210 | });
211 | output += `\nTo analyze all nodes, increase the maxChildNodes parameter.\n\n`;
212 | }
213 |
214 | // Visual context for AI implementation
215 | if (parsedInput?.source === 'url') {
216 | // Reconstruct the Figma URL from the parsed input
217 | const figmaUrl = `https://www.figma.com/design/${parsedInput.fileId}/?node-id=${parsedInput.nodeId}`;
218 | output += generateComponentVisualContext(analysis, figmaUrl, parsedInput.nodeId);
219 | output += `\n`;
220 | }
221 |
222 | // Flutter implementation guidance
223 | output += generateFlutterGuidance(analysis);
224 |
225 | return output;
226 | }
227 |
228 | /**
229 | * Generate Flutter implementation guidance
230 | */
231 | export function generateFlutterGuidance(analysis: ComponentAnalysis): string {
232 | let guidance = `Flutter Implementation Guidance:\n\n`;
233 |
234 | // Widget composition best practices
235 | guidance += `🏗️ Widget Composition Best Practices:\n`;
236 | guidance += `- Start by building the complete widget tree in a single build() method\n`;
237 | guidance += `- Keep composing widgets inline until you reach ~200 lines of code\n`;
238 | guidance += `- Only then extract reusable parts into private StatelessWidget classes\n`;
239 | guidance += `- Use private widgets (prefix with _) for internal component breakdown\n`;
240 | guidance += `- Avoid functional widgets - always use StatelessWidget classes\n\n`;
241 |
242 | // Main container guidance
243 | guidance += `Main Widget Structure:\n`;
244 | if (analysis.layout.type === 'auto-layout') {
245 | const containerWidget = analysis.layout.direction === 'horizontal' ? 'Row' : 'Column';
246 | guidance += `- Use ${containerWidget}() as the main layout widget\n`;
247 |
248 | if (analysis.layout.spacing && analysis.layout.spacing > 0) {
249 | const spacingWidget = analysis.layout.direction === 'horizontal' ? 'width' : 'height';
250 | guidance += `- Add spacing with SizedBox(${spacingWidget}: ${analysis.layout.spacing})\n`;
251 | }
252 |
253 | if (analysis.layout.alignItems) {
254 | guidance += `- CrossAxisAlignment: ${mapFigmaToFlutterAlignment(analysis.layout.alignItems)}\n`;
255 | }
256 | if (analysis.layout.justifyContent) {
257 | guidance += `- MainAxisAlignment: ${mapFigmaToFlutterAlignment(analysis.layout.justifyContent)}\n`;
258 | }
259 | } else {
260 | guidance += `- Use Container() or Stack() for layout\n`;
261 | }
262 |
263 | // Styling guidance
264 | if (hasVisualStyling(analysis.styling)) {
265 | guidance += `\nContainer Decoration:\n`;
266 | guidance += `Container(\n`;
267 | guidance += ` decoration: BoxDecoration(\n`;
268 |
269 | if (analysis.styling.fills && analysis.styling.fills.length > 0) {
270 | const fill = analysis.styling.fills[0];
271 | if (fill.hex) {
272 | guidance += ` color: Color(0xFF${fill.hex.substring(1)}),\n`;
273 | }
274 | }
275 |
276 | if (analysis.styling.strokes && analysis.styling.strokes.length > 0) {
277 | const stroke = analysis.styling.strokes[0];
278 | guidance += ` border: Border.all(\n`;
279 | guidance += ` color: Color(0xFF${stroke.hex.substring(1)}),\n`;
280 | guidance += ` width: ${stroke.weight},\n`;
281 | guidance += ` ),\n`;
282 | }
283 |
284 | if (analysis.styling.cornerRadius !== undefined) {
285 | if (typeof analysis.styling.cornerRadius === 'number') {
286 | guidance += ` borderRadius: BorderRadius.circular(${analysis.styling.cornerRadius}),\n`;
287 | } else {
288 | const r = analysis.styling.cornerRadius;
289 | guidance += ` borderRadius: BorderRadius.only(\n`;
290 | guidance += ` topLeft: Radius.circular(${r.topLeft}),\n`;
291 | guidance += ` topRight: Radius.circular(${r.topRight}),\n`;
292 | guidance += ` bottomLeft: Radius.circular(${r.bottomLeft}),\n`;
293 | guidance += ` bottomRight: Radius.circular(${r.bottomRight}),\n`;
294 | guidance += ` ),\n`;
295 | }
296 | }
297 |
298 | if (analysis.styling.effects?.dropShadows.length) {
299 | guidance += ` boxShadow: [\n`;
300 | analysis.styling.effects.dropShadows.forEach(shadow => {
301 | guidance += ` BoxShadow(\n`;
302 | guidance += ` color: Color(0xFF${shadow.hex.substring(1)}).withOpacity(${shadow.opacity.toFixed(2)}),\n`;
303 | guidance += ` offset: Offset(${shadow.offset.x}, ${shadow.offset.y}),\n`;
304 | guidance += ` blurRadius: ${shadow.radius},\n`;
305 | if (shadow.spread) {
306 | guidance += ` spreadRadius: ${shadow.spread},\n`;
307 | }
308 | guidance += ` ),\n`;
309 | });
310 | guidance += ` ],\n`;
311 | }
312 |
313 | guidance += ` ),\n`;
314 |
315 | if (analysis.layout.padding) {
316 | const p = analysis.layout.padding;
317 | if (p.isUniform) {
318 | guidance += ` padding: EdgeInsets.all(${p.top}),\n`;
319 | } else {
320 | guidance += ` padding: EdgeInsets.fromLTRB(${p.left}, ${p.top}, ${p.right}, ${p.bottom}),\n`;
321 | }
322 | }
323 |
324 | guidance += ` child: /* Your content here */\n`;
325 | guidance += `)\n\n`;
326 | }
327 |
328 | // Component organization guidance
329 | if (analysis.nestedComponents.length > 0) {
330 | guidance += `Component Architecture:\n`;
331 | guidance += `Create separate widget classes for reusability:\n`;
332 | analysis.nestedComponents.forEach((comp, index) => {
333 | const widgetName = toPascalCase(comp.name);
334 | guidance += `${index + 1}. ${widgetName}() - Node ID: ${comp.nodeId}\n`;
335 | });
336 | guidance += `\nAnalyze each nested component separately using the analyze_figma_component tool.\n\n`;
337 | }
338 |
339 | // Text widget guidance with enhanced Flutter suggestions
340 | const textChildren = analysis.children.filter(child => child.type === 'TEXT');
341 | if (textChildren.length > 0) {
342 | guidance += `Text Elements & Flutter Widgets:\n`;
343 | textChildren.forEach((textChild, index) => {
344 | const textInfo = textChild.basicInfo?.text;
345 | if (textInfo) {
346 | // Import the generateFlutterTextWidget function result
347 | const widgetSuggestion = generateFlutterTextWidget(textInfo);
348 | const placeholderNote = textInfo.isPlaceholder ? ' // Placeholder text - replace with actual content' : '';
349 | const semanticNote = textInfo.semanticType && textInfo.semanticType !== 'other' ? ` // Detected as ${textInfo.semanticType}` : '';
350 |
351 | guidance += `${index + 1}. "${textInfo.content}"${placeholderNote}${semanticNote}\n`;
352 | guidance += ` Flutter Widget:\n`;
353 |
354 | // Indent the widget suggestion
355 | const indentedWidget = widgetSuggestion.split('\n').map(line => ` ${line}`).join('\n');
356 | guidance += `${indentedWidget}\n\n`;
357 | } else {
358 | guidance += `${index + 1}. Text('${textChild.name}') // No text info available\n\n`;
359 | }
360 | });
361 | }
362 |
363 | return guidance;
364 | }
365 |
366 | /**
367 | * Generate structure inspection report
368 | */
369 | export function generateStructureInspectionReport(node: any, showAllChildren: boolean): string {
370 | let output = `Component Structure Inspection\n\n`;
371 |
372 | output += `Component: ${node.name}\n`;
373 | output += `Type: ${node.type}\n`;
374 | output += `Node ID: ${node.id}\n`;
375 | output += `Children: ${node.children?.length || 0}\n`;
376 |
377 | if (node.absoluteBoundingBox) {
378 | const bbox = node.absoluteBoundingBox;
379 | output += `Dimensions: ${Math.round(bbox.width)}×${Math.round(bbox.height)}px\n`;
380 | }
381 |
382 | output += `\n`;
383 |
384 | if (!node.children || node.children.length === 0) {
385 | output += `This component has no children.\n`;
386 | return output;
387 | }
388 |
389 | output += `Child Structure:\n`;
390 |
391 | const childrenToShow = showAllChildren ? node.children : node.children.slice(0, 15);
392 | const hasMore = node.children.length > childrenToShow.length;
393 |
394 | childrenToShow.forEach((child: any, index: number) => {
395 | const isComponent = child.type === 'COMPONENT' || child.type === 'INSTANCE';
396 | const componentMark = isComponent ? ' [COMPONENT]' : '';
397 | const hiddenMark = child.visible === false ? ' [HIDDEN]' : '';
398 |
399 | output += `${index + 1}. ${child.name} (${child.type})${componentMark}${hiddenMark}\n`;
400 |
401 | if (child.absoluteBoundingBox) {
402 | const bbox = child.absoluteBoundingBox;
403 | output += ` Size: ${Math.round(bbox.width)}×${Math.round(bbox.height)}px\n`;
404 | }
405 |
406 | if (child.children && child.children.length > 0) {
407 | output += ` Contains: ${child.children.length} child nodes\n`;
408 | }
409 |
410 | // Show basic styling info
411 | if (child.fills && child.fills.length > 0) {
412 | const fill = child.fills[0];
413 | if (fill.color) {
414 | const hex = rgbaToHex(fill.color);
415 | output += ` Background: ${hex}\n`;
416 | }
417 | }
418 | });
419 |
420 | if (hasMore) {
421 | output += `\n... and ${node.children.length - childrenToShow.length} more children.\n`;
422 | output += `Use showAllChildren: true to see all children.\n`;
423 | }
424 |
425 | // Analysis recommendations
426 | output += `\nAnalysis Recommendations:\n`;
427 | const componentChildren = node.children.filter((child: any) =>
428 | child.type === 'COMPONENT' || child.type === 'INSTANCE'
429 | );
430 |
431 | if (componentChildren.length > 0) {
432 | output += `- Found ${componentChildren.length} nested components for separate analysis\n`;
433 | }
434 |
435 | const largeChildren = node.children.filter((child: any) => {
436 | const bbox = child.absoluteBoundingBox;
437 | return bbox && (bbox.width * bbox.height) > 5000;
438 | });
439 |
440 | if (largeChildren.length > 3) {
441 | output += `- Component has ${largeChildren.length} large children - consider increasing maxChildNodes\n`;
442 | }
443 |
444 | const textChildren = node.children.filter((child: any) => child.type === 'TEXT');
445 | if (textChildren.length > 0) {
446 | output += `- Found ${textChildren.length} text nodes for content extraction\n`;
447 | }
448 |
449 | return output;
450 | }
451 |
452 | // Helper functions
453 | export function mapFigmaToFlutterAlignment(alignment: string): string {
454 | const alignmentMap: Record<string, string> = {
455 | 'MIN': 'CrossAxisAlignment.start',
456 | 'CENTER': 'CrossAxisAlignment.center',
457 | 'MAX': 'CrossAxisAlignment.end',
458 | 'SPACE_BETWEEN': 'MainAxisAlignment.spaceBetween',
459 | 'SPACE_AROUND': 'MainAxisAlignment.spaceAround',
460 | 'SPACE_EVENLY': 'MainAxisAlignment.spaceEvenly'
461 | };
462 |
463 | return alignmentMap[alignment] || 'CrossAxisAlignment.center';
464 | }
465 |
466 | export function hasVisualStyling(styling: any): boolean {
467 | return !!(styling.fills?.length || styling.strokes?.length ||
468 | styling.cornerRadius !== undefined || styling.effects?.dropShadows?.length);
469 | }
470 |
471 | export function toPascalCase(str: string): string {
472 | return str
473 | .replace(/[^a-zA-Z0-9]/g, ' ')
474 | .replace(/\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
475 | .replace(/\s/g, '');
476 | }
477 |
478 | export function rgbaToHex(color: {r: number; g: number; b: number; a?: number}): string {
479 | const r = Math.round(color.r * 255);
480 | const g = Math.round(color.g * 255);
481 | const b = Math.round(color.b * 255);
482 |
483 | return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase();
484 | }
```
--------------------------------------------------------------------------------
/src/tools/flutter/semantic-detection.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/tools/flutter/semantic-detection.ts
2 |
3 | /**
4 | * Advanced semantic detection with multi-factor analysis and confidence scoring
5 | * Based on heuristics improvement recommendations
6 | */
7 |
8 | export interface SemanticContext {
9 | parentType?: string;
10 | siblingTypes: string[];
11 | screenPosition: 'header' | 'content' | 'footer' | 'unknown';
12 | componentType: 'form' | 'navigation' | 'content' | 'card' | 'button-group' | 'unknown';
13 | layoutDirection?: 'horizontal' | 'vertical';
14 | isInteractive?: boolean;
15 | hasActionableNeighbors?: boolean;
16 | }
17 |
18 | export interface SemanticClassification {
19 | type: 'heading' | 'body' | 'label' | 'button' | 'link' | 'caption' | 'error' | 'success' | 'warning' | 'other';
20 | confidence: number; // 0-1
21 | alternatives: Array<{type: string; confidence: number}>;
22 | reasoning: string[];
23 | }
24 |
25 | export interface PositionContext {
26 | isTopLevel: boolean;
27 | isBottomLevel: boolean;
28 | isIsolated: boolean;
29 | visualWeight: number;
30 | relativeSize: number;
31 | hasNearbyInteractive: boolean;
32 | }
33 |
34 | export interface TextAnalysis {
35 | isActionable: boolean;
36 | isDescriptive: boolean;
37 | isNavigational: boolean;
38 | isStatusMessage: boolean;
39 | length: number;
40 | structure: 'imperative' | 'declarative' | 'question' | 'fragment' | 'mixed';
41 | hasActionWords: boolean;
42 | hasImperativeForm: boolean;
43 | }
44 |
45 | export interface DesignPattern {
46 | name: string;
47 | confidence: number;
48 | indicators: string[];
49 | }
50 |
51 | /**
52 | * Enhanced semantic type detection with multi-factor analysis
53 | */
54 | export function detectSemanticTypeAdvanced(
55 | content: string,
56 | nodeName: string,
57 | context: SemanticContext,
58 | nodeProperties?: any
59 | ): SemanticClassification {
60 | const factors = {
61 | textContent: analyzeTextContent(content),
62 | nodeName: analyzeNodeName(nodeName),
63 | position: nodeProperties ? analyzePosition(nodeProperties, context) : null,
64 | styling: nodeProperties ? analyzeStyling(nodeProperties) : null,
65 | patterns: detectDesignPatterns(content, nodeName, nodeProperties, context)
66 | };
67 |
68 | return weightedClassification(factors, context);
69 | }
70 |
71 | /**
72 | * Analyze text content for semantic clues
73 | */
74 | function analyzeTextContent(content: string): TextAnalysis {
75 | const lowerContent = content.toLowerCase().trim();
76 |
77 | // Check for action words
78 | const actionWords = [
79 | 'click', 'tap', 'press', 'submit', 'send', 'save', 'cancel', 'continue',
80 | 'next', 'back', 'close', 'start', 'begin', 'try', 'get', 'download',
81 | 'upload', 'buy', 'purchase', 'add', 'remove', 'edit', 'delete', 'create'
82 | ];
83 |
84 | const hasActionWords = actionWords.some(word => lowerContent.includes(word));
85 |
86 | // Check for imperative form (simplified detection)
87 | const hasImperativeForm = /^(let's|please|go|try|get|make|do|use|see|find|learn|discover)/.test(lowerContent) ||
88 | /^[a-z]+ (now|here|more|started)$/i.test(lowerContent);
89 |
90 | // Check for navigational keywords
91 | const navKeywords = ['home', 'back', 'next', 'previous', 'menu', 'settings', 'profile', 'about', 'contact', 'help'];
92 | const isNavigational = navKeywords.some(word => lowerContent.includes(word));
93 |
94 | // Check for status messages
95 | const statusKeywords = ['error', 'success', 'warning', 'complete', 'failed', 'loading', 'pending'];
96 | const isStatusMessage = statusKeywords.some(word => lowerContent.includes(word));
97 |
98 | // Determine structure
99 | let structure: TextAnalysis['structure'] = 'mixed';
100 | if (content.endsWith('?')) structure = 'question';
101 | else if (hasImperativeForm || hasActionWords) structure = 'imperative';
102 | else if (content.length > 50 && content.includes('.')) structure = 'declarative';
103 | else if (content.length < 30 && !content.includes('.')) structure = 'fragment';
104 |
105 | return {
106 | isActionable: hasActionWords || hasImperativeForm,
107 | isDescriptive: content.length > 50 && !hasActionWords,
108 | isNavigational,
109 | isStatusMessage,
110 | length: content.length,
111 | structure,
112 | hasActionWords,
113 | hasImperativeForm
114 | };
115 | }
116 |
117 | /**
118 | * Analyze node name for semantic clues
119 | */
120 | function analyzeNodeName(nodeName: string): {type: string; confidence: number} {
121 | const lowerName = nodeName.toLowerCase();
122 |
123 | // High confidence name patterns
124 | if (lowerName.includes('button') || lowerName.includes('btn')) {
125 | return {type: 'button', confidence: 0.9};
126 | }
127 | if (lowerName.includes('heading') || lowerName.includes('title') || /h[1-6]/.test(lowerName)) {
128 | return {type: 'heading', confidence: 0.9};
129 | }
130 | if (lowerName.includes('label') || lowerName.includes('field')) {
131 | return {type: 'label', confidence: 0.8};
132 | }
133 | if (lowerName.includes('link') || lowerName.includes('nav')) {
134 | return {type: 'link', confidence: 0.8};
135 | }
136 | if (lowerName.includes('caption') || lowerName.includes('subtitle')) {
137 | return {type: 'caption', confidence: 0.8};
138 | }
139 | if (lowerName.includes('error') || lowerName.includes('warning') || lowerName.includes('success')) {
140 | return {type: lowerName.includes('error') ? 'error' : lowerName.includes('warning') ? 'warning' : 'success', confidence: 0.9};
141 | }
142 |
143 | return {type: 'unknown', confidence: 0.0};
144 | }
145 |
146 | /**
147 | * Analyze position context
148 | */
149 | function analyzePosition(nodeProperties: any, context: SemanticContext): PositionContext {
150 | const bounds = nodeProperties.absoluteBoundingBox;
151 | const parentBounds = nodeProperties.parent?.absoluteBoundingBox;
152 |
153 | if (!bounds || !parentBounds) {
154 | return {
155 | isTopLevel: false,
156 | isBottomLevel: false,
157 | isIsolated: false,
158 | visualWeight: 1,
159 | relativeSize: 1,
160 | hasNearbyInteractive: false
161 | };
162 | }
163 |
164 | const relativeY = (bounds.y - parentBounds.y) / parentBounds.height;
165 | const relativeSize = (bounds.width * bounds.height) / (parentBounds.width * parentBounds.height);
166 |
167 | return {
168 | isTopLevel: relativeY < 0.2,
169 | isBottomLevel: relativeY > 0.8,
170 | isIsolated: calculateIsolation(bounds, nodeProperties.siblings || []),
171 | visualWeight: calculateVisualWeight(nodeProperties),
172 | relativeSize,
173 | hasNearbyInteractive: context.hasActionableNeighbors || false
174 | };
175 | }
176 |
177 | /**
178 | * Analyze styling patterns
179 | */
180 | function analyzeStyling(nodeProperties: any): {buttonLike: number; textLike: number; statusLike: number} {
181 | const styling = nodeProperties.styling || {};
182 |
183 | let buttonLike = 0;
184 | let textLike = 0;
185 | let statusLike = 0;
186 |
187 | // Button-like characteristics
188 | if (styling.fills?.length > 0) buttonLike += 0.3;
189 | if (styling.strokes?.length > 0) buttonLike += 0.2;
190 | if (styling.cornerRadius !== undefined && styling.cornerRadius > 4) buttonLike += 0.3;
191 | if (nodeProperties.layout?.padding) buttonLike += 0.2;
192 |
193 | // Text-like characteristics
194 | if (!styling.fills || styling.fills.length === 0) textLike += 0.3;
195 | if (!styling.strokes || styling.strokes.length === 0) textLike += 0.2;
196 | if (!styling.cornerRadius || styling.cornerRadius === 0) textLike += 0.3;
197 |
198 | // Status-like characteristics (color-based)
199 | if (styling.fills?.length > 0) {
200 | const primaryColor = styling.fills[0].hex?.toLowerCase();
201 | if (primaryColor?.includes('red') || primaryColor?.includes('ff')) statusLike += 0.4;
202 | if (primaryColor?.includes('green') || primaryColor?.includes('0f')) statusLike += 0.4;
203 | if (primaryColor?.includes('orange') || primaryColor?.includes('yellow')) statusLike += 0.4;
204 | }
205 |
206 | return {buttonLike, textLike, statusLike};
207 | }
208 |
209 | /**
210 | * Detect design patterns
211 | */
212 | function detectDesignPatterns(
213 | content: string,
214 | nodeName: string,
215 | nodeProperties: any,
216 | context: SemanticContext
217 | ): DesignPattern[] {
218 | const patterns: DesignPattern[] = [];
219 |
220 | // Button pattern
221 | const buttonPattern = detectButtonPattern(content, nodeName, nodeProperties, context);
222 | if (buttonPattern.confidence > 0.5) patterns.push(buttonPattern);
223 |
224 | // Heading pattern
225 | const headingPattern = detectHeadingPattern(content, nodeName, nodeProperties, context);
226 | if (headingPattern.confidence > 0.5) patterns.push(headingPattern);
227 |
228 | // Form label pattern
229 | const labelPattern = detectLabelPattern(content, nodeName, nodeProperties, context);
230 | if (labelPattern.confidence > 0.5) patterns.push(labelPattern);
231 |
232 | // Navigation pattern
233 | const navPattern = detectNavigationPattern(content, nodeName, nodeProperties, context);
234 | if (navPattern.confidence > 0.5) patterns.push(navPattern);
235 |
236 | return patterns;
237 | }
238 |
239 | /**
240 | * Detect button pattern
241 | */
242 | function detectButtonPattern(content: string, nodeName: string, nodeProperties: any, context: SemanticContext): DesignPattern {
243 | const indicators: string[] = [];
244 | let confidence = 0;
245 |
246 | // Content analysis
247 | const textAnalysis = analyzeTextContent(content);
248 | if (textAnalysis.isActionable) {
249 | confidence += 0.4;
250 | indicators.push('actionable text');
251 | }
252 |
253 | // Visual characteristics
254 | const styling = analyzeStyling(nodeProperties);
255 | if (styling.buttonLike > 0.6) {
256 | confidence += 0.3;
257 | indicators.push('button-like styling');
258 | }
259 |
260 | // Context analysis
261 | if (context.componentType === 'form' && textAnalysis.hasActionWords) {
262 | confidence += 0.2;
263 | indicators.push('form context with action words');
264 | }
265 |
266 | // Name analysis
267 | if (nodeName.toLowerCase().includes('button')) {
268 | confidence += 0.1;
269 | indicators.push('button in name');
270 | }
271 |
272 | return {
273 | name: 'button',
274 | confidence: Math.min(confidence, 1),
275 | indicators
276 | };
277 | }
278 |
279 | /**
280 | * Detect heading pattern
281 | */
282 | function detectHeadingPattern(content: string, nodeName: string, nodeProperties: any, context: SemanticContext): DesignPattern {
283 | const indicators: string[] = [];
284 | let confidence = 0;
285 |
286 | // Size and typography
287 | const fontSize = nodeProperties.text?.fontSize || 0;
288 | const fontWeight = nodeProperties.text?.fontWeight || 400;
289 |
290 | if (fontSize > 18) {
291 | confidence += 0.3;
292 | indicators.push('large font size');
293 | }
294 | if (fontWeight >= 600) {
295 | confidence += 0.2;
296 | indicators.push('bold font weight');
297 | }
298 |
299 | // Content structure
300 | if (content.length < 80 && !content.endsWith('.')) {
301 | confidence += 0.2;
302 | indicators.push('short non-sentence text');
303 | }
304 |
305 | // Position context
306 | const position = analyzePosition(nodeProperties, context);
307 | if (position.isTopLevel) {
308 | confidence += 0.2;
309 | indicators.push('top-level position');
310 | }
311 |
312 | // Context analysis
313 | if (context.screenPosition === 'header') {
314 | confidence += 0.1;
315 | indicators.push('header context');
316 | }
317 |
318 | return {
319 | name: 'heading',
320 | confidence: Math.min(confidence, 1),
321 | indicators
322 | };
323 | }
324 |
325 | /**
326 | * Detect label pattern
327 | */
328 | function detectLabelPattern(content: string, nodeName: string, nodeProperties: any, context: SemanticContext): DesignPattern {
329 | const indicators: string[] = [];
330 | let confidence = 0;
331 |
332 | // Content characteristics
333 | if (content.length < 40 && content.endsWith(':')) {
334 | confidence += 0.4;
335 | indicators.push('short text ending with colon');
336 | }
337 |
338 | // Context analysis
339 | if (context.componentType === 'form') {
340 | confidence += 0.3;
341 | indicators.push('form context');
342 | }
343 |
344 | // Name analysis
345 | if (nodeName.toLowerCase().includes('label')) {
346 | confidence += 0.3;
347 | indicators.push('label in name');
348 | }
349 |
350 | return {
351 | name: 'label',
352 | confidence: Math.min(confidence, 1),
353 | indicators
354 | };
355 | }
356 |
357 | /**
358 | * Detect navigation pattern
359 | */
360 | function detectNavigationPattern(content: string, nodeName: string, nodeProperties: any, context: SemanticContext): DesignPattern {
361 | const indicators: string[] = [];
362 | let confidence = 0;
363 |
364 | // Content analysis
365 | const textAnalysis = analyzeTextContent(content);
366 | if (textAnalysis.isNavigational) {
367 | confidence += 0.4;
368 | indicators.push('navigational content');
369 | }
370 |
371 | // Context analysis
372 | if (context.componentType === 'navigation' || context.screenPosition === 'header') {
373 | confidence += 0.3;
374 | indicators.push('navigation context');
375 | }
376 |
377 | // Layout analysis
378 | if (context.hasActionableNeighbors) {
379 | confidence += 0.2;
380 | indicators.push('near other actionable elements');
381 | }
382 |
383 | return {
384 | name: 'navigation',
385 | confidence: Math.min(confidence, 1),
386 | indicators
387 | };
388 | }
389 |
390 | /**
391 | * Weighted classification based on all factors
392 | */
393 | function weightedClassification(factors: any, context: SemanticContext): SemanticClassification {
394 | const candidates: Array<{type: string; confidence: number; reasoning: string[]}> = [];
395 |
396 | // Pattern-based classification (highest priority)
397 | if (factors.patterns) {
398 | factors.patterns.forEach((pattern: DesignPattern) => {
399 | if (pattern.confidence > 0.6) {
400 | candidates.push({
401 | type: pattern.name,
402 | confidence: pattern.confidence,
403 | reasoning: [`Strong ${pattern.name} pattern: ${pattern.indicators.join(', ')}`]
404 | });
405 | }
406 | });
407 | }
408 |
409 | // Text content analysis
410 | if (factors.textContent) {
411 | const textAnalysis = factors.textContent as TextAnalysis;
412 |
413 | if (textAnalysis.isActionable && textAnalysis.length < 50) {
414 | candidates.push({
415 | type: 'button',
416 | confidence: 0.7,
417 | reasoning: ['Actionable short text content']
418 | });
419 | }
420 |
421 | if (textAnalysis.isNavigational) {
422 | candidates.push({
423 | type: 'link',
424 | confidence: 0.6,
425 | reasoning: ['Navigational text content']
426 | });
427 | }
428 |
429 | if (textAnalysis.isStatusMessage) {
430 | const statusType = determineStatusType(factors.textContent, factors.styling);
431 | candidates.push({
432 | type: statusType,
433 | confidence: 0.8,
434 | reasoning: ['Status message detected']
435 | });
436 | }
437 |
438 | if (textAnalysis.length > 80 || textAnalysis.structure === 'declarative') {
439 | candidates.push({
440 | type: 'body',
441 | confidence: 0.6,
442 | reasoning: ['Long descriptive text']
443 | });
444 | }
445 | }
446 |
447 | // Name-based classification
448 | if (factors.nodeName && factors.nodeName.confidence > 0.7) {
449 | candidates.push({
450 | type: factors.nodeName.type,
451 | confidence: factors.nodeName.confidence,
452 | reasoning: ['Node name indicates type']
453 | });
454 | }
455 |
456 | // Apply confidence threshold - only return high confidence classifications
457 | const highConfidenceCandidates = candidates.filter(c => c.confidence >= 0.6);
458 |
459 | if (highConfidenceCandidates.length === 0) {
460 | return {
461 | type: 'other',
462 | confidence: 0.0,
463 | alternatives: candidates.map(c => ({type: c.type, confidence: c.confidence})),
464 | reasoning: ['Low confidence in all classifications - deferring to AI interpretation']
465 | };
466 | }
467 |
468 | // Sort by confidence and return the best match
469 | highConfidenceCandidates.sort((a, b) => b.confidence - a.confidence);
470 | const bestMatch = highConfidenceCandidates[0];
471 |
472 | return {
473 | type: bestMatch.type as any,
474 | confidence: bestMatch.confidence,
475 | alternatives: highConfidenceCandidates.slice(1).map(c => ({type: c.type, confidence: c.confidence})),
476 | reasoning: bestMatch.reasoning
477 | };
478 | }
479 |
480 | /**
481 | * Helper functions
482 | */
483 | function calculateIsolation(bounds: any, siblings: any[]): boolean {
484 | if (!siblings || siblings.length === 0) return true;
485 |
486 | const minDistance = 50; // pixels
487 | return !siblings.some((sibling: any) => {
488 | if (!sibling.absoluteBoundingBox) return false;
489 |
490 | const distance = Math.sqrt(
491 | Math.pow(bounds.x - sibling.absoluteBoundingBox.x, 2) +
492 | Math.pow(bounds.y - sibling.absoluteBoundingBox.y, 2)
493 | );
494 |
495 | return distance < minDistance;
496 | });
497 | }
498 |
499 | function calculateVisualWeight(nodeProperties: any): number {
500 | let weight = 1;
501 |
502 | // Size contribution
503 | const area = (nodeProperties.absoluteBoundingBox?.width || 0) * (nodeProperties.absoluteBoundingBox?.height || 0);
504 | if (area > 10000) weight += 2;
505 | else if (area > 5000) weight += 1;
506 |
507 | // Styling contribution
508 | if (nodeProperties.styling?.fills?.length > 0) weight += 1;
509 | if (nodeProperties.styling?.strokes?.length > 0) weight += 0.5;
510 | if (nodeProperties.text?.fontWeight >= 600) weight += 1;
511 |
512 | return weight;
513 | }
514 |
515 | function determineStatusType(textAnalysis: TextAnalysis, styling: any): string {
516 | const content = textAnalysis;
517 | // This is a simplified version - in practice you'd analyze the content more thoroughly
518 | if (styling?.statusLike > 0.5) {
519 | // Could analyze color to determine if error, warning, or success
520 | return 'error'; // simplified
521 | }
522 | return 'other';
523 | }
524 |
525 | /**
526 | * Enhanced section type detection with confidence scoring
527 | */
528 | export function detectSectionTypeAdvanced(
529 | node: any,
530 | parent?: any,
531 | siblings?: any[]
532 | ): {type: 'header' | 'navigation' | 'content' | 'footer' | 'sidebar' | 'modal' | 'other'; confidence: number; reasoning: string[]} {
533 | const factors = {
534 | nodeName: analyzeNodeName(node.name),
535 | position: parent ? analyzePosition(node, generateSemanticContext(node, parent, siblings)) : null,
536 | styling: analyzeStyling(node),
537 | siblings: siblings ? analyzeSiblingContext(siblings) : null
538 | };
539 |
540 | return classifySection(factors);
541 | }
542 |
543 | /**
544 | * Classify section based on multiple factors
545 | */
546 | function classifySection(factors: any): {type: 'header' | 'navigation' | 'content' | 'footer' | 'sidebar' | 'modal' | 'other'; confidence: number; reasoning: string[]} {
547 | const candidates: Array<{type: 'header' | 'navigation' | 'content' | 'footer' | 'sidebar' | 'modal' | 'other'; confidence: number; reasoning: string[]}> = [];
548 |
549 | // Name-based classification
550 | if (factors.nodeName.confidence > 0.7) {
551 | const nameType = factors.nodeName.type;
552 | const sectionType = (['header', 'navigation', 'content', 'footer', 'sidebar', 'modal'].includes(nameType))
553 | ? nameType as 'header' | 'navigation' | 'content' | 'footer' | 'sidebar' | 'modal'
554 | : 'other' as const;
555 |
556 | candidates.push({
557 | type: sectionType,
558 | confidence: factors.nodeName.confidence,
559 | reasoning: ['Node name indicates section type']
560 | });
561 | }
562 |
563 | // Position-based classification
564 | if (factors.position) {
565 | if (factors.position.isTopLevel) {
566 | candidates.push({
567 | type: 'header',
568 | confidence: 0.7,
569 | reasoning: ['Positioned in top area of screen']
570 | });
571 | } else if (factors.position.isBottomLevel) {
572 | candidates.push({
573 | type: 'footer',
574 | confidence: 0.7,
575 | reasoning: ['Positioned in bottom area of screen']
576 | });
577 | }
578 | }
579 |
580 | // Styling-based classification
581 | if (factors.styling.buttonLike > 0.6 && factors.siblings?.hasMultipleButtons) {
582 | candidates.push({
583 | type: 'navigation',
584 | confidence: 0.6,
585 | reasoning: ['Multiple button-like elements suggest navigation']
586 | });
587 | }
588 |
589 | // Apply confidence threshold
590 | const highConfidenceCandidates = candidates.filter(c => c.confidence >= 0.6);
591 |
592 | if (highConfidenceCandidates.length === 0) {
593 | return {
594 | type: 'content',
595 | confidence: 0.5,
596 | reasoning: ['Default classification - insufficient confidence in alternatives']
597 | };
598 | }
599 |
600 | // Return best match
601 | highConfidenceCandidates.sort((a, b) => b.confidence - a.confidence);
602 | const bestMatch = highConfidenceCandidates[0];
603 |
604 | return {
605 | type: bestMatch.type,
606 | confidence: bestMatch.confidence,
607 | reasoning: bestMatch.reasoning
608 | };
609 | }
610 |
611 | /**
612 | * Analyze sibling context for section detection
613 | */
614 | function analyzeSiblingContext(siblings: any[]): {hasMultipleButtons: boolean; hasNavigation: boolean} {
615 | const buttonLikeSiblings = siblings.filter(sibling => {
616 | const name = sibling.name?.toLowerCase() || '';
617 | return name.includes('button') || name.includes('btn') || name.includes('tab');
618 | });
619 |
620 | const navigationSiblings = siblings.filter(sibling => {
621 | const name = sibling.name?.toLowerCase() || '';
622 | return name.includes('nav') || name.includes('menu') || name.includes('link');
623 | });
624 |
625 | return {
626 | hasMultipleButtons: buttonLikeSiblings.length >= 2,
627 | hasNavigation: navigationSiblings.length > 0
628 | };
629 | }
630 |
631 | /**
632 | * Generate semantic context from component hierarchy
633 | */
634 | export function generateSemanticContext(
635 | node: any,
636 | parent?: any,
637 | siblings?: any[]
638 | ): SemanticContext {
639 | const parentName = parent?.name?.toLowerCase() || '';
640 | const siblingTypes = siblings?.map(s => s.type) || [];
641 |
642 | // Determine screen position
643 | let screenPosition: SemanticContext['screenPosition'] = 'unknown';
644 | if (parent?.absoluteBoundingBox) {
645 | const relativeY = (node.absoluteBoundingBox?.y || 0) - parent.absoluteBoundingBox.y;
646 | const parentHeight = parent.absoluteBoundingBox.height;
647 |
648 | if (relativeY < parentHeight * 0.15) screenPosition = 'header';
649 | else if (relativeY > parentHeight * 0.85) screenPosition = 'footer';
650 | else screenPosition = 'content';
651 | }
652 |
653 | // Determine component type
654 | let componentType: SemanticContext['componentType'] = 'unknown';
655 | if (parentName.includes('form') || parentName.includes('input')) componentType = 'form';
656 | else if (parentName.includes('nav') || parentName.includes('menu')) componentType = 'navigation';
657 | else if (parentName.includes('card') || parentName.includes('item')) componentType = 'card';
658 | else if (parentName.includes('button') && siblings?.length) componentType = 'button-group';
659 | else componentType = 'content';
660 |
661 | // Detect actionable neighbors
662 | const hasActionableNeighbors = siblings?.some(sibling => {
663 | const name = sibling.name?.toLowerCase() || '';
664 | return name.includes('button') || name.includes('link') || name.includes('nav');
665 | }) || false;
666 |
667 | return {
668 | parentType: parent?.type,
669 | siblingTypes,
670 | screenPosition,
671 | componentType,
672 | layoutDirection: parent?.layoutMode === 'HORIZONTAL' ? 'horizontal' : 'vertical',
673 | hasActionableNeighbors
674 | };
675 | }
676 |
```
--------------------------------------------------------------------------------
/src/extractors/screens/extractor.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/extractors/screens/extractor.mts
2 |
3 | import type {FigmaNode} from '../../types/figma.js';
4 | import type {
5 | ScreenAnalysis,
6 | ScreenMetadata,
7 | ScreenLayoutInfo,
8 | ScreenSection,
9 | NavigationInfo,
10 | NavigationElement,
11 | ScreenAssetInfo,
12 | SkippedNodeInfo,
13 | ScreenExtractionOptions
14 | } from './types.js';
15 | import type {ComponentChild, NestedComponentInfo} from '../components/types.js';
16 | import {
17 | extractLayoutInfo,
18 | extractStylingInfo,
19 | createComponentChild,
20 | createNestedComponentInfo,
21 | calculateVisualImportance,
22 | isComponentNode
23 | } from '../components/extractor.js';
24 | import { detectSectionTypeAdvanced } from '../../tools/flutter/semantic-detection.js';
25 |
26 | /**
27 | * Extract screen metadata
28 | */
29 | export function extractScreenMetadata(node: FigmaNode): ScreenMetadata {
30 | const dimensions = {
31 | width: node.absoluteBoundingBox?.width || 0,
32 | height: node.absoluteBoundingBox?.height || 0
33 | };
34 |
35 | const deviceType = detectDeviceType(dimensions);
36 | const orientation = detectOrientation(dimensions);
37 |
38 | return {
39 | name: node.name,
40 | type: node.type as 'FRAME' | 'PAGE' | 'COMPONENT',
41 | nodeId: node.id,
42 | deviceType,
43 | orientation,
44 | dimensions
45 | };
46 | }
47 |
48 | /**
49 | * Extract screen layout information
50 | */
51 | export function extractScreenLayoutInfo(node: FigmaNode): ScreenLayoutInfo {
52 | const baseLayout = extractLayoutInfo(node);
53 |
54 | return {
55 | ...baseLayout,
56 | scrollable: detectScrollable(node),
57 | hasHeader: detectHeader(node),
58 | hasFooter: detectFooter(node),
59 | hasNavigation: detectNavigation(node),
60 | contentArea: calculateContentArea(node)
61 | };
62 | }
63 |
64 | /**
65 | * Analyze screen sections (header, content, footer, etc.)
66 | */
67 | export function analyzeScreenSections(
68 | node: FigmaNode,
69 | options: Required<ScreenExtractionOptions>
70 | ): {
71 | sections: ScreenSection[];
72 | components: NestedComponentInfo[];
73 | skippedNodes: SkippedNodeInfo[];
74 | } {
75 | const sections: ScreenSection[] = [];
76 | const components: NestedComponentInfo[] = [];
77 | const skippedNodes: SkippedNodeInfo[] = [];
78 |
79 | if (!node.children || node.children.length === 0) {
80 | return {sections, components, skippedNodes};
81 | }
82 |
83 | // Filter visible nodes unless includeHiddenNodes is true
84 | let visibleChildren = node.children;
85 | if (!options.includeHiddenNodes) {
86 | visibleChildren = node.children.filter(child => child.visible !== false);
87 | }
88 |
89 | // Filter out device UI elements (status bars, notches, home indicators, etc.)
90 | const filteredDeviceUI = visibleChildren.filter(child => isDeviceUIElement(child, node));
91 | visibleChildren = visibleChildren.filter(child => !isDeviceUIElement(child, node));
92 |
93 | // Add filtered device UI elements to skipped nodes for reporting
94 | filteredDeviceUI.forEach(deviceUINode => {
95 | skippedNodes.push({
96 | nodeId: deviceUINode.id,
97 | name: deviceUINode.name,
98 | type: deviceUINode.type,
99 | reason: 'device_ui_element'
100 | });
101 | });
102 |
103 | // Analyze each child as potential section
104 | const sectionsWithImportance = visibleChildren.map(child => {
105 | const siblings = visibleChildren.filter(sibling => sibling.id !== child.id);
106 | return {
107 | node: child,
108 | importance: calculateSectionImportance(child),
109 | sectionType: detectSectionType(child, node, siblings)
110 | };
111 | });
112 |
113 | // Sort by importance
114 | sectionsWithImportance.sort((a, b) => b.importance - a.importance);
115 |
116 | // Process up to maxSections
117 | const processedCount = Math.min(sectionsWithImportance.length, options.maxSections);
118 |
119 | for (let i = 0; i < sectionsWithImportance.length; i++) {
120 | const {node: child, importance, sectionType} = sectionsWithImportance[i];
121 |
122 | if (i < processedCount) {
123 | const section = createScreenSection(child, sectionType, importance, options);
124 | sections.push(section);
125 |
126 | // Collect nested components from this section
127 | section.components.forEach(comp => {
128 | if (!components.find(c => c.nodeId === comp.nodeId)) {
129 | components.push(comp);
130 | }
131 | });
132 | } else {
133 | skippedNodes.push({
134 | nodeId: child.id,
135 | name: child.name,
136 | type: child.type,
137 | reason: 'max_sections'
138 | });
139 | }
140 | }
141 |
142 | return {sections, components, skippedNodes};
143 | }
144 |
145 | /**
146 | * Extract navigation information
147 | */
148 | export function extractNavigationInfo(node: FigmaNode): NavigationInfo {
149 | const navigationElements: NavigationElement[] = [];
150 |
151 | // Traverse to find navigation elements
152 | traverseForNavigation(node, navigationElements);
153 |
154 | return {
155 | hasTabBar: detectTabBar(node),
156 | hasAppBar: detectAppBar(node),
157 | hasDrawer: detectDrawer(node),
158 | hasBottomSheet: detectBottomSheet(node),
159 | navigationElements
160 | };
161 | }
162 |
163 | /**
164 | * Extract screen assets information
165 | */
166 | export function extractScreenAssets(node: FigmaNode): ScreenAssetInfo[] {
167 | const assets: ScreenAssetInfo[] = [];
168 |
169 | traverseForAssets(node, assets);
170 |
171 | return assets;
172 | }
173 |
174 | /**
175 | * Create screen section
176 | */
177 | function createScreenSection(
178 | node: FigmaNode,
179 | sectionType: ScreenSection['type'],
180 | importance: number,
181 | options: Required<ScreenExtractionOptions>
182 | ): ScreenSection {
183 | const children: ComponentChild[] = [];
184 | const components: NestedComponentInfo[] = [];
185 |
186 | // Analyze section children
187 | if (node.children) {
188 | const visibleChildren = options.includeHiddenNodes
189 | ? node.children
190 | : node.children.filter(child => child.visible !== false);
191 |
192 | visibleChildren.forEach(child => {
193 | const childImportance = calculateVisualImportance(child);
194 | const isComponent = isComponentNode(child);
195 |
196 | if (isComponent) {
197 | components.push(createNestedComponentInfo(child));
198 | }
199 |
200 | // Pass parent and siblings for better semantic detection
201 | const siblings = visibleChildren.filter(sibling => sibling.id !== child.id);
202 | children.push(createComponentChild(child, childImportance, isComponent, {
203 | maxChildNodes: 20, // Higher limit for screens
204 | maxDepth: options.maxDepth,
205 | includeHiddenNodes: options.includeHiddenNodes,
206 | prioritizeComponents: true,
207 | extractTextContent: true
208 | }, node, siblings));
209 | });
210 | }
211 |
212 | return {
213 | id: `section_${node.id}`,
214 | name: node.name,
215 | type: sectionType,
216 | nodeId: node.id,
217 | layout: extractLayoutInfo(node),
218 | styling: extractStylingInfo(node),
219 | children,
220 | components,
221 | importance
222 | };
223 | }
224 |
225 | /**
226 | * Calculate section importance for prioritization
227 | */
228 | function calculateSectionImportance(node: FigmaNode): number {
229 | let score = 0;
230 |
231 | // Size importance (0-3 points)
232 | const area = (node.absoluteBoundingBox?.width || 0) * (node.absoluteBoundingBox?.height || 0);
233 | if (area > 50000) score += 3;
234 | else if (area > 20000) score += 2;
235 | else if (area > 5000) score += 1;
236 |
237 | // Position importance (0-2 points) - top elements are more important
238 | const y = node.absoluteBoundingBox?.y || 0;
239 | if (y < 100) score += 2; // Header area
240 | else if (y < 200) score += 1;
241 |
242 | // Type importance (0-3 points)
243 | if (node.type === 'COMPONENT' || node.type === 'INSTANCE') score += 3;
244 | else if (node.type === 'FRAME') score += 2;
245 |
246 | // Name-based importance (0-2 points)
247 | const name = node.name.toLowerCase();
248 | if (name.includes('header') || name.includes('nav') || name.includes('footer')) score += 2;
249 | else if (name.includes('content') || name.includes('main') || name.includes('body')) score += 1;
250 |
251 | return Math.min(score, 10);
252 | }
253 |
254 | /**
255 | * Detect section type based on node properties
256 | * Enhanced with multi-factor analysis and confidence scoring
257 | */
258 | function detectSectionType(node: FigmaNode, parent?: FigmaNode, siblings?: FigmaNode[]): ScreenSection['type'] {
259 | // Try advanced detection first
260 | try {
261 | const classification = detectSectionTypeAdvanced(node, parent, siblings);
262 |
263 | // Use advanced classification if confidence is high enough
264 | if (classification.confidence >= 0.6) {
265 | return classification.type as ScreenSection['type'];
266 | }
267 |
268 | // Log reasoning for debugging (in development)
269 | if (process.env.NODE_ENV === 'development') {
270 | console.debug(`Low confidence (${classification.confidence}) for section "${node.name}": ${classification.reasoning.join(', ')}`);
271 | }
272 | } catch (error) {
273 | // Fall back to legacy detection if advanced detection fails
274 | console.warn('Advanced section detection failed, using legacy method:', error);
275 | }
276 |
277 | // Legacy detection as fallback
278 | return detectSectionTypeLegacy(node);
279 | }
280 |
281 | /**
282 | * Legacy section type detection (fallback)
283 | */
284 | function detectSectionTypeLegacy(node: FigmaNode): ScreenSection['type'] {
285 | const name = node.name.toLowerCase();
286 | const bounds = node.absoluteBoundingBox;
287 |
288 | // Name-based detection
289 | if (name.includes('header') || name.includes('app bar') || name.includes('top bar')) return 'header';
290 | if (name.includes('footer') || name.includes('bottom') || name.includes('tab bar')) return 'footer';
291 | if (name.includes('nav') || name.includes('menu') || name.includes('sidebar')) return 'navigation';
292 | if (name.includes('modal') || name.includes('dialog') || name.includes('popup')) return 'modal';
293 | if (name.includes('content') || name.includes('main') || name.includes('body')) return 'content';
294 |
295 | // Position-based detection
296 | if (bounds) {
297 | const screenHeight = bounds.y + bounds.height;
298 |
299 | // Top 15% of screen likely header
300 | if (bounds.y < screenHeight * 0.15) return 'header';
301 |
302 | // Bottom 15% of screen likely footer/navigation
303 | if (bounds.y > screenHeight * 0.85) return 'footer';
304 |
305 | // Side areas might be navigation
306 | if (bounds.width < 100 && bounds.height > 200) return 'sidebar';
307 | }
308 |
309 | return 'content';
310 | }
311 |
312 | /**
313 | * Detect device type based on dimensions
314 | */
315 | function detectDeviceType(dimensions: {width: number; height: number}): ScreenMetadata['deviceType'] {
316 | const {width, height} = dimensions;
317 | const maxDimension = Math.max(width, height);
318 | const minDimension = Math.min(width, height);
319 |
320 | // Mobile devices (typical ranges)
321 | if (maxDimension <= 900 && minDimension <= 500) return 'mobile';
322 |
323 | // Tablet devices
324 | if (maxDimension <= 1400 && minDimension <= 1000) return 'tablet';
325 |
326 | // Desktop
327 | if (maxDimension > 1400) return 'desktop';
328 |
329 | return 'unknown';
330 | }
331 |
332 | /**
333 | * Detect orientation
334 | */
335 | function detectOrientation(dimensions: {width: number; height: number}): ScreenMetadata['orientation'] {
336 | return dimensions.width > dimensions.height ? 'landscape' : 'portrait';
337 | }
338 |
339 | /**
340 | * Detect if screen is scrollable
341 | */
342 | function detectScrollable(node: FigmaNode): boolean {
343 | // Check for scroll properties or overflow
344 | const nodeAny = node as any;
345 | return !!(nodeAny.overflowDirection || nodeAny.scrollBehavior);
346 | }
347 |
348 | /**
349 | * Detect header presence
350 | */
351 | function detectHeader(node: FigmaNode): boolean {
352 | if (!node.children) return false;
353 |
354 | return node.children.some(child => {
355 | const name = child.name.toLowerCase();
356 | const bounds = child.absoluteBoundingBox;
357 |
358 | return (name.includes('header') || name.includes('app bar') || name.includes('top bar')) ||
359 | (bounds && bounds.y < 150); // Top area
360 | });
361 | }
362 |
363 | /**
364 | * Detect footer presence
365 | */
366 | function detectFooter(node: FigmaNode): boolean {
367 | if (!node.children) return false;
368 |
369 | const screenHeight = node.absoluteBoundingBox?.height || 0;
370 |
371 | return node.children.some(child => {
372 | const name = child.name.toLowerCase();
373 | const bounds = child.absoluteBoundingBox;
374 |
375 | return (name.includes('footer') || name.includes('bottom') || name.includes('tab bar')) ||
376 | (bounds && bounds.y > screenHeight * 0.8); // Bottom area
377 | });
378 | }
379 |
380 | /**
381 | * Detect navigation presence
382 | */
383 | function detectNavigation(node: FigmaNode): boolean {
384 | if (!node.children) return false;
385 |
386 | return node.children.some(child => {
387 | const name = child.name.toLowerCase();
388 | return name.includes('nav') || name.includes('menu') || name.includes('tab') || name.includes('drawer');
389 | });
390 | }
391 |
392 | /**
393 | * Calculate content area
394 | */
395 | function calculateContentArea(node: FigmaNode): ScreenLayoutInfo['contentArea'] {
396 | const bounds = node.absoluteBoundingBox;
397 | if (!bounds) return undefined;
398 |
399 | // Simple heuristic: assume content is the main area minus header/footer
400 | return {
401 | x: bounds.x,
402 | y: bounds.y + 100, // Assume 100px header
403 | width: bounds.width,
404 | height: bounds.height - 200 // Assume 100px header + 100px footer
405 | };
406 | }
407 |
408 | /**
409 | * Detect tab bar
410 | */
411 | function detectTabBar(node: FigmaNode): boolean {
412 | return traverseAndCheck(node, child => {
413 | const name = child.name.toLowerCase();
414 | return !!(name.includes('tab bar') || name.includes('bottom nav') ||
415 | (name.includes('tab') && child.children && child.children.length > 1));
416 | });
417 | }
418 |
419 | /**
420 | * Detect app bar
421 | */
422 | function detectAppBar(node: FigmaNode): boolean {
423 | return traverseAndCheck(node, child => {
424 | const name = child.name.toLowerCase();
425 | return !!(name.includes('app bar') || name.includes('header') || name.includes('top bar'));
426 | });
427 | }
428 |
429 | /**
430 | * Detect drawer
431 | */
432 | function detectDrawer(node: FigmaNode): boolean {
433 | return traverseAndCheck(node, child => {
434 | const name = child.name.toLowerCase();
435 | return !!(name.includes('drawer') || name.includes('sidebar') || name.includes('menu'));
436 | });
437 | }
438 |
439 | /**
440 | * Detect bottom sheet
441 | */
442 | function detectBottomSheet(node: FigmaNode): boolean {
443 | return traverseAndCheck(node, child => {
444 | const name = child.name.toLowerCase();
445 | return !!(name.includes('bottom sheet') || name.includes('modal') || name.includes('popup'));
446 | });
447 | }
448 |
449 | /**
450 | * Traverse and check condition
451 | */
452 | function traverseAndCheck(node: FigmaNode, condition: (node: FigmaNode) => boolean): boolean {
453 | if (condition(node)) return true;
454 |
455 | if (node.children) {
456 | return node.children.some(child => traverseAndCheck(child, condition));
457 | }
458 |
459 | return false;
460 | }
461 |
462 | /**
463 | * Traverse for navigation elements
464 | */
465 | function traverseForNavigation(node: FigmaNode, results: NavigationElement[], depth: number = 0): void {
466 | if (depth > 3) return;
467 |
468 | const name = node.name.toLowerCase();
469 |
470 | // Check if this node is a navigation element
471 | if (isNavigationElement(node)) {
472 | results.push({
473 | nodeId: node.id,
474 | name: node.name,
475 | type: detectNavigationElementType(node),
476 | text: extractNavigationText(node),
477 | icon: hasIcon(node),
478 | isActive: detectActiveState(node)
479 | });
480 | }
481 |
482 | // Traverse children
483 | if (node.children) {
484 | node.children.forEach(child => {
485 | traverseForNavigation(child, results, depth + 1);
486 | });
487 | }
488 | }
489 |
490 | /**
491 | * Check if node is navigation element
492 | */
493 | function isNavigationElement(node: FigmaNode): boolean {
494 | const name = node.name.toLowerCase();
495 |
496 | return name.includes('tab') || name.includes('button') || name.includes('link') ||
497 | name.includes('menu') || name.includes('nav') ||
498 | (node.type === 'INSTANCE' && name.includes('item'));
499 | }
500 |
501 | /**
502 | * Detect navigation element type
503 | */
504 | function detectNavigationElementType(node: FigmaNode): NavigationElement['type'] {
505 | const name = node.name.toLowerCase();
506 |
507 | if (name.includes('tab')) return 'tab';
508 | if (name.includes('button')) return 'button';
509 | if (name.includes('link')) return 'link';
510 | if (name.includes('icon')) return 'icon';
511 | if (name.includes('menu')) return 'menu';
512 |
513 | return 'other';
514 | }
515 |
516 | /**
517 | * Extract navigation text
518 | */
519 | function extractNavigationText(node: FigmaNode): string | undefined {
520 | // Look for text children
521 | if (node.children) {
522 | for (const child of node.children) {
523 | if (child.type === 'TEXT' && child.name) {
524 | return child.name;
525 | }
526 | }
527 | }
528 |
529 | // Fallback to node name if it looks like text
530 | const name = node.name;
531 | if (name && !name.toLowerCase().includes('component') && !name.toLowerCase().includes('instance')) {
532 | return name;
533 | }
534 |
535 | return undefined;
536 | }
537 |
538 | /**
539 | * Check if node has icon
540 | */
541 | function hasIcon(node: FigmaNode): boolean {
542 | if (!node.children) return false;
543 |
544 | return node.children.some(child => {
545 | const name = child.name.toLowerCase();
546 | return name.includes('icon') || child.type === 'VECTOR';
547 | });
548 | }
549 |
550 | /**
551 | * Detect active state
552 | */
553 | function detectActiveState(node: FigmaNode): boolean {
554 | const name = node.name.toLowerCase();
555 | return name.includes('active') || name.includes('selected') || name.includes('current');
556 | }
557 |
558 | /**
559 | * Check if a node represents device UI elements that should be ignored
560 | */
561 | function isDeviceUIElement(child: FigmaNode, parent: FigmaNode): boolean {
562 | const name = child.name.toLowerCase();
563 | const bounds = child.absoluteBoundingBox;
564 | const parentBounds = parent.absoluteBoundingBox;
565 |
566 | if (!bounds || !parentBounds) return false;
567 |
568 | // Name-based detection for common device UI elements
569 | const deviceUIKeywords = [
570 | 'status bar', 'statusbar', 'status_bar',
571 | 'battery', 'signal', 'wifi', 'cellular', 'carrier',
572 | 'notch', 'dynamic island', 'safe area',
573 | 'home indicator', 'home_indicator', 'home bar',
574 | 'navigation bar', 'system bar', 'system_bar',
575 | 'chin', 'bezel', 'nav bar',
576 | 'clock', 'time indicator',
577 | 'signal strength', 'battery indicator',
578 | 'screen recording', 'screen_recording'
579 | ];
580 |
581 | // Check if name contains device UI keywords
582 | if (deviceUIKeywords.some(keyword => name.includes(keyword))) {
583 | return true;
584 | }
585 |
586 | // Position and size-based detection
587 | const screenWidth = parentBounds.width;
588 | const screenHeight = parentBounds.height;
589 | const elementWidth = bounds.width;
590 | const elementHeight = bounds.height;
591 | const elementY = bounds.y - parentBounds.y; // Relative position
592 | const elementX = bounds.x - parentBounds.x;
593 |
594 | // Status bar detection (top of screen)
595 | if (elementY <= 50 && // Very close to top
596 | elementWidth >= screenWidth * 0.8 && // Nearly full width
597 | elementHeight <= 50) { // Thin height
598 |
599 | // Additional checks for status bar content
600 | if (hasStatusBarContent(child)) {
601 | return true;
602 | }
603 | }
604 |
605 | // Home indicator detection (bottom of screen)
606 | if (elementY >= screenHeight - 50 && // Very close to bottom
607 | elementWidth <= screenWidth * 0.4 && // Narrow width (typical home indicator)
608 | elementHeight <= 20 && // Very thin
609 | elementX >= screenWidth * 0.3 && // Centered horizontally
610 | elementX <= screenWidth * 0.7) {
611 | return true;
612 | }
613 |
614 | // Notch/Dynamic Island detection (top center, small area)
615 | if (elementY <= 30 && // Very top
616 | elementWidth <= screenWidth * 0.3 && // Small width
617 | elementHeight <= 30 && // Small height
618 | elementX >= screenWidth * 0.35 && // Centered area
619 | elementX <= screenWidth * 0.65) {
620 | return true;
621 | }
622 |
623 | // Side bezels or navigation areas
624 | if ((elementX <= 20 || elementX >= screenWidth - 20) && // At edges
625 | elementWidth <= 40 && // Thin
626 | elementHeight >= screenHeight * 0.3) { // Tall
627 | return true;
628 | }
629 |
630 | // Small elements in corners (likely UI indicators)
631 | const isInCorner = (elementX <= 50 || elementX >= screenWidth - 50) &&
632 | (elementY <= 50 || elementY >= screenHeight - 50);
633 | if (isInCorner && elementWidth <= 80 && elementHeight <= 80) {
634 | return true;
635 | }
636 |
637 | return false;
638 | }
639 |
640 | /**
641 | * Check if a node contains typical status bar content
642 | */
643 | function hasStatusBarContent(node: FigmaNode): boolean {
644 | if (!node.children) return false;
645 |
646 | const statusBarElements = node.children.some(child => {
647 | const name = child.name.toLowerCase();
648 | return name.includes('battery') ||
649 | name.includes('signal') ||
650 | name.includes('wifi') ||
651 | name.includes('time') ||
652 | name.includes('clock') ||
653 | name.includes('carrier') ||
654 | name.includes('cellular') ||
655 | (child.type === 'TEXT' && /^\d{1,2}:\d{2}/.test(child.name)); // Time format
656 | });
657 |
658 | return statusBarElements;
659 | }
660 |
661 | /**
662 | * Traverse for assets
663 | */
664 | function traverseForAssets(node: FigmaNode, results: ScreenAssetInfo[], depth: number = 0): void {
665 | if (depth > 4) return;
666 |
667 | // Check if this node is an asset
668 | if (isAssetNode(node)) {
669 | results.push({
670 | nodeId: node.id,
671 | name: node.name,
672 | type: detectAssetType(node),
673 | size: detectAssetSize(node),
674 | usage: detectAssetUsage(node)
675 | });
676 | }
677 |
678 | // Traverse children
679 | if (node.children) {
680 | node.children.forEach(child => {
681 | traverseForAssets(child, results, depth + 1);
682 | });
683 | }
684 | }
685 |
686 | /**
687 | * Check if node is an asset
688 | */
689 | function isAssetNode(node: FigmaNode): boolean {
690 | // Check for image fills
691 | if (node.fills && node.fills.some((fill: any) => fill.type === 'IMAGE')) return true;
692 |
693 | // Check for vectors that are likely assets
694 | if (node.type === 'VECTOR') {
695 | const name = node.name.toLowerCase();
696 | return name.includes('image') || name.includes('illustration') ||
697 | name.includes('icon') || name.includes('logo');
698 | }
699 |
700 | return false;
701 | }
702 |
703 | /**
704 | * Detect asset type
705 | */
706 | function detectAssetType(node: FigmaNode): ScreenAssetInfo['type'] {
707 | const name = node.name.toLowerCase();
708 |
709 | if (name.includes('icon')) return 'icon';
710 | if (name.includes('illustration') || name.includes('graphic')) return 'illustration';
711 | if (name.includes('background') || name.includes('bg')) return 'background';
712 |
713 | return 'image';
714 | }
715 |
716 | /**
717 | * Detect asset size
718 | */
719 | function detectAssetSize(node: FigmaNode): ScreenAssetInfo['size'] {
720 | const bounds = node.absoluteBoundingBox;
721 | if (!bounds) return 'medium';
722 |
723 | const area = bounds.width * bounds.height;
724 |
725 | if (area < 2500) return 'small'; // < 50x50
726 | if (area > 40000) return 'large'; // > 200x200
727 |
728 | return 'medium';
729 | }
730 |
731 | /**
732 | * Detect asset usage
733 | */
734 | function detectAssetUsage(node: FigmaNode): ScreenAssetInfo['usage'] {
735 | const name = node.name.toLowerCase();
736 |
737 | if (name.includes('logo') || name.includes('brand')) return 'branding';
738 | if (name.includes('nav') || name.includes('menu') || name.includes('tab')) return 'navigation';
739 | if (name.includes('background') || name.includes('decoration')) return 'decorative';
740 |
741 | return 'content';
742 | }
743 |
```
--------------------------------------------------------------------------------
/src/extractors/components/extractor.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/extractors/components/extractor.mts
2 |
3 | import type {FigmaNode, FigmaColor, FigmaEffect} from '../../types/figma.js';
4 | import type {
5 | ComponentMetadata,
6 | LayoutInfo,
7 | StylingInfo,
8 | ComponentChild,
9 | NestedComponentInfo,
10 | SkippedNodeInfo,
11 | CategorizedEffects,
12 | ColorInfo,
13 | StrokeInfo,
14 | CornerRadii,
15 | PaddingInfo,
16 | TextInfo,
17 | ComponentExtractionOptions
18 | } from './types.js';
19 | import { detectSemanticTypeAdvanced, generateSemanticContext } from '../../tools/flutter/semantic-detection.js';
20 |
21 | /**
22 | * Extract component metadata
23 | */
24 | export function extractMetadata(node: FigmaNode, userDefinedAsComponent: boolean): ComponentMetadata {
25 | const metadata: ComponentMetadata = {
26 | name: node.name,
27 | type: node.type as 'COMPONENT' | 'COMPONENT_SET' | 'FRAME',
28 | nodeId: node.id,
29 | isUserDefinedComponent: userDefinedAsComponent
30 | };
31 |
32 | // Add component-specific metadata
33 | if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
34 | metadata.componentKey = (node as any).componentKey;
35 | }
36 |
37 | if (node.type === 'COMPONENT_SET') {
38 | metadata.variantCount = node.children?.length || 0;
39 | }
40 |
41 | return metadata;
42 | }
43 |
44 | /**
45 | * Extract layout information
46 | */
47 | export function extractLayoutInfo(node: FigmaNode): LayoutInfo {
48 | const layout: LayoutInfo = {
49 | type: determineLayoutType(node),
50 | dimensions: {
51 | width: node.absoluteBoundingBox?.width || 0,
52 | height: node.absoluteBoundingBox?.height || 0
53 | }
54 | };
55 |
56 | // Auto-layout specific properties
57 | if (node.layoutMode) {
58 | layout.direction = node.layoutMode === 'HORIZONTAL' ? 'horizontal' : 'vertical';
59 | layout.spacing = node.itemSpacing || 0;
60 |
61 | // Extract padding
62 | if (hasPadding(node)) {
63 | layout.padding = extractPadding(node);
64 | }
65 |
66 | // Alignment properties
67 | layout.alignItems = (node as any).primaryAxisAlignItems;
68 | layout.justifyContent = (node as any).counterAxisAlignItems;
69 | }
70 |
71 | // Constraints
72 | if (node.constraints) {
73 | layout.constraints = node.constraints;
74 | }
75 |
76 | return layout;
77 | }
78 |
79 | /**
80 | * Extract styling information
81 | */
82 | export function extractStylingInfo(node: FigmaNode): StylingInfo {
83 | const styling: StylingInfo = {};
84 |
85 | // Fills (background colors/gradients)
86 | if (node.fills && node.fills.length > 0) {
87 | styling.fills = node.fills.map(convertFillToColorInfo);
88 | }
89 |
90 | // Strokes (borders)
91 | if (node.strokes && node.strokes.length > 0) {
92 | styling.strokes = node.strokes.map(convertStrokeInfo);
93 | }
94 |
95 | // Effects (shadows, blurs)
96 | if (node.effects && node.effects.length > 0) {
97 | styling.effects = categorizeEffects(node.effects);
98 | }
99 |
100 | // Corner radius
101 | const cornerRadius = extractCornerRadius(node);
102 | if (cornerRadius) {
103 | styling.cornerRadius = cornerRadius;
104 | }
105 |
106 | // Opacity
107 | if ((node as any).opacity !== undefined && (node as any).opacity !== 1) {
108 | styling.opacity = (node as any).opacity;
109 | }
110 |
111 | return styling;
112 | }
113 |
114 | /**
115 | * Analyze child nodes with prioritization and limits
116 | */
117 | export function analyzeChildren(
118 | node: FigmaNode,
119 | options: Required<ComponentExtractionOptions>
120 | ): {
121 | children: ComponentChild[];
122 | nestedComponents: NestedComponentInfo[];
123 | skippedNodes: SkippedNodeInfo[];
124 | } {
125 | const children: ComponentChild[] = [];
126 | const nestedComponents: NestedComponentInfo[] = [];
127 | const skippedNodes: SkippedNodeInfo[] = [];
128 |
129 | if (!node.children || node.children.length === 0) {
130 | return {children, nestedComponents, skippedNodes};
131 | }
132 |
133 | // Filter visible nodes unless includeHiddenNodes is true
134 | let visibleChildren = node.children;
135 | if (!options.includeHiddenNodes) {
136 | visibleChildren = node.children.filter(child => child.visible !== false);
137 | }
138 |
139 | // Calculate visual importance for all children
140 | const childrenWithImportance = visibleChildren.map(child => ({
141 | node: child,
142 | importance: calculateVisualImportance(child),
143 | isComponent: isComponentNode(child)
144 | }));
145 |
146 | // Sort by importance (components first if prioritized, then by visual importance)
147 | childrenWithImportance.sort((a, b) => {
148 | if (options.prioritizeComponents) {
149 | if (a.isComponent && !b.isComponent) return -1;
150 | if (!a.isComponent && b.isComponent) return 1;
151 | }
152 | return b.importance - a.importance;
153 | });
154 |
155 | // Process up to maxChildNodes
156 | const processedCount = Math.min(childrenWithImportance.length, options.maxChildNodes);
157 |
158 | for (let i = 0; i < childrenWithImportance.length; i++) {
159 | const {node: child, importance, isComponent} = childrenWithImportance[i];
160 |
161 | if (i < processedCount) {
162 | // Check if this is a nested component
163 | if (isComponent) {
164 | nestedComponents.push(createNestedComponentInfo(child));
165 | }
166 |
167 | // Pass parent and siblings for better semantic detection
168 | const siblings = visibleChildren.filter(sibling => sibling.id !== child.id);
169 | children.push(createComponentChild(child, importance, isComponent, options, node, siblings));
170 | } else {
171 | // Track skipped nodes
172 | skippedNodes.push({
173 | nodeId: child.id,
174 | name: child.name,
175 | type: child.type,
176 | reason: 'max_nodes'
177 | });
178 | }
179 | }
180 |
181 | return {children, nestedComponents, skippedNodes};
182 | }
183 |
184 | /**
185 | * Create nested component information
186 | */
187 | export function createNestedComponentInfo(node: FigmaNode): NestedComponentInfo {
188 | return {
189 | nodeId: node.id,
190 | name: node.name,
191 | componentKey: (node as any).componentKey,
192 | masterComponent: (node as any).masterComponent?.key,
193 | isComponentInstance: node.type === 'INSTANCE',
194 | needsSeparateAnalysis: true,
195 | instanceType: node.type === 'INSTANCE' ? 'COMPONENT' : node.type as 'COMPONENT' | 'COMPONENT_SET'
196 | };
197 | }
198 |
199 | /**
200 | * Create component child information
201 | */
202 | export function createComponentChild(
203 | node: FigmaNode,
204 | importance: number,
205 | isNestedComponent: boolean,
206 | options: Required<ComponentExtractionOptions>,
207 | parent?: FigmaNode,
208 | siblings?: FigmaNode[]
209 | ): ComponentChild {
210 | const child: ComponentChild = {
211 | nodeId: node.id,
212 | name: node.name,
213 | type: node.type,
214 | isNestedComponent,
215 | visualImportance: importance
216 | };
217 |
218 | // Extract basic info for non-component children
219 | if (!isNestedComponent) {
220 | child.basicInfo = {
221 | layout: extractBasicLayout(node),
222 | styling: extractBasicStyling(node)
223 | };
224 |
225 | // Extract text info for text nodes
226 | if (node.type === 'TEXT' && options.extractTextContent) {
227 | child.basicInfo.text = extractTextInfo(node, parent, siblings);
228 | }
229 | }
230 |
231 | return child;
232 | }
233 |
234 | /**
235 | * Calculate visual importance score (1-10)
236 | */
237 | export function calculateVisualImportance(node: FigmaNode): number {
238 | let score = 0;
239 |
240 | // Size importance (0-4 points)
241 | const area = (node.absoluteBoundingBox?.width || 0) * (node.absoluteBoundingBox?.height || 0);
242 | if (area > 10000) score += 4;
243 | else if (area > 5000) score += 3;
244 | else if (area > 1000) score += 2;
245 | else if (area > 100) score += 1;
246 |
247 | // Type importance (0-3 points)
248 | if (node.type === 'COMPONENT' || node.type === 'INSTANCE') score += 3;
249 | else if (node.type === 'FRAME') score += 2;
250 | else if (node.type === 'TEXT') score += 2;
251 | else if (node.type === 'VECTOR') score += 1;
252 |
253 | // Styling importance (0-2 points)
254 | if (node.fills && node.fills.length > 0) score += 1;
255 | if (node.effects && node.effects.length > 0) score += 1;
256 |
257 | // Has children importance (0-1 point)
258 | if (node.children && node.children.length > 0) score += 1;
259 |
260 | return Math.min(score, 10);
261 | }
262 |
263 | /**
264 | * Check if node is a component
265 | */
266 | export function isComponentNode(node: FigmaNode): boolean {
267 | return node.type === 'COMPONENT' || node.type === 'INSTANCE' || node.type === 'COMPONENT_SET';
268 | }
269 |
270 | /**
271 | * Determine layout type from node properties
272 | */
273 | export function determineLayoutType(node: FigmaNode): 'auto-layout' | 'absolute' | 'frame' {
274 | if (node.layoutMode) {
275 | return 'auto-layout';
276 | }
277 | if (node.type === 'FRAME' || node.type === 'COMPONENT') {
278 | return 'frame';
279 | }
280 | return 'absolute';
281 | }
282 |
283 | /**
284 | * Check if node has padding
285 | */
286 | export function hasPadding(node: FigmaNode): boolean {
287 | return !!(node.paddingTop || node.paddingRight || node.paddingBottom || node.paddingLeft);
288 | }
289 |
290 | /**
291 | * Extract padding information
292 | */
293 | export function extractPadding(node: FigmaNode): PaddingInfo {
294 | const top = node.paddingTop || 0;
295 | const right = node.paddingRight || 0;
296 | const bottom = node.paddingBottom || 0;
297 | const left = node.paddingLeft || 0;
298 |
299 | return {
300 | top,
301 | right,
302 | bottom,
303 | left,
304 | isUniform: top === right && right === bottom && bottom === left
305 | };
306 | }
307 |
308 | /**
309 | * Convert fill to color info
310 | */
311 | export function convertFillToColorInfo(fill: any): ColorInfo {
312 | const colorInfo: ColorInfo = {
313 | type: fill.type,
314 | opacity: fill.opacity
315 | };
316 |
317 | if (fill.color) {
318 | colorInfo.color = fill.color;
319 | colorInfo.hex = rgbaToHex(fill.color);
320 | }
321 |
322 | if (fill.gradientStops) {
323 | colorInfo.gradientStops = fill.gradientStops;
324 | }
325 |
326 | return colorInfo;
327 | }
328 |
329 | /**
330 | * Convert stroke to stroke info
331 | */
332 | export function convertStrokeInfo(stroke: any): StrokeInfo {
333 | return {
334 | type: stroke.type,
335 | color: stroke.color,
336 | hex: rgbaToHex(stroke.color),
337 | weight: (stroke as any).strokeWeight || 1,
338 | align: (stroke as any).strokeAlign
339 | };
340 | }
341 |
342 | /**
343 | * Categorize effects for Flutter mapping
344 | */
345 | export function categorizeEffects(effects: FigmaEffect[]): CategorizedEffects {
346 | const categorized: CategorizedEffects = {
347 | dropShadows: [],
348 | innerShadows: [],
349 | blurs: []
350 | };
351 |
352 | effects.forEach(effect => {
353 | if (effect.type === 'DROP_SHADOW' && effect.visible !== false) {
354 | categorized.dropShadows.push({
355 | color: effect.color!,
356 | hex: rgbaToHex(effect.color!),
357 | offset: effect.offset || {x: 0, y: 0},
358 | radius: effect.radius,
359 | spread: effect.spread,
360 | opacity: effect.color?.a || 1
361 | });
362 | } else if (effect.type === 'INNER_SHADOW' && effect.visible !== false) {
363 | categorized.innerShadows.push({
364 | color: effect.color!,
365 | hex: rgbaToHex(effect.color!),
366 | offset: effect.offset || {x: 0, y: 0},
367 | radius: effect.radius,
368 | spread: effect.spread,
369 | opacity: effect.color?.a || 1
370 | });
371 | } else if ((effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') && effect.visible !== false) {
372 | categorized.blurs.push({
373 | type: effect.type,
374 | radius: effect.radius
375 | });
376 | }
377 | });
378 |
379 | return categorized;
380 | }
381 |
382 | /**
383 | * Extract corner radius
384 | */
385 | export function extractCornerRadius(node: FigmaNode): number | CornerRadii | undefined {
386 | const nodeAny = node as any;
387 |
388 | if (nodeAny.cornerRadius !== undefined) {
389 | return nodeAny.cornerRadius;
390 | }
391 |
392 | // Check for individual corner radii
393 | if (nodeAny.rectangleCornerRadii && Array.isArray(nodeAny.rectangleCornerRadii)) {
394 | const [topLeft, topRight, bottomRight, bottomLeft] = nodeAny.rectangleCornerRadii;
395 | const isUniform = topLeft === topRight && topRight === bottomRight && bottomRight === bottomLeft;
396 |
397 | if (isUniform) {
398 | return topLeft;
399 | }
400 |
401 | return {
402 | topLeft,
403 | topRight,
404 | bottomLeft,
405 | bottomRight,
406 | isUniform: false
407 | };
408 | }
409 |
410 | return undefined;
411 | }
412 |
413 | /**
414 | * Extract basic layout info for non-component children
415 | */
416 | export function extractBasicLayout(node: FigmaNode): Partial<LayoutInfo> {
417 | return {
418 | type: determineLayoutType(node),
419 | dimensions: {
420 | width: node.absoluteBoundingBox?.width || 0,
421 | height: node.absoluteBoundingBox?.height || 0
422 | }
423 | };
424 | }
425 |
426 | /**
427 | * Extract basic styling info for non-component children
428 | */
429 | export function extractBasicStyling(node: FigmaNode): Partial<StylingInfo> {
430 | const styling: Partial<StylingInfo> = {};
431 |
432 | if (node.fills && node.fills.length > 0) {
433 | styling.fills = node.fills.slice(0, 1).map(convertFillToColorInfo); // Limit to primary fill
434 | }
435 |
436 | const cornerRadius = extractCornerRadius(node);
437 | if (cornerRadius) {
438 | styling.cornerRadius = cornerRadius;
439 | }
440 |
441 | return styling;
442 | }
443 |
444 | /**
445 | * Extract enhanced text information
446 | */
447 | export function extractTextInfo(node: FigmaNode, parent?: FigmaNode, siblings?: FigmaNode[]): TextInfo | undefined {
448 | if (node.type !== 'TEXT') return undefined;
449 |
450 | const textContent = getActualTextContent(node);
451 | const isPlaceholder = isPlaceholderText(textContent);
452 |
453 | return {
454 | content: textContent,
455 | isPlaceholder,
456 | fontFamily: node.style?.fontFamily,
457 | fontSize: node.style?.fontSize,
458 | fontWeight: node.style?.fontWeight,
459 | textAlign: node.style?.textAlignHorizontal,
460 | textCase: detectTextCase(textContent),
461 | semanticType: detectSemanticType(textContent, node.name, node, parent, siblings),
462 | placeholder: isPlaceholder
463 | };
464 | }
465 |
466 | /**
467 | * Get actual text content from various sources
468 | */
469 | function getActualTextContent(node: FigmaNode): string {
470 | // 1. Primary source: characters property (official Figma API text content)
471 | if (node.characters && node.characters.trim().length > 0) {
472 | return node.characters.trim();
473 | }
474 |
475 | // 2. Check fills for text content (sometimes stored in fill metadata)
476 | if (node.fills) {
477 | for (const fill of node.fills) {
478 | if ((fill as any).textData || (fill as any).content) {
479 | const textContent = (fill as any).textData || (fill as any).content;
480 | if (textContent && textContent.trim().length > 0) {
481 | return textContent.trim();
482 | }
483 | }
484 | }
485 | }
486 |
487 | // 3. Check for text in component properties (for component instances)
488 | if (node.type === 'INSTANCE' && (node as any).componentProperties) {
489 | const textProps = extractTextFromComponentProperties((node as any).componentProperties);
490 | if (textProps && textProps.trim().length > 0) {
491 | return textProps.trim();
492 | }
493 | }
494 |
495 | // 4. Analyze node name for meaningful content
496 | const nodeName = node.name;
497 |
498 | // If node name looks like actual content (not generic), use it
499 | if (isLikelyActualContent(nodeName)) {
500 | return nodeName;
501 | }
502 |
503 | // 5. Fallback to node name with placeholder flag
504 | return nodeName;
505 | }
506 |
507 | /**
508 | * Check if node name looks like actual content vs generic label
509 | */
510 | function isLikelyActualContent(name: string): boolean {
511 | const genericPatterns = [
512 | /^text$/i,
513 | /^label$/i,
514 | /^heading$/i,
515 | /^title$/i,
516 | /^body\s*\d*$/i,
517 | /^text\s*\d+$/i,
518 | /^heading\s*\d+$/i,
519 | /^h\d+$/i,
520 | /^lorem\s+ipsum/i,
521 | /^sample\s+text/i,
522 | /^placeholder/i,
523 | /^example\s+text/i,
524 | /^demo\s+text/i,
525 | /^text\s*layer/i,
526 | /^component\s*\d+/i
527 | ];
528 |
529 | // If it matches generic patterns, it's probably not actual content
530 | if (genericPatterns.some(pattern => pattern.test(name))) {
531 | return false;
532 | }
533 |
534 | // If it's very short and common UI text, it might be actual content
535 | const shortUIText = ['ok', 'yes', 'no', 'save', 'cancel', 'close', 'menu', 'home', 'back', 'next', 'login', 'signup'];
536 | if (name.length <= 8 && shortUIText.includes(name.toLowerCase())) {
537 | return true;
538 | }
539 |
540 | // If it contains real words and is reasonably long, likely actual content
541 | if (name.length > 3 && name.length < 100) {
542 | // Check if it has word-like structure
543 | const hasWords = /\b[a-zA-Z]{2,}\b/.test(name);
544 | const hasSpaces = name.includes(' ');
545 |
546 | if (hasWords && (hasSpaces || name.length > 8)) {
547 | return true;
548 | }
549 | }
550 |
551 | return false;
552 | }
553 |
554 | /**
555 | * Check if text content is placeholder/dummy text
556 | */
557 | function isPlaceholderText(content: string): boolean {
558 | if (!content || content.trim().length === 0) {
559 | return true;
560 | }
561 |
562 | const trimmedContent = content.trim().toLowerCase();
563 |
564 | // Common placeholder patterns
565 | const placeholderPatterns = [
566 | /lorem\s+ipsum/i,
567 | /dolor\s+sit\s+amet/i,
568 | /consectetur\s+adipiscing/i,
569 | /the\s+quick\s+brown\s+fox/i,
570 | /sample\s+text/i,
571 | /placeholder/i,
572 | /example\s+text/i,
573 | /demo\s+text/i,
574 | /test\s+content/i,
575 | /dummy\s+text/i,
576 | /text\s+goes\s+here/i,
577 | /your\s+text\s+here/i,
578 | /add\s+text\s+here/i,
579 | /enter\s+text/i,
580 | /\[.*\]/, // Text in brackets like [Your text here]
581 | /^heading\s*\d*$/i,
582 | /^title\s*\d*$/i,
583 | /^body\s*\d*$/i,
584 | /^text\s*\d*$/i,
585 | /^label\s*\d*$/i,
586 | /^h[1-6]$/i,
587 | /^paragraph$/i,
588 | /^caption$/i,
589 | /^subtitle$/i,
590 | /^overline$/i
591 | ];
592 |
593 | // Check against placeholder patterns
594 | if (placeholderPatterns.some(pattern => pattern.test(content))) {
595 | return true;
596 | }
597 |
598 | // Check for generic single words that are likely placeholders
599 | const genericWords = [
600 | 'text', 'label', 'title', 'heading', 'body', 'content',
601 | 'description', 'subtitle', 'caption', 'paragraph', 'copy'
602 | ];
603 |
604 | if (genericWords.includes(trimmedContent)) {
605 | return true;
606 | }
607 |
608 | // Check for repeated characters (like "AAAA" or "xxxx")
609 | if (content.length > 2 && /^(.)\1+$/.test(content.trim())) {
610 | return true;
611 | }
612 |
613 | // Check for Lorem Ipsum variations
614 | if (/lorem|ipsum|dolor|sit|amet|consectetur|adipiscing|elit/i.test(content)) {
615 | return true;
616 | }
617 |
618 | return false;
619 | }
620 |
621 | /**
622 | * Extract text from component properties
623 | */
624 | function extractTextFromComponentProperties(properties: any): string | null {
625 | if (!properties || typeof properties !== 'object') {
626 | return null;
627 | }
628 |
629 | // Look for common text property names
630 | const textPropertyNames = ['text', 'label', 'title', 'content', 'value', 'caption'];
631 |
632 | for (const propName of textPropertyNames) {
633 | if (properties[propName] && typeof properties[propName] === 'string') {
634 | return properties[propName];
635 | }
636 | }
637 |
638 | // Look for any string property that might contain text
639 | for (const [key, value] of Object.entries(properties)) {
640 | if (typeof value === 'string' && value.length > 0 && !isPlaceholderText(value)) {
641 | return value;
642 | }
643 | }
644 |
645 | return null;
646 | }
647 |
648 | /**
649 | * Detect text case pattern
650 | */
651 | function detectTextCase(content: string): 'uppercase' | 'lowercase' | 'capitalize' | 'sentence' | 'mixed' {
652 | if (content.length === 0) return 'mixed';
653 |
654 | const isAllUpper = content === content.toUpperCase() && content !== content.toLowerCase();
655 | const isAllLower = content === content.toLowerCase() && content !== content.toUpperCase();
656 |
657 | if (isAllUpper) return 'uppercase';
658 | if (isAllLower) return 'lowercase';
659 |
660 | // Check if it's title case (first letter of each word capitalized)
661 | const words = content.split(/\s+/);
662 | const isTitleCase = words.every(word => {
663 | return word.length === 0 || word[0] === word[0].toUpperCase();
664 | });
665 |
666 | if (isTitleCase) return 'capitalize';
667 |
668 | // Check if it's sentence case (first letter capitalized, rest normal)
669 | if (content[0] === content[0].toUpperCase()) {
670 | return 'sentence';
671 | }
672 |
673 | return 'mixed';
674 | }
675 |
676 | /**
677 | * Detect semantic type of text based on content and context
678 | * Enhanced with multi-factor analysis and confidence scoring
679 | */
680 | function detectSemanticType(
681 | content: string,
682 | nodeName: string,
683 | node?: any,
684 | parent?: any,
685 | siblings?: any[]
686 | ): 'heading' | 'body' | 'label' | 'button' | 'link' | 'caption' | 'error' | 'success' | 'warning' | 'other' {
687 | // Skip detection for placeholder text
688 | if (isPlaceholderText(content)) {
689 | return 'other';
690 | }
691 |
692 | // Use advanced semantic detection if node properties are available
693 | if (node) {
694 | try {
695 | const context = generateSemanticContext(node, parent, siblings);
696 | const classification = detectSemanticTypeAdvanced(content, nodeName, context, node);
697 |
698 | // Only use advanced classification if confidence is high enough
699 | if (classification.confidence >= 0.6) {
700 | return classification.type;
701 | }
702 |
703 | // Log reasoning for debugging (in development)
704 | if (process.env.NODE_ENV === 'development') {
705 | console.debug(`Low confidence (${classification.confidence}) for "${content}": ${classification.reasoning.join(', ')}`);
706 | }
707 | } catch (error) {
708 | // Fall back to legacy detection if advanced detection fails
709 | console.warn('Advanced semantic detection failed, using legacy method:', error);
710 | }
711 | }
712 |
713 | // Legacy detection as fallback
714 | return detectSemanticTypeLegacy(content, nodeName);
715 | }
716 |
717 | /**
718 | * Legacy semantic type detection (fallback)
719 | */
720 | function detectSemanticTypeLegacy(content: string, nodeName: string): 'heading' | 'body' | 'label' | 'button' | 'link' | 'caption' | 'error' | 'success' | 'warning' | 'other' {
721 | const lowerContent = content.toLowerCase().trim();
722 | const lowerNodeName = nodeName.toLowerCase();
723 |
724 | // Button text patterns - exact matches for common button labels
725 | const buttonPatterns = [
726 | /^(click|tap|press|submit|send|save|cancel|ok|yes|no|continue|next|back|close|done|finish|start|begin)$/i,
727 | /^(login|log in|sign in|signup|sign up|register|logout|log out|sign out)$/i,
728 | /^(buy|purchase|add|remove|delete|edit|update|create|new|get started|learn more|try now)$/i,
729 | /^(download|upload|share|copy|paste|cut|undo|redo|refresh|reload|search|filter|sort|apply|reset|clear)$/i,
730 | /^(accept|decline|agree|disagree|confirm|verify|validate|approve|reject)$/i
731 | ];
732 |
733 | if (buttonPatterns.some(pattern => pattern.test(content)) || lowerNodeName.includes('button')) {
734 | return 'button';
735 | }
736 |
737 | // Error/status text patterns - more comprehensive detection
738 | const errorPatterns = /error|invalid|required|missing|failed|wrong|incorrect|forbidden|unauthorized|not found|unavailable|expired|timeout/i;
739 | const successPatterns = /success|completed|done|saved|updated|created|uploaded|downloaded|sent|delivered|confirmed|verified|approved/i;
740 | const warningPatterns = /warning|caution|note|important|attention|notice|alert|reminder|tip|info|information/i;
741 |
742 | if (errorPatterns.test(content)) {
743 | return 'error';
744 | }
745 |
746 | if (successPatterns.test(content)) {
747 | return 'success';
748 | }
749 |
750 | if (warningPatterns.test(content)) {
751 | return 'warning';
752 | }
753 |
754 | // Link patterns - navigation and informational links
755 | const linkPatterns = [
756 | /^(learn more|read more|see more|view all|show all|details|click here|view details)$/i,
757 | /^(about|contact|help|support|faq|terms|privacy|policy|documentation|docs|guide|tutorial)$/i,
758 | /^(home|dashboard|profile|settings|preferences|account|billing|notifications|security)$/i
759 | ];
760 |
761 | if (linkPatterns.some(pattern => pattern.test(content)) || lowerNodeName.includes('link')) {
762 | return 'link';
763 | }
764 |
765 | // Heading patterns - based on structure and context
766 | if (content.length < 80 && !content.endsWith('.') && !content.includes('\n')) {
767 | if (lowerNodeName.includes('heading') || lowerNodeName.includes('title') || /h[1-6]/.test(lowerNodeName)) {
768 | return 'heading';
769 | }
770 | // Check if it looks like a title (short, starts with capital, no sentence punctuation)
771 | if (content.length < 50 && /^[A-Z]/.test(content) && !/[.!?]$/.test(content)) {
772 | return 'heading';
773 | }
774 | }
775 |
776 | // Label patterns - form labels and descriptive text
777 | if (content.length < 40 && (content.endsWith(':') || lowerNodeName.includes('label') || lowerNodeName.includes('field'))) {
778 | return 'label';
779 | }
780 |
781 | // Caption patterns - short descriptive text
782 | if (content.length < 120 && (
783 | lowerNodeName.includes('caption') ||
784 | lowerNodeName.includes('subtitle') ||
785 | lowerNodeName.includes('description') ||
786 | lowerNodeName.includes('meta')
787 | )) {
788 | return 'caption';
789 | }
790 |
791 | // Body text - longer content, paragraphs
792 | if (content.length > 80 || content.includes('\n') || content.includes('. ')) {
793 | return 'body';
794 | }
795 |
796 | return 'other';
797 | }
798 |
799 | /**
800 | * Generate Flutter widget suggestion based on semantic type and text info
801 | */
802 | export function generateFlutterTextWidget(textInfo: TextInfo): string {
803 | // Escape single quotes in content for Dart strings
804 | const escapedContent = textInfo.content.replace(/'/g, "\\'");
805 |
806 | if (textInfo.isPlaceholder) {
807 | return `Text('${escapedContent}') // TODO: Replace with actual content`;
808 | }
809 |
810 | // Generate style properties based on text info
811 | const styleProps: string[] = [];
812 | if (textInfo.fontFamily) {
813 | styleProps.push(`fontFamily: '${textInfo.fontFamily}'`);
814 | }
815 | if (textInfo.fontSize) {
816 | styleProps.push(`fontSize: ${textInfo.fontSize}`);
817 | }
818 | if (textInfo.fontWeight && textInfo.fontWeight !== 400) {
819 | const fontWeight = textInfo.fontWeight >= 700 ? 'FontWeight.bold' :
820 | textInfo.fontWeight >= 600 ? 'FontWeight.w600' :
821 | textInfo.fontWeight >= 500 ? 'FontWeight.w500' :
822 | textInfo.fontWeight <= 300 ? 'FontWeight.w300' : 'FontWeight.normal';
823 | styleProps.push(`fontWeight: ${fontWeight}`);
824 | }
825 |
826 | const customStyle = styleProps.length > 0 ? `TextStyle(${styleProps.join(', ')})` : null;
827 |
828 | switch (textInfo.semanticType) {
829 | case 'button':
830 | return `ElevatedButton(\n onPressed: () {\n // TODO: Implement button action\n },\n child: Text('${escapedContent}'),\n)`;
831 |
832 | case 'link':
833 | return `TextButton(\n onPressed: () {\n // TODO: Implement navigation\n },\n child: Text('${escapedContent}'),\n)`;
834 |
835 | case 'heading':
836 | const headingStyle = customStyle || 'Theme.of(context).textTheme.headlineMedium';
837 | return `Text(\n '${escapedContent}',\n style: ${headingStyle},\n)`;
838 |
839 | case 'body':
840 | const bodyStyle = customStyle || 'Theme.of(context).textTheme.bodyMedium';
841 | return `Text(\n '${escapedContent}',\n style: ${bodyStyle},\n)`;
842 |
843 | case 'caption':
844 | const captionStyle = customStyle || 'Theme.of(context).textTheme.bodySmall';
845 | return `Text(\n '${escapedContent}',\n style: ${captionStyle},\n)`;
846 |
847 | case 'label':
848 | const labelStyle = customStyle || 'Theme.of(context).textTheme.labelMedium';
849 | return `Text(\n '${escapedContent}',\n style: ${labelStyle},\n)`;
850 |
851 | case 'error':
852 | const errorStyle = customStyle ?
853 | `${customStyle.slice(0, -1)}, color: Theme.of(context).colorScheme.error)` :
854 | 'TextStyle(color: Theme.of(context).colorScheme.error)';
855 | return `Text(\n '${escapedContent}',\n style: ${errorStyle},\n)`;
856 |
857 | case 'success':
858 | const successStyle = customStyle ?
859 | `${customStyle.slice(0, -1)}, color: Colors.green)` :
860 | 'TextStyle(color: Colors.green)';
861 | return `Text(\n '${escapedContent}',\n style: ${successStyle},\n)`;
862 |
863 | case 'warning':
864 | const warningStyle = customStyle ?
865 | `${customStyle.slice(0, -1)}, color: Colors.orange)` :
866 | 'TextStyle(color: Colors.orange)';
867 | return `Text(\n '${escapedContent}',\n style: ${warningStyle},\n)`;
868 |
869 | default:
870 | return customStyle ?
871 | `Text(\n '${escapedContent}',\n style: ${customStyle},\n)` :
872 | `Text('${escapedContent}')`;
873 | }
874 | }
875 |
876 | /**
877 | * Get all text content from a component tree
878 | */
879 | export function extractAllTextContent(node: FigmaNode): Array<{nodeId: string, textInfo: TextInfo, widgetSuggestion: string}> {
880 | const textNodes: Array<{nodeId: string, textInfo: TextInfo, widgetSuggestion: string}> = [];
881 |
882 | traverseForText(node, textNodes);
883 |
884 | return textNodes;
885 | }
886 |
887 | /**
888 | * Recursively traverse node tree to find all text nodes
889 | */
890 | function traverseForText(
891 | node: FigmaNode,
892 | results: Array<{nodeId: string, textInfo: TextInfo, widgetSuggestion: string}>,
893 | depth: number = 0
894 | ): void {
895 | if (depth > 5) return; // Prevent infinite recursion
896 |
897 | if (node.type === 'TEXT') {
898 | const textInfo = extractTextInfo(node);
899 | if (textInfo) {
900 | results.push({
901 | nodeId: node.id,
902 | textInfo,
903 | widgetSuggestion: generateFlutterTextWidget(textInfo)
904 | });
905 | }
906 | }
907 |
908 | if (node.children) {
909 | node.children.forEach(child => {
910 | traverseForText(child, results, depth + 1);
911 | });
912 | }
913 | }
914 |
915 | /**
916 | * Convert RGBA color to hex string
917 | */
918 | export function rgbaToHex(color: FigmaColor): string {
919 | const r = Math.round(color.r * 255);
920 | const g = Math.round(color.g * 255);
921 | const b = Math.round(color.b * 255);
922 |
923 | return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase();
924 | }
```