context-processor.ts•77.2 kB
import {
FigmaNode,
EnhancedFigmaNode,
CSSProperties,
SemanticRole,
AccessibilityInfo,
DesignToken,
ComponentVariant,
InteractionState,
LayoutContext,
ComponentRelationships,
FigmaColor,
FigmaTypeStyle,
FigmaComment,
CommentInstruction,
EnhancedFigmaNodeWithComments
} from '../types/figma.js';
import {
ContextRules,
RuleCondition,
RuleAction,
DEFAULT_RULES,
mergeRules,
getEnvironmentRules
} from '../config/rules.js';
export interface ProcessingContext {
fileKey: string;
fileName?: string;
parentNode?: EnhancedFigmaNode;
depth: number;
siblingIndex: number;
totalSiblings: number;
framework?: 'react' | 'vue' | 'angular' | 'svelte' | 'html' | 'swiftui' | 'uikit' | 'electron' | 'tauri' | 'nwjs' | undefined;
designSystem?: DesignSystemContext;
}
export interface DesignSystemContext {
colors: Map<string, string>;
typography: Map<string, FigmaTypeStyle>;
spacing: Map<string, number>;
components: Map<string, ComponentVariant[]>;
breakpoints: Map<string, number>;
}
export interface ProcessingStats {
nodesProcessed: number;
nodesEnhanced: number;
rulesApplied: number;
processingTime: number;
errors: string[];
warnings: string[];
}
export class ContextProcessor {
private rules: ContextRules;
private stats: ProcessingStats = {
nodesProcessed: 0,
nodesEnhanced: 0,
rulesApplied: 0,
processingTime: 0,
errors: [],
warnings: []
};
constructor(customRules?: Partial<ContextRules>) {
const envRules = getEnvironmentRules();
this.rules = mergeRules(DEFAULT_RULES, { ...envRules, ...customRules });
}
/**
* Process a Figma node tree and enhance it with AI-optimized context
*/
async processNode(
node: FigmaNode,
context: ProcessingContext
): Promise<EnhancedFigmaNode> {
const startTime = Date.now();
this.stats.nodesProcessed++;
try {
// Check depth limit
if (context.depth > this.rules.maxDepth) {
this.stats.warnings.push(`Max depth exceeded for node ${node.id}`);
return this.createMinimalNode(node);
}
// Apply node type filters
if (!this.shouldIncludeNode(node)) {
return this.createMinimalNode(node);
}
// Create enhanced node
const enhancedNode: EnhancedFigmaNode = {
...node,
cssProperties: {},
semanticRole: undefined,
accessibilityInfo: {},
designTokens: [],
componentVariants: [],
interactionStates: [],
layoutContext: this.createLayoutContext(node, context),
// Add component relationships
componentRelationships: this.createComponentRelationships(node, context)
};
// Apply AI optimizations
if (this.rules.aiOptimization.enableCSSGeneration) {
enhancedNode.cssProperties = this.generateCSSProperties(node, context);
}
if (this.rules.aiOptimization.enableSemanticAnalysis) {
enhancedNode.semanticRole = this.analyzeSemanticRole(node, context);
}
if (this.rules.aiOptimization.enableAccessibilityInfo) {
enhancedNode.accessibilityInfo = this.generateAccessibilityInfo(node, context);
}
if (this.rules.aiOptimization.enableDesignTokens) {
enhancedNode.designTokens = this.extractDesignTokens(node, context);
}
if (this.rules.aiOptimization.enableComponentVariants) {
enhancedNode.componentVariants = this.detectComponentVariants(node, context);
}
if (this.rules.aiOptimization.enableInteractionStates) {
enhancedNode.interactionStates = this.generateInteractionStates(node, context);
}
// Apply custom rules
await this.applyCustomRules(enhancedNode, context);
// Process children recursively
if (node.children && context.depth < this.rules.maxDepth) {
enhancedNode.children = await Promise.all(
node.children.map((child, index) =>
this.processNode(child, {
...context,
parentNode: enhancedNode,
depth: context.depth + 1,
siblingIndex: index,
totalSiblings: node.children?.length || 0
})
)
);
}
// Apply context reduction
this.applyContextReduction(enhancedNode);
this.stats.nodesEnhanced++;
return enhancedNode;
} catch (error) {
this.stats.errors.push(`Error processing node ${node.id}: ${error}`);
return this.createMinimalNode(node);
} finally {
this.stats.processingTime += Date.now() - startTime;
}
}
private shouldIncludeNode(node: FigmaNode): boolean {
// Check visibility
if (!this.rules.includeHiddenNodes && node.visible === false) {
return false;
}
// Check locked status
if (!this.rules.includeLockedNodes && node.locked === true) {
return false;
}
// Check node type filters
const { include, exclude } = this.rules.nodeTypeFilters;
if (exclude.includes(node.type)) {
return false;
}
if (include.length > 0 && !include.includes(node.type)) {
return false;
}
return true;
}
private createMinimalNode(node: FigmaNode): EnhancedFigmaNode {
return {
id: node.id,
name: node.name,
type: node.type,
visible: node.visible,
locked: node.locked
};
}
private createLayoutContext(node: FigmaNode, context: ProcessingContext): LayoutContext {
const position = context.totalSiblings === 1 ? 'only' :
context.siblingIndex === 0 ? 'first' :
context.siblingIndex === context.totalSiblings - 1 ? 'last' : 'middle';
return {
parentType: context.parentNode?.type || 'DOCUMENT',
siblingCount: context.totalSiblings,
position,
gridArea: this.detectGridArea(node, context),
flexOrder: this.detectFlexOrder(node, context)
};
}
private createComponentRelationships(node: FigmaNode, context: ProcessingContext): ComponentRelationships {
const relationships: ComponentRelationships = {};
// Parent information
if (context.parentNode) {
relationships.parent = {
id: context.parentNode.id,
name: context.parentNode.name,
type: context.parentNode.type
};
}
// Children information (simplified for AI)
if (node.children && node.children.length > 0) {
relationships.children = node.children.slice(0, 10).map(child => ({
id: child.id,
name: child.name,
type: child.type,
role: this.detectChildRole(child, node)
}));
}
// Sibling information (limited to avoid bloat)
if (context.parentNode?.children && context.totalSiblings > 1) {
const siblings = context.parentNode.children
.filter(sibling => sibling.id !== node.id)
.slice(0, 5)
.map(sibling => ({
id: sibling.id,
name: sibling.name,
type: sibling.type
}));
if (siblings.length > 0) {
relationships.siblings = siblings;
}
}
// Component instance information
if (node.componentId || node.type === 'INSTANCE') {
relationships.componentInstance = {
componentId: node.componentId,
overrides: node.componentProperties
};
}
// Export settings
if (node.exportSettings && node.exportSettings.length > 0) {
relationships.exportable = {
hasExportSettings: true,
formats: node.exportSettings.map(setting => setting.format.toLowerCase()),
category: this.detectExportCategory(node)
};
}
return relationships;
}
private detectChildRole(child: FigmaNode, _parent: FigmaNode): string | undefined {
const childName = child.name.toLowerCase();
// Detect common child roles
if (child.type === 'TEXT') {
if (childName.includes('title') || childName.includes('heading')) return 'title';
if (childName.includes('description') || childName.includes('body')) return 'description';
if (childName.includes('label')) return 'label';
if (childName.includes('caption')) return 'caption';
}
if (this.isButton(child, childName)) return 'action';
if (this.isImage(child, childName)) return 'visual';
if (child.type === 'FRAME' && childName.includes('content')) return 'content';
return undefined;
}
private detectExportCategory(node: FigmaNode): 'icon' | 'image' | 'logo' | 'asset' {
const name = node.name.toLowerCase();
if (name.includes('icon')) return 'icon';
if (name.includes('logo')) return 'logo';
if (name.includes('image') || name.includes('photo')) return 'image';
// Size-based detection
if (node.absoluteBoundingBox) {
const { width, height } = node.absoluteBoundingBox;
if (width <= 32 && height <= 32) return 'icon';
if (width > 200 || height > 200) return 'image';
}
return 'asset';
}
private generateCSSProperties(node: FigmaNode, _context: ProcessingContext): CSSProperties {
const css: CSSProperties = {};
// Layout properties
if (node.absoluteBoundingBox) {
css.width = `${node.absoluteBoundingBox.width}px`;
css.height = `${node.absoluteBoundingBox.height}px`;
}
// Auto layout properties
if (node.layoutMode) {
css.display = 'flex';
css.flexDirection = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column';
if (node.primaryAxisAlignItems) {
css.justifyContent = this.mapAxisAlign(node.primaryAxisAlignItems);
}
if (node.counterAxisAlignItems) {
css.alignItems = this.mapAxisAlign(node.counterAxisAlignItems);
}
if (node.itemSpacing) {
css.gap = `${node.itemSpacing}px`;
}
}
// Padding
if (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom) {
const top = node.paddingTop || 0;
const right = node.paddingRight || 0;
const bottom = node.paddingBottom || 0;
const left = node.paddingLeft || 0;
css.padding = `${top}px ${right}px ${bottom}px ${left}px`;
}
// Background (enhanced with gradient support)
if (node.fills && node.fills.length > 0) {
const fill = node.fills[0];
if (fill && fill.visible !== false) {
if (fill.type === 'SOLID' && fill.color) {
css.backgroundColor = this.colorToCSS(fill.color);
} else if (fill.type.startsWith('GRADIENT_') && fill.gradientStops) {
css.background = this.generateGradientCSS(fill);
} else if (fill.type === 'IMAGE' && fill.imageRef) {
css.backgroundImage = `url(${fill.imageRef})`;
if (fill.scaleMode) {
css.backgroundSize = this.mapScaleMode(fill.scaleMode);
}
}
}
}
// Border radius - support individual corners and cornerSmoothing
if (node.cornerRadius !== undefined) {
css.borderRadius = `${node.cornerRadius}px`;
} else if (node.rectangleCornerRadii) {
// Support for individual corner radii
const [topLeft, topRight, bottomRight, bottomLeft] = node.rectangleCornerRadii;
css.borderRadius = `${topLeft}px ${topRight}px ${bottomRight}px ${bottomLeft}px`;
}
// Strokes (borders)
if (node.strokes && node.strokes.length > 0) {
const stroke = node.strokes[0]; // Use first stroke
if (stroke && stroke.type === 'SOLID' && stroke.color) {
const strokeWeight = node.strokeWeight || 1;
const strokeColor = this.colorToCSS(stroke.color);
// Handle stroke alignment
if (node.strokeAlign === 'INSIDE') {
// Use box-shadow inset to simulate inside stroke
css.boxShadow = `inset 0 0 0 ${strokeWeight}px ${strokeColor}`;
} else if (node.strokeAlign === 'OUTSIDE') {
// Use box-shadow to simulate outside stroke
css.boxShadow = `0 0 0 ${strokeWeight}px ${strokeColor}`;
} else {
// CENTER (default) - use regular border
css.border = `${strokeWeight}px solid ${strokeColor}`;
}
}
}
// Individual strokes per side
if (node.individualStrokeWeights) {
const { top, right, bottom, left } = node.individualStrokeWeights;
if (node.strokes && node.strokes.length > 0) {
const stroke = node.strokes[0];
if (stroke && stroke.type === 'SOLID' && stroke.color) {
const strokeColor = this.colorToCSS(stroke.color);
css.borderTop = top > 0 ? `${top}px solid ${strokeColor}` : 'none';
css.borderRight = right > 0 ? `${right}px solid ${strokeColor}` : 'none';
css.borderBottom = bottom > 0 ? `${bottom}px solid ${strokeColor}` : 'none';
css.borderLeft = left > 0 ? `${left}px solid ${strokeColor}` : 'none';
}
}
}
// Stroke dashes
if (node.strokeDashes && node.strokeDashes.length > 0) {
css.borderStyle = 'dashed';
// Note: CSS doesn't support custom dash patterns like Figma
}
// Opacity
if (node.opacity !== undefined && node.opacity < 1) {
css.opacity = node.opacity.toString();
}
// Blend mode
if (node.blendMode && node.blendMode !== 'NORMAL' && node.blendMode !== 'PASS_THROUGH') {
css.mixBlendMode = node.blendMode.toLowerCase().replace('_', '-');
}
// Layout sizing (for auto layout children)
if (node.layoutSizingHorizontal === 'FILL') {
css.flexGrow = '1';
}
if (node.layoutSizingVertical === 'FILL') {
css.alignSelf = 'stretch';
}
// Layout alignment
if (node.layoutAlign) {
switch (node.layoutAlign) {
case 'MIN':
css.alignSelf = 'flex-start';
break;
case 'CENTER':
css.alignSelf = 'center';
break;
case 'MAX':
css.alignSelf = 'flex-end';
break;
case 'STRETCH':
css.alignSelf = 'stretch';
break;
}
}
// Text properties
if (node.type === 'TEXT' && node.style) {
css.fontSize = `${node.style.fontSize}px`;
css.fontFamily = node.style.fontFamily;
css.lineHeight = `${node.style.lineHeightPx}px`;
css.letterSpacing = `${node.style.letterSpacing}px`;
// Add missing typography properties
if (node.style.fontPostScriptName) {
css.fontFamily = `"${node.style.fontPostScriptName}", ${css.fontFamily}`;
}
// Text decoration
if (node.style.textDecoration && node.style.textDecoration !== 'NONE') {
css.textDecoration = node.style.textDecoration.toLowerCase().replace('_', '-');
}
// Text transform
if (node.style.textCase && node.style.textCase !== 'ORIGINAL') {
switch (node.style.textCase) {
case 'UPPER':
css.textTransform = 'uppercase';
break;
case 'LOWER':
css.textTransform = 'lowercase';
break;
case 'TITLE':
css.textTransform = 'capitalize';
break;
case 'SMALL_CAPS':
case 'SMALL_CAPS_FORCED':
css.fontVariant = 'small-caps';
break;
}
}
// Paragraph spacing
if (node.style.paragraphSpacing) {
css.marginBottom = `${node.style.paragraphSpacing}px`;
}
// Paragraph indent
if (node.style.paragraphIndent) {
css.textIndent = `${node.style.paragraphIndent}px`;
}
if (node.style.fills && node.style.fills.length > 0) {
const textFill = node.style.fills[0];
if (textFill && textFill.type === 'SOLID' && textFill.color) {
css.color = this.colorToCSS(textFill.color);
}
}
}
// Effects (shadows and blurs)
if (node.effects && node.effects.length > 0) {
const dropShadows: string[] = [];
const innerShadows: string[] = [];
let layerBlur: number | undefined;
let backgroundBlur: number | undefined;
// Process all effects
node.effects.forEach(effect => {
if (effect.visible === false) return; // Skip invisible effects
switch (effect.type) {
case 'DROP_SHADOW':
const x = effect.offset?.x || 0;
const y = effect.offset?.y || 0;
const blur = effect.radius || 0;
const spread = effect.spread || 0;
const color = effect.color ? this.colorToCSS(effect.color) : 'rgba(0,0,0,0.25)';
dropShadows.push(`${x}px ${y}px ${blur}px ${spread}px ${color}`);
break;
case 'INNER_SHADOW':
const ix = effect.offset?.x || 0;
const iy = effect.offset?.y || 0;
const iblur = effect.radius || 0;
const ispread = effect.spread || 0;
const icolor = effect.color ? this.colorToCSS(effect.color) : 'rgba(0,0,0,0.25)';
innerShadows.push(`inset ${ix}px ${iy}px ${iblur}px ${ispread}px ${icolor}`);
break;
case 'LAYER_BLUR':
layerBlur = effect.radius || 0;
break;
case 'BACKGROUND_BLUR':
backgroundBlur = effect.radius || 0;
break;
}
});
// Combine all shadows into box-shadow
const allShadows = [...innerShadows, ...dropShadows];
if (allShadows.length > 0) {
// Check if we already have stroke shadows
if (css.boxShadow) {
css.boxShadow = `${css.boxShadow}, ${allShadows.join(', ')}`;
} else {
css.boxShadow = allShadows.join(', ');
}
}
// Apply blur effects
if (layerBlur !== undefined || backgroundBlur !== undefined) {
const filters: string[] = [];
if (layerBlur !== undefined) {
filters.push(`blur(${layerBlur}px)`);
}
if (backgroundBlur !== undefined) {
// backdrop-filter for background blur
css.backdropFilter = `blur(${backgroundBlur}px)`;
}
if (filters.length > 0) {
css.filter = filters.join(' ');
}
}
}
return css;
}
private analyzeSemanticRole(node: FigmaNode, context: ProcessingContext): SemanticRole | undefined {
const name = node.name.toLowerCase();
// Enhanced button detection with states
if (this.isButton(node, name)) {
return {
type: 'button',
purpose: 'interactive',
variant: this.detectButtonVariant(node, name),
state: this.detectComponentState(node, name)
};
}
// Enhanced input detection with field types
if (this.isInput(node, name)) {
return {
type: 'input',
purpose: 'data-entry',
inputType: this.detectInputType(node, name),
required: name.includes('required') || name.includes('*')
};
}
// Navigation and menu detection
if (this.isNavigation(node, name, context)) {
return {
type: 'navigation',
purpose: 'navigation',
level: this.detectNavigationLevel(node, context)
};
}
// Enhanced text content with semantic hierarchy
if (node.type === 'TEXT') {
const hierarchy = this.detectTextHierarchy(node);
const contentType = this.detectContentType(node, name);
return {
type: 'text',
hierarchy,
contentType,
textAlign: this.detectTextAlignment(node)
};
}
// List detection
if (this.isList(node, name, context)) {
return {
type: 'list',
purpose: 'content',
listType: this.detectListType(node, name),
itemCount: this.countListItems(node)
};
}
// Grid detection
if (this.isGrid(node, name, context)) {
return {
type: 'grid',
purpose: 'layout',
gridStructure: this.analyzeGridStructure(node),
responsive: this.detectResponsiveBehavior(node)
};
}
// Card component detection
if (this.isCard(node, name, context)) {
return {
type: 'card',
purpose: 'content',
cardType: this.detectCardType(node, name),
hasActions: this.hasCardActions(node)
};
}
// Container with layout analysis
if (node.type === 'FRAME' && node.children && node.children.length > 0) {
const layoutPattern = this.detectLayoutPattern(node);
return {
type: 'container',
purpose: 'layout',
layoutPattern,
semantic: this.detectContainerSemantic(node, name)
};
}
// Image with enhanced detection
if (this.isImage(node, name)) {
return {
type: 'image',
purpose: 'visual',
imageType: this.detectImageType(node, name),
hasCaption: this.hasImageCaption(node, context)
};
}
return undefined;
}
// Enhanced helper methods for semantic analysis
private isButton(node: FigmaNode, name: string): boolean {
return name.includes('button') ||
name.includes('btn') ||
name.includes('cta') ||
(node.type === 'FRAME' && this.hasButtonCharacteristics(node));
}
private hasButtonCharacteristics(node: FigmaNode): boolean {
// Check for button-like styling: rounded corners, solid background, centered text
const hasRoundedCorners = Boolean(node.cornerRadius && node.cornerRadius > 0);
const hasSolidBackground = Boolean(node.fills && node.fills.some(fill => fill.type === 'SOLID'));
const hasClickableSize = Boolean(node.absoluteBoundingBox &&
node.absoluteBoundingBox.width >= 60 && node.absoluteBoundingBox.height >= 32);
const hasTextChild = Boolean(node.children && node.children.some(child => child.type === 'TEXT'));
return hasRoundedCorners && hasSolidBackground && hasClickableSize && hasTextChild;
}
private detectButtonVariant(_node: FigmaNode, name: string): string {
if (name.includes('primary')) return 'primary';
if (name.includes('secondary')) return 'secondary';
if (name.includes('outline')) return 'outline';
if (name.includes('ghost')) return 'ghost';
if (name.includes('link')) return 'link';
return 'default';
}
private detectComponentState(_node: FigmaNode, name: string): string {
if (name.includes('disabled')) return 'disabled';
if (name.includes('hover')) return 'hover';
if (name.includes('active')) return 'active';
if (name.includes('focus')) return 'focus';
return 'default';
}
private isInput(_node: FigmaNode, name: string): boolean {
return name.includes('input') ||
name.includes('field') ||
name.includes('textbox') ||
name.includes('textarea') ||
name.includes('select') ||
name.includes('dropdown');
}
private detectInputType(_node: FigmaNode, name: string): string {
if (name.includes('email')) return 'email';
if (name.includes('password')) return 'password';
if (name.includes('search')) return 'search';
if (name.includes('number')) return 'number';
if (name.includes('tel') || name.includes('phone')) return 'tel';
if (name.includes('url')) return 'url';
if (name.includes('date')) return 'date';
if (name.includes('textarea')) return 'textarea';
if (name.includes('select') || name.includes('dropdown')) return 'select';
return 'text';
}
private isNavigation(node: FigmaNode, name: string, context: ProcessingContext): boolean {
return name.includes('nav') ||
name.includes('menu') ||
name.includes('header') ||
name.includes('breadcrumb') ||
(this.hasNavigationPattern(node) && context.depth <= 2);
}
private hasNavigationPattern(node: FigmaNode): boolean {
// Check for horizontal list of links/buttons
if (node.layoutMode === 'HORIZONTAL' && node.children) {
const hasMultipleItems = node.children.length >= 2;
const hasUniformItems = this.hasUniformChildren(node);
return hasMultipleItems && hasUniformItems;
}
return false;
}
private detectNavigationLevel(_node: FigmaNode, context: ProcessingContext): number {
if (context.depth === 0) return 1; // Primary navigation
if (context.depth === 1) return 2; // Secondary navigation
return 3; // Tertiary navigation
}
private isList(node: FigmaNode, name: string, _context: ProcessingContext): boolean {
if (name.includes('list') || name.includes('items')) return true;
// Auto-detect list pattern
if (node.children && node.children.length >= 2) {
const hasRepeatingPattern = this.hasRepeatingPattern(node);
const isVerticalLayout = node.layoutMode === 'VERTICAL' ||
(node.layoutMode === undefined && this.hasVerticalArrangement(node));
return hasRepeatingPattern && isVerticalLayout;
}
return false;
}
private detectListType(node: FigmaNode, name: string): string {
if (name.includes('ordered') || name.includes('numbered')) return 'ordered';
if (name.includes('unordered') || name.includes('bullet')) return 'unordered';
if (name.includes('description') || name.includes('definition')) return 'description';
// Auto-detect based on content
if (node.children && node.children.length > 0) {
const firstChild = node.children[0];
if (firstChild && this.hasNumbering(firstChild)) return 'ordered';
if (firstChild && this.hasBulletPoints(firstChild)) return 'unordered';
}
return 'unordered';
}
private countListItems(node: FigmaNode): number {
if (!node.children) return 0;
// Count direct children that represent list items
return node.children.filter(child =>
child.type === 'FRAME' || child.type === 'TEXT'
).length;
}
private isGrid(node: FigmaNode, name: string, _context: ProcessingContext): boolean {
if (name.includes('grid') || name.includes('gallery')) return true;
// Auto-detect grid pattern
if (node.children && node.children.length >= 4) {
const gridStructure = this.analyzeGridStructure(node);
return gridStructure.columns > 1 && gridStructure.rows > 1;
}
return false;
}
private analyzeGridStructure(node: FigmaNode): { columns: number; rows: number; gap: number } {
if (!node.children || node.children.length === 0) {
return { columns: 1, rows: 1, gap: 0 };
}
// Sort children by position
const children = [...node.children].sort((a, b) => {
const aBox = a.absoluteBoundingBox;
const bBox = b.absoluteBoundingBox;
if (!aBox || !bBox) return 0;
// Sort by Y first, then by X
if (Math.abs(aBox.y - bBox.y) < 10) {
return aBox.x - bBox.x;
}
return aBox.y - bBox.y;
});
// Detect grid dimensions
if (children.length === 0) return { columns: 1, rows: 1, gap: 0 };
const firstChild = children[0];
if (!firstChild || !firstChild.absoluteBoundingBox) return { columns: 1, rows: 1, gap: 0 };
// Count items in first row (same Y position)
const firstRowY = firstChild.absoluteBoundingBox.y;
const firstRowItems = children.filter(child =>
child.absoluteBoundingBox &&
Math.abs(child.absoluteBoundingBox.y - firstRowY) < 10
);
const columns = firstRowItems.length;
const rows = Math.ceil(children.length / columns);
// Calculate gap
let gap = 0;
if (firstRowItems.length > 1) {
const first = firstRowItems[0]?.absoluteBoundingBox;
const second = firstRowItems[1]?.absoluteBoundingBox;
if (first && second) {
gap = second.x - (first.x + first.width);
}
}
return { columns, rows, gap: Math.max(0, gap) };
}
private isCard(node: FigmaNode, name: string, _context: ProcessingContext): boolean {
if (name.includes('card') || name.includes('tile')) return true;
// Auto-detect card pattern
return this.hasCardCharacteristics(node);
}
private hasCardCharacteristics(node: FigmaNode): boolean {
// Check for card-like styling and content structure
const hasBackground = Boolean(node.fills && node.fills.length > 0);
const hasBorder = Boolean(node.strokes && node.strokes.length > 0);
const hasShadow = Boolean(node.effects && node.effects.some(effect =>
effect.type === 'DROP_SHADOW' && effect.visible !== false
));
const hasStructuredContent = Boolean(node.children && node.children.length >= 2);
const hasRoundedCorners = Boolean(node.cornerRadius && node.cornerRadius > 0);
return (hasBackground || hasBorder || hasShadow) &&
hasStructuredContent &&
hasRoundedCorners;
}
private detectCardType(_node: FigmaNode, name: string): string {
if (name.includes('product')) return 'product';
if (name.includes('profile') || name.includes('user')) return 'profile';
if (name.includes('article') || name.includes('blog')) return 'article';
if (name.includes('feature')) return 'feature';
return 'content';
}
private hasCardActions(node: FigmaNode): boolean {
if (!node.children) return false;
return node.children.some(child =>
this.isButton(child, child.name.toLowerCase()) ||
child.name.toLowerCase().includes('action') ||
child.name.toLowerCase().includes('link')
);
}
private detectLayoutPattern(node: FigmaNode): string {
if (!node.children || node.children.length === 0) return 'empty';
// Check for specific layout patterns
if (node.layoutMode === 'HORIZONTAL') {
if (this.hasUniformChildren(node)) return 'horizontal-list';
if (this.hasSidebarPattern(node)) return 'sidebar';
return 'horizontal-flow';
}
if (node.layoutMode === 'VERTICAL') {
if (this.hasHeaderBodyFooterPattern(node)) return 'header-body-footer';
if (this.hasUniformChildren(node)) return 'vertical-list';
return 'vertical-flow';
}
// Auto layout not defined, analyze positioning
if (this.hasGridPattern(node)) return 'grid';
if (this.hasAbsolutePositioning(node)) return 'absolute';
if (this.hasStackingPattern(node)) return 'stack';
return 'free-form';
}
private detectContainerSemantic(_node: FigmaNode, name: string): string {
if (name.includes('header')) return 'header';
if (name.includes('footer')) return 'footer';
if (name.includes('sidebar')) return 'aside';
if (name.includes('main') || name.includes('content')) return 'main';
if (name.includes('section')) return 'section';
if (name.includes('article')) return 'article';
if (name.includes('nav')) return 'nav';
return 'div';
}
private isImage(node: FigmaNode, name: string): boolean {
const hasImageFill = node.fills && node.fills.some(fill => fill.type === 'IMAGE');
const isImageType = node.type === 'RECTANGLE' || node.type === 'ELLIPSE';
const hasImageName = name.includes('image') || name.includes('photo') ||
name.includes('picture') || name.includes('avatar');
return hasImageFill || (isImageType && hasImageName);
}
private detectImageType(_node: FigmaNode, name: string): string {
if (name.includes('avatar') || name.includes('profile')) return 'avatar';
if (name.includes('logo')) return 'logo';
if (name.includes('icon')) return 'icon';
if (name.includes('hero') || name.includes('banner')) return 'hero';
if (name.includes('thumbnail')) return 'thumbnail';
return 'content';
}
private hasImageCaption(_node: FigmaNode, context: ProcessingContext): boolean {
// Check if there's a text element near this image
if (!context.parentNode?.children) return false;
const nodeIndex = context.siblingIndex;
const siblings = context.parentNode.children;
// Check next sibling for caption
if (nodeIndex + 1 < siblings.length) {
const nextSibling = siblings[nodeIndex + 1];
if (!nextSibling) return false;
return nextSibling.type === 'TEXT' &&
nextSibling.name.toLowerCase().includes('caption');
}
return false;
}
// Enhanced text hierarchy detection
private detectTextHierarchy(node: FigmaNode): number {
if (node.type !== 'TEXT' || !node.style) {
return 0;
}
const fontSize = node.style.fontSize;
// @ts-ignore - fontWeight not in type definition but exists in API
const fontWeight = node.style.fontWeight || 400;
const name = node.name.toLowerCase();
// Check explicit heading indicators first
if (name.includes('h1') || name.includes('heading 1')) return 1;
if (name.includes('h2') || name.includes('heading 2')) return 2;
if (name.includes('h3') || name.includes('heading 3')) return 3;
if (name.includes('h4') || name.includes('heading 4')) return 4;
if (name.includes('h5') || name.includes('heading 5')) return 5;
if (name.includes('h6') || name.includes('heading 6')) return 6;
// Semantic name-based detection
if (name.includes('title') || name.includes('headline')) {
if (fontSize >= 32) return 1;
if (fontSize >= 24) return 2;
return 3;
}
if (name.includes('subtitle') || name.includes('subheading')) {
if (fontSize >= 20) return 3;
return 4;
}
// Font size and weight based detection
if (fontSize >= 36 || (fontSize >= 28 && fontWeight >= 600)) return 1;
if (fontSize >= 28 || (fontSize >= 24 && fontWeight >= 600)) return 2;
if (fontSize >= 24 || (fontSize >= 20 && fontWeight >= 600)) return 3;
if (fontSize >= 20 || (fontSize >= 18 && fontWeight >= 600)) return 4;
if (fontSize >= 18 || (fontSize >= 16 && fontWeight >= 600)) return 5;
if (fontSize >= 16 && fontWeight >= 600) return 6;
return 0; // Body text
}
private detectContentType(_node: FigmaNode, name: string): string {
if (name.includes('title') || name.includes('heading') || name.includes('headline')) return 'title';
if (name.includes('subtitle') || name.includes('subheading')) return 'subtitle';
if (name.includes('label')) return 'label';
if (name.includes('caption')) return 'caption';
if (name.includes('description') || name.includes('body')) return 'body';
if (name.includes('quote') || name.includes('blockquote')) return 'quote';
if (name.includes('code')) return 'code';
if (name.includes('link')) return 'link';
if (name.includes('date') || name.includes('time')) return 'datetime';
if (name.includes('price') || name.includes('cost')) return 'price';
if (name.includes('tag')) return 'tag';
return 'text';
}
private detectTextAlignment(node: FigmaNode): string {
if (node.type !== 'TEXT' || !node.style) return 'left';
// @ts-ignore - textAlignHorizontal not in type definition but exists in API
const textAlign = node.style.textAlignHorizontal;
if (textAlign === 'CENTER') return 'center';
if (textAlign === 'RIGHT') return 'right';
if (textAlign === 'JUSTIFIED') return 'justify';
return 'left';
}
private generateAccessibilityInfo(node: FigmaNode, context: ProcessingContext): AccessibilityInfo {
const info: AccessibilityInfo = {};
// Generate aria-label from node name
if (node.name && !node.name.startsWith('Rectangle') && !node.name.startsWith('Ellipse')) {
info.ariaLabel = node.name;
}
// Set focusable for interactive elements
const semanticRole = this.analyzeSemanticRole(node, context);
if (semanticRole?.type === 'button' || semanticRole?.type === 'input') {
info.focusable = true;
info.tabIndex = 0;
}
// Set appropriate ARIA roles
switch (semanticRole?.type) {
case 'button':
info.ariaRole = 'button';
break;
case 'input':
info.ariaRole = 'textbox';
break;
case 'navigation':
info.ariaRole = 'navigation';
break;
case 'image':
info.ariaRole = 'img';
info.altText = node.name;
break;
}
return info;
}
private extractDesignTokens(node: FigmaNode, _context: ProcessingContext): DesignToken[] {
const tokens: DesignToken[] = [];
// Color tokens
if (node.fills && node.fills.length > 0) {
node.fills.forEach((fill, index) => {
if (fill.type === 'SOLID' && fill.color) {
tokens.push({
name: `${node.name}-fill-${index}`,
value: this.colorToCSS(fill.color),
type: 'color',
category: 'background'
});
}
});
}
// Typography tokens
if (node.type === 'TEXT' && node.style) {
tokens.push({
name: `${node.name}-font-size`,
value: `${node.style.fontSize}px`,
type: 'typography',
category: 'font-size'
});
tokens.push({
name: `${node.name}-line-height`,
value: `${node.style.lineHeightPx}px`,
type: 'typography',
category: 'line-height'
});
}
// Spacing tokens
if (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom) {
const padding = [
node.paddingTop || 0,
node.paddingRight || 0,
node.paddingBottom || 0,
node.paddingLeft || 0
];
tokens.push({
name: `${node.name}-padding`,
value: padding.map(p => `${p}px`).join(' '),
type: 'spacing',
category: 'padding'
});
}
// Shadow tokens
if (node.effects && node.effects.length > 0) {
const dropShadows = node.effects.filter(e => e.type === 'DROP_SHADOW' && e.visible !== false);
const innerShadows = node.effects.filter(e => e.type === 'INNER_SHADOW' && e.visible !== false);
dropShadows.forEach((shadow, index) => {
const x = shadow.offset?.x || 0;
const y = shadow.offset?.y || 0;
const blur = shadow.radius || 0;
const spread = shadow.spread || 0;
const color = shadow.color ? this.colorToCSS(shadow.color) : 'rgba(0,0,0,0.25)';
tokens.push({
name: `${node.name}-drop-shadow-${index}`,
value: `${x}px ${y}px ${blur}px ${spread}px ${color}`,
type: 'shadow',
category: 'drop-shadow'
});
});
innerShadows.forEach((shadow, index) => {
const x = shadow.offset?.x || 0;
const y = shadow.offset?.y || 0;
const blur = shadow.radius || 0;
const spread = shadow.spread || 0;
const color = shadow.color ? this.colorToCSS(shadow.color) : 'rgba(0,0,0,0.25)';
tokens.push({
name: `${node.name}-inner-shadow-${index}`,
value: `inset ${x}px ${y}px ${blur}px ${spread}px ${color}`,
type: 'shadow',
category: 'inner-shadow'
});
});
}
// Border tokens
if (node.strokes && node.strokes.length > 0 && node.strokeWeight) {
const stroke = node.strokes[0];
if (stroke && stroke.type === 'SOLID' && stroke.color) {
tokens.push({
name: `${node.name}-border`,
value: `${node.strokeWeight}px solid ${this.colorToCSS(stroke.color)}`,
type: 'border',
category: 'stroke'
});
}
}
// Border radius tokens
if (node.cornerRadius !== undefined) {
tokens.push({
name: `${node.name}-border-radius`,
value: `${node.cornerRadius}px`,
type: 'border',
category: 'radius'
});
}
return tokens;
}
private detectComponentVariants(node: FigmaNode, _context: ProcessingContext): ComponentVariant[] {
const variants: ComponentVariant[] = [];
if (node.type === 'COMPONENT' || node.type === 'INSTANCE') {
// Default variant
variants.push({
name: 'default',
properties: {},
state: 'default'
});
// Detect hover state based on naming
if (node.name.toLowerCase().includes('hover')) {
variants.push({
name: 'hover',
properties: { state: 'hover' },
state: 'hover'
});
}
// Detect disabled state
if (node.name.toLowerCase().includes('disabled')) {
variants.push({
name: 'disabled',
properties: { state: 'disabled' },
state: 'disabled'
});
}
}
return variants;
}
private generateInteractionStates(node: FigmaNode, context: ProcessingContext): InteractionState[] {
const states: InteractionState[] = [];
const semanticRole = this.analyzeSemanticRole(node, context);
if (semanticRole?.type === 'button') {
states.push({
trigger: 'hover',
changes: { opacity: '0.8' },
animation: { duration: '0.2s', easing: 'ease-in-out' }
});
states.push({
trigger: 'click',
changes: { transform: 'scale(0.95)' },
animation: { duration: '0.1s', easing: 'ease-in-out' }
});
}
if (semanticRole?.type === 'input') {
states.push({
trigger: 'focus',
changes: {
borderColor: '#007AFF',
boxShadow: '0 0 0 2px rgba(0, 122, 255, 0.2)'
},
animation: { duration: '0.2s', easing: 'ease-in-out' }
});
}
return states;
}
private async applyCustomRules(node: EnhancedFigmaNode, context: ProcessingContext): Promise<void> {
const applicableRules = this.rules.customRules
.filter(rule => rule.enabled)
.filter(rule => this.evaluateRuleCondition(rule.condition, node, context))
.sort((a, b) => b.priority - a.priority);
for (const rule of applicableRules) {
try {
await this.applyRuleAction(rule.action, node, context);
this.stats.rulesApplied++;
} catch (error) {
this.stats.errors.push(`Error applying rule "${rule.name}": ${error}`);
}
}
}
private evaluateRuleCondition(condition: RuleCondition, node: EnhancedFigmaNode, _context: ProcessingContext): boolean {
// Check node type
if (condition.nodeType) {
const types = Array.isArray(condition.nodeType) ? condition.nodeType : [condition.nodeType];
if (!types.includes(node.type)) {
return false;
}
}
// Check node name
if (condition.nodeName) {
if (condition.nodeName instanceof RegExp) {
if (!condition.nodeName.test(node.name)) {
return false;
}
} else {
if (!node.name.toLowerCase().includes(condition.nodeName.toLowerCase())) {
return false;
}
}
}
// Check has children
if (condition.hasChildren !== undefined) {
const hasChildren = node.children && node.children.length > 0;
if (condition.hasChildren !== hasChildren) {
return false;
}
}
// Check has text
if (condition.hasText !== undefined) {
const hasText = node.type === 'TEXT' ||
(node.children && node.children.some(child => child.type === 'TEXT'));
if (condition.hasText !== hasText) {
return false;
}
}
// Check is component
if (condition.isComponent !== undefined) {
const isComponent = node.type === 'COMPONENT' || node.type === 'INSTANCE';
if (condition.isComponent !== isComponent) {
return false;
}
}
// Check has auto layout
if (condition.hasAutoLayout !== undefined) {
const hasAutoLayout = node.layoutMode !== undefined && node.layoutMode !== 'NONE';
if (condition.hasAutoLayout !== hasAutoLayout) {
return false;
}
}
// Custom condition
if (condition.customCondition) {
return condition.customCondition(node);
}
return true;
}
private async applyRuleAction(action: RuleAction, node: EnhancedFigmaNode, context: ProcessingContext): Promise<void> {
switch (action.type) {
case 'enhance':
Object.assign(node, action.parameters);
break;
case 'transform':
// Apply transformations based on parameters
break;
case 'custom':
if (action.customAction) {
await action.customAction(node, context);
}
break;
}
}
private applyContextReduction(node: EnhancedFigmaNode): void {
if (!this.rules.contextReduction.removeRedundantProperties) {
return;
}
// Remove empty arrays and objects
Object.keys(node).forEach(key => {
const value = (node as any)[key];
if (Array.isArray(value) && value.length === 0) {
delete (node as any)[key];
} else if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) {
delete (node as any)[key];
}
});
// Limit text length
if (node.characters && node.characters.length > this.rules.contextReduction.limitTextLength) {
node.characters = node.characters.substring(0, this.rules.contextReduction.limitTextLength) + '...';
}
}
// Helper methods
private colorToCSS(color: FigmaColor): string {
const r = Math.round(color.r * 255);
const g = Math.round(color.g * 255);
const b = Math.round(color.b * 255);
const a = color.a;
if (a === 1) {
return `rgb(${r}, ${g}, ${b})`;
} else {
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
}
private mapAxisAlign(align: string): string {
switch (align) {
case 'MIN': return 'flex-start';
case 'CENTER': return 'center';
case 'MAX': return 'flex-end';
case 'SPACE_BETWEEN': return 'space-between';
default: return 'flex-start';
}
}
private detectGridArea(_node: FigmaNode, _context: ProcessingContext): string | undefined {
// Implementation for detecting CSS Grid area
return undefined;
}
private detectFlexOrder(_node: FigmaNode, _context: ProcessingContext): number | undefined {
// Implementation for detecting flex order
return undefined;
}
/**
* Get processing statistics
*/
getStats(): ProcessingStats {
return { ...this.stats };
}
/**
* Reset processing statistics
*/
resetStats(): void {
this.stats = {
nodesProcessed: 0,
nodesEnhanced: 0,
rulesApplied: 0,
processingTime: 0,
errors: [],
warnings: []
};
}
/**
* Update rules configuration
*/
updateRules(newRules: Partial<ContextRules>): void {
this.rules = mergeRules(this.rules, newRules);
}
/**
* Process comments and associate them with nodes using coordinate matching
* Uses bottom-up approach to find the most specific element for each comment
*/
processCommentsForNode(
node: EnhancedFigmaNode,
comments: FigmaComment[]
): EnhancedFigmaNodeWithComments {
// Extract simplified comment instructions with coordinates
const simplifiedInstructions = this.extractSimplifiedInstructions(comments);
console.error(`[Context Processor] Processing comments for node: ${node.name} (${node.id})`);
console.error(` Available instructions: ${simplifiedInstructions.length}`);
// COMPREHENSIVE COORDINATE DEBUG: Show all element bounds vs comment coordinates
if (simplifiedInstructions.length > 0) {
console.error(`[Context Processor] COORDINATE ANALYSIS:`);
simplifiedInstructions.forEach((inst, instIndex) => {
console.error(` Comment ${instIndex}: "${inst.instruction}" at (${inst.coordinates.x}, ${inst.coordinates.y})`);
this.debugElementCoordinates(node, inst.coordinates, ` `);
});
}
// Process children first (bottom-up approach) - this is crucial for specificity
const processedChildren: EnhancedFigmaNodeWithComments[] = [];
const usedInstructions = new Set<number>(); // Track which instructions have been used
if (node.children) {
console.error(` Processing ${node.children.length} children first...`);
for (const child of node.children) {
const processedChild = this.processCommentsForNode(child as EnhancedFigmaNode, comments);
processedChildren.push(processedChild);
// Mark instructions used by children - be more aggressive about tracking
if (processedChild.aiInstructions && processedChild.aiInstructions.length > 0) {
console.error(` Child ${child.name} claimed ${processedChild.aiInstructions.length} instructions`);
processedChild.aiInstructions.forEach(inst => {
const index = simplifiedInstructions.findIndex(si =>
si.instruction === inst.instruction &&
si.coordinates &&
si.coordinates.x === inst.coordinates!.x &&
si.coordinates.y === inst.coordinates!.y
);
if (index >= 0) {
usedInstructions.add(index);
console.error(` Marked instruction ${index} as used: "${inst.instruction}"`);
}
});
}
// Also recursively mark instructions from grandchildren
this.markUsedInstructionsRecursively(processedChild, simplifiedInstructions, usedInstructions);
}
}
console.error(` Instructions used by children: ${usedInstructions.size}`);
// Only match instructions to this node if they haven't been used by children
const availableInstructions = simplifiedInstructions.filter((_, index) => !usedInstructions.has(index));
console.error(` Available instructions for this node: ${availableInstructions.length}`);
// Use ONLY coordinate-based matching - no semantic override
// Children have already claimed their instructions, now parent gets remaining ones that coordinate-match
const matchedInstructions = this.matchInstructionsToNode(node, availableInstructions);
// Create enhanced node with comments
const enhancedNodeWithComments: EnhancedFigmaNodeWithComments = {
...node,
children: processedChildren.length > 0 ? processedChildren : undefined
};
// Only attach instructions if there are any for this specific node
if (matchedInstructions.length > 0) {
console.error(` Attaching ${matchedInstructions.length} instructions to ${node.name}`);
enhancedNodeWithComments.aiInstructions = matchedInstructions;
enhancedNodeWithComments.commentInstructions = matchedInstructions;
}
return enhancedNodeWithComments;
}
/**
* Recursively mark instructions as used from all descendants
*/
private markUsedInstructionsRecursively(
node: EnhancedFigmaNodeWithComments,
simplifiedInstructions: Array<{ instruction: string; coordinates: { x: number; y: number }; nodeId?: string }>,
usedInstructions: Set<number>
): void {
// Mark this node's instructions
if (node.aiInstructions) {
node.aiInstructions.forEach(inst => {
const index = simplifiedInstructions.findIndex(si =>
si.instruction === inst.instruction &&
si.coordinates &&
si.coordinates.x === inst.coordinates!.x &&
si.coordinates.y === inst.coordinates!.y
);
if (index >= 0) {
usedInstructions.add(index);
}
});
}
// Recursively mark children's instructions
if (node.children) {
node.children.forEach(child => {
this.markUsedInstructionsRecursively(child as EnhancedFigmaNodeWithComments, simplifiedInstructions, usedInstructions);
});
}
}
/**
* Extract only essential data from comments: instruction + coordinates
*/
private extractSimplifiedInstructions(comments: FigmaComment[]): Array<{
instruction: string;
coordinates: { x: number; y: number };
nodeId?: string;
}> {
console.error(`[Context Processor] FULL COMMENT DATA DEBUG:`);
comments.forEach((comment, index) => {
console.error(` Comment ${index}:`);
console.error(` message: "${comment.message}"`);
console.error(` client_meta:`, JSON.stringify(comment.client_meta, null, 2));
if (comment.client_meta?.node_offset) {
console.error(` coordinates: (${comment.client_meta.node_offset.x}, ${comment.client_meta.node_offset.y})`);
}
if (comment.client_meta?.node_id) {
console.error(` target_node_id: ${comment.client_meta.node_id}`);
}
});
const instructions = comments
.filter(comment => comment.message && comment.client_meta?.node_offset)
.map(comment => ({
instruction: comment.message,
coordinates: {
x: comment.client_meta!.node_offset!.x,
y: comment.client_meta!.node_offset!.y
},
nodeId: comment.client_meta?.node_id
}));
// Enhanced debug logging for comment coordinates
console.error(`[Context Processor] Extracted ${instructions.length} instructions with coordinates:`);
instructions.forEach((inst, index) => {
console.error(` ${index}: "${inst.instruction}" at (${inst.coordinates.x}, ${inst.coordinates.y}) node_id: ${inst.nodeId || 'none'}`);
});
return instructions;
}
/**
* Match instructions to nodes using precise coordinate matching with specificity priority
*/
private matchInstructionsToNode(
node: EnhancedFigmaNode,
instructions: Array<{ instruction: string; coordinates: { x: number; y: number }; nodeId?: string }>
): CommentInstruction[] {
const matchedInstructions: CommentInstruction[] = [];
// Debug logging for this node
console.error(`[Context Processor] Matching instructions for node: ${node.name} (${node.id})`);
if (node.absoluteBoundingBox) {
const bounds = node.absoluteBoundingBox;
console.error(` Node bounds: x=${bounds.x}, y=${bounds.y}, width=${bounds.width}, height=${bounds.height}`);
console.error(` Bounds range: x=${bounds.x} to ${bounds.x + bounds.width}, y=${bounds.y} to ${bounds.y + bounds.height}`);
} else {
console.error(` Node has no absoluteBoundingBox`);
}
// Direct node ID match (highest priority)
const directMatches = instructions.filter(inst => inst.nodeId === node.id);
console.error(` Direct ID matches: ${directMatches.length}`);
// Precise coordinate-based matching - prioritize smaller, more specific elements
let coordinateMatches: typeof instructions = [];
if (node.absoluteBoundingBox && instructions.length > directMatches.length) {
const bounds = node.absoluteBoundingBox;
const nodeArea = bounds.width * bounds.height; // Calculate area for specificity
coordinateMatches = instructions.filter(inst =>
!inst.nodeId || inst.nodeId !== node.id // Don't double-count direct matches
).filter(inst => {
const { x, y } = inst.coordinates;
// STRICT coordinate matching only - no fuzzy tolerance for precision
const exactMatch = x >= bounds.x &&
x <= bounds.x + bounds.width &&
y >= bounds.y &&
y <= bounds.y + bounds.height;
if (exactMatch) {
console.error(` EXACT COORDINATE MATCH: "${inst.instruction}" at (${x}, ${y}) in node area ${nodeArea}px²`);
return true;
}
console.error(` NO MATCH: "${inst.instruction}" at (${x}, ${y}) outside bounds`);
return false;
});
}
console.error(` Precise coordinate matches: ${coordinateMatches.length}`);
// Convert to CommentInstruction format with specificity-based confidence
[...directMatches, ...coordinateMatches].forEach(match => {
const instructionType = this.categorizeInstruction(match.instruction);
let confidence = 1.0;
// Adjust confidence based on match type and node specificity
if (match.nodeId === node.id) {
confidence = 1.0; // Direct node ID match
} else if (node.absoluteBoundingBox) {
const bounds = node.absoluteBoundingBox;
const nodeArea = bounds.width * bounds.height;
// Higher confidence for smaller, more specific elements
// Elements smaller than 10,000px² (100x100) get higher confidence
if (nodeArea < 10000) {
confidence = 0.95; // High confidence for small, specific elements like logos/icons
} else if (nodeArea < 50000) {
confidence = 0.85; // Medium confidence for medium elements like buttons
} else {
confidence = 0.7; // Lower confidence for large containers
}
console.error(` Assigned confidence ${confidence} based on node area ${nodeArea}px²`);
} else {
confidence = 0.5; // Low confidence for nodes without bounds
}
matchedInstructions.push({
type: instructionType,
instruction: match.instruction,
author: 'Designer',
timestamp: new Date().toISOString(),
confidence,
coordinates: match.coordinates
});
});
console.error(` Total matched instructions: ${matchedInstructions.length}`);
return matchedInstructions;
}
/**
* Debug helper: Recursively show all element bounds compared to comment coordinates
*/
private debugElementCoordinates(node: EnhancedFigmaNode, commentCoords: { x: number; y: number }, indent: string): void {
if (node.absoluteBoundingBox) {
const bounds = node.absoluteBoundingBox;
const isInside = commentCoords.x >= bounds.x &&
commentCoords.x <= bounds.x + bounds.width &&
commentCoords.y >= bounds.y &&
commentCoords.y <= bounds.y + bounds.height;
const area = bounds.width * bounds.height;
console.error(`${indent}${node.name} (${node.type}): bounds(${bounds.x},${bounds.y}) to (${bounds.x + bounds.width},${bounds.y + bounds.height}) area=${area}px² ${isInside ? '✓ CONTAINS' : '✗ outside'}`);
} else {
console.error(`${indent}${node.name} (${node.type}): NO BOUNDS`);
}
// Recursively check children
if (node.children) {
node.children.forEach(child => {
this.debugElementCoordinates(child as EnhancedFigmaNode, commentCoords, indent + ' ');
});
}
}
/**
* Simplified instruction categorization
*/
private categorizeInstruction(instruction: string): 'animation' | 'interaction' | 'behavior' | 'general' {
const text = instruction.toLowerCase();
if (text.includes('hover') || text.includes('click') || text.includes('tap') || text.includes('focus')) {
return 'interaction';
}
if (text.includes('animate') || text.includes('animation') || text.includes('transition') ||
text.includes('fade') || text.includes('slide') || text.includes('bounce')) {
return 'animation';
}
if (text.includes('show') || text.includes('hide') || text.includes('toggle') ||
text.includes('enable') || text.includes('disable')) {
return 'behavior';
}
return 'general';
}
/**
* Extract all node IDs from a node tree for comment filtering
*/
extractAllNodeIds(node: FigmaNode): string[] {
const nodeIds = [node.id];
if (node.children) {
node.children.forEach(child => {
nodeIds.push(...this.extractAllNodeIds(child));
});
}
return nodeIds;
}
/**
* Generate optimized data structure for AI code generation
* Removes redundant and non-valuable information, focuses on development needs
*/
optimizeForAI(node: EnhancedFigmaNodeWithComments): any {
const optimized: any = {
// Core identification
id: node.id,
name: node.name,
type: node.type,
};
// Only include valuable properties
if (node.visible === false) optimized.visible = false; // Only if hidden
if (node.children && node.children.length > 0) {
optimized.children = node.children.map(child =>
this.optimizeForAI(child as EnhancedFigmaNodeWithComments)
);
}
// AI-friendly positioning (simplified)
if (node.absoluteBoundingBox) {
optimized.bounds = {
x: node.absoluteBoundingBox.x,
y: node.absoluteBoundingBox.y,
width: node.absoluteBoundingBox.width,
height: node.absoluteBoundingBox.height
};
}
// Essential CSS properties only
if (node.cssProperties && Object.keys(node.cssProperties).length > 0) {
optimized.css = this.cleanCSSProperties(node.cssProperties);
}
// Detect and mark exportable images/icons
const imageInfo = this.detectExportableImage(node);
if (imageInfo) {
optimized.image = imageInfo;
// Override type to be more specific
if (imageInfo.category === 'icon') optimized.type = 'ICON';
if (imageInfo.category === 'image') optimized.type = 'IMAGE';
}
// Semantic information for AI
if (node.semanticRole) {
optimized.role = node.semanticRole;
}
// Accessibility information
if (node.accessibilityInfo && Object.keys(node.accessibilityInfo).length > 0) {
optimized.accessibility = node.accessibilityInfo;
}
// Design tokens (valuable for design systems)
if (node.designTokens && node.designTokens.length > 0) {
optimized.tokens = node.designTokens.map(token => ({
name: token.name,
value: token.value,
type: token.type
}));
}
// Interaction states (valuable for components)
if (node.interactionStates && node.interactionStates.length > 0) {
optimized.interactions = node.interactionStates;
}
// Text content (essential)
if (node.type === 'TEXT' && node.characters) {
optimized.text = node.characters;
// Simplified text styling (no complex overrides)
if (node.style) {
optimized.textStyle = {
fontFamily: node.style.fontFamily,
fontSize: node.style.fontSize,
lineHeight: node.style.lineHeightPx
};
}
}
// Layout information (essential for containers)
if (node.layoutMode) {
optimized.layout = {
mode: node.layoutMode,
direction: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column',
gap: node.itemSpacing,
padding: this.simplifyPadding(node)
};
}
// Component relationships (valuable for understanding structure)
if (node.componentRelationships) {
const relationships: any = {};
if (node.componentRelationships.parent) {
relationships.parent = {
name: node.componentRelationships.parent.name,
type: node.componentRelationships.parent.type
};
}
if (node.componentRelationships.children && node.componentRelationships.children.length > 0) {
relationships.children = node.componentRelationships.children.map(child => ({
name: child.name,
type: child.type,
role: child.role
}));
}
if (node.componentRelationships.componentInstance) {
relationships.component = node.componentRelationships.componentInstance;
}
if (node.componentRelationships.exportable?.hasExportSettings) {
relationships.exportable = node.componentRelationships.exportable;
}
if (Object.keys(relationships).length > 0) {
optimized.relationships = relationships;
}
}
// AI Instructions (the most valuable!)
if (node.aiInstructions && node.aiInstructions.length > 0) {
optimized.instructions = node.aiInstructions.map(inst => ({
type: inst.type,
instruction: inst.instruction,
confidence: inst.confidence
}));
}
return optimized;
}
/**
* Clean CSS properties - remove redundant and keep only development-relevant ones
*/
private cleanCSSProperties(css: any): any {
const essential = ['width', 'height', 'padding', 'margin', 'gap',
'backgroundColor', 'color', 'fontSize', 'fontFamily',
'borderRadius', 'border', 'boxShadow', 'display',
'flexDirection', 'justifyContent', 'alignItems'];
const cleaned: any = {};
essential.forEach(prop => {
if (css[prop] && css[prop] !== '0px' && css[prop] !== 'none') {
cleaned[prop] = css[prop];
}
});
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
/**
* Simplify padding to a single property
*/
private simplifyPadding(node: any): string | undefined {
if (!node.paddingTop && !node.paddingRight && !node.paddingBottom && !node.paddingLeft) {
return undefined;
}
const top = node.paddingTop || 0;
const right = node.paddingRight || 0;
const bottom = node.paddingBottom || 0;
const left = node.paddingLeft || 0;
// Check if all sides are equal
if (top === right && right === bottom && bottom === left) {
return `${top}px`;
}
return `${top}px ${right}px ${bottom}px ${left}px`;
}
/**
* Detect ONLY nodes with explicit Figma export settings - no heuristics
* Following Figma API specification: only export what's marked for export
*/
private detectExportableImage(node: any): { category: 'icon' | 'image' | 'logo'; formats: string[]; isExportable: boolean } | null {
// ONLY detect nodes with explicit export settings configured in Figma
const hasExportSettings = node.exportSettings && node.exportSettings.length > 0;
if (!hasExportSettings) {
return null; // No export settings = not exportable
}
// Extract actual export formats and scales from Figma export settings
const formats: string[] = [];
for (const setting of node.exportSettings) {
const format = setting.format.toLowerCase();
let scale = 1;
// Extract scale from constraint according to Figma API
if (setting.constraint) {
if (setting.constraint.type === 'SCALE') {
scale = setting.constraint.value;
}
}
// Format with scale info (e.g., "svg", "png@2x")
if (format === 'svg' || scale === 1) {
formats.push(format);
} else {
formats.push(`${format}@${scale}x`);
}
}
// Determine category based on actual content, not guessing
let category: 'icon' | 'image' | 'logo' = 'icon';
// Check for actual image fills (photos/raster images)
const hasImageFill = node.fills && node.fills.some((fill: any) =>
fill.type === 'IMAGE' && fill.imageRef
);
if (hasImageFill) {
category = 'image';
} else {
// For vector content, check naming for logos vs icons
const name = node.name.toLowerCase();
if (name.includes('logo') || name.includes('brand')) {
category = 'logo';
} else {
category = 'icon';
}
}
return {
category,
formats: [...new Set(formats)], // Remove duplicates
isExportable: true
};
}
// Helper methods for pattern detection
private hasUniformChildren(node: FigmaNode): boolean {
if (!node.children || node.children.length < 2) return false;
const firstChild = node.children[0];
if (!firstChild) return false;
const firstType = firstChild.type;
const firstSize = firstChild.absoluteBoundingBox;
return node.children.every(child => {
const childSize = child.absoluteBoundingBox;
const sameType = child.type === firstType;
const similarSize = firstSize && childSize &&
Math.abs(firstSize.width - childSize.width) < 20 &&
Math.abs(firstSize.height - childSize.height) < 20;
return sameType && similarSize;
});
}
private hasRepeatingPattern(node: FigmaNode): boolean {
if (!node.children || node.children.length < 2) return false;
// Check if children have similar structure
const firstChild = node.children[0];
if (!firstChild) return false;
const pattern = this.analyzeNodeStructure(firstChild);
return node.children.slice(1).every(child =>
this.matchesStructurePattern(child, pattern)
);
}
private hasVerticalArrangement(node: FigmaNode): boolean {
if (!node.children || node.children.length < 2) return false;
const sorted = [...node.children].sort((a, b) => {
const aBox = a.absoluteBoundingBox;
const bBox = b.absoluteBoundingBox;
if (!aBox || !bBox) return 0;
return aBox.y - bBox.y;
});
// Check if items are arranged vertically with minimal horizontal overlap
for (let i = 1; i < sorted.length; i++) {
const prevNode = sorted[i - 1];
const currNode = sorted[i];
const prev = prevNode?.absoluteBoundingBox;
const curr = currNode?.absoluteBoundingBox;
if (!prev || !curr) continue;
// Items should be below each other, not side by side
if (curr.y <= prev.y + prev.height * 0.5) return false;
}
return true;
}
private hasNumbering(node: FigmaNode): boolean {
if (node.type !== 'TEXT' || !node.characters) return false;
const text = node.characters.trim();
const numberPatterns = [
/^\d+\./, // 1. 2. 3.
/^\d+\)/, // 1) 2) 3)
/^\(\d+\)/, // (1) (2) (3)
/^[ivx]+\./i, // i. ii. iii.
/^[a-z]\./ // a. b. c.
];
return numberPatterns.some(pattern => pattern.test(text));
}
private hasBulletPoints(node: FigmaNode): boolean {
if (node.type !== 'TEXT' || !node.characters) return false;
const text = node.characters.trim();
const bulletPatterns = [
/^•/, /^·/, /^‣/, /^⁃/, // Unicode bullets
/^-/, /^\*/, /^\+/ // ASCII bullets
];
return bulletPatterns.some(pattern => pattern.test(text));
}
private hasSidebarPattern(node: FigmaNode): boolean {
if (!node.children || node.children.length !== 2) return false;
const first = node.children[0];
const second = node.children[1];
if (!first || !second) return false;
const firstBox = first.absoluteBoundingBox;
const secondBox = second.absoluteBoundingBox;
if (!firstBox || !secondBox) return false;
// One child should be significantly narrower (sidebar)
const firstIsNarrow = firstBox.width < secondBox.width * 0.5;
const secondIsNarrow = secondBox.width < firstBox.width * 0.5;
return firstIsNarrow || secondIsNarrow;
}
private hasHeaderBodyFooterPattern(node: FigmaNode): boolean {
if (!node.children || node.children.length < 3) return false;
// Sort by Y position
const sorted = [...node.children].sort((a, b) => {
const aBox = a.absoluteBoundingBox;
const bBox = b.absoluteBoundingBox;
if (!aBox || !bBox) return 0;
return aBox.y - bBox.y;
});
// Check if first and last are smaller than middle sections
const heights = sorted.map(child => child.absoluteBoundingBox?.height || 0);
if (heights.length < 3) return false;
const [headerHeight, ...bodyAndFooter] = heights;
const footerHeight = bodyAndFooter[bodyAndFooter.length - 1];
const bodyHeights = bodyAndFooter.slice(0, -1);
const maxBodyHeight = Math.max(...bodyHeights);
return (headerHeight || 0) < maxBodyHeight && (footerHeight || 0) < maxBodyHeight;
}
private hasGridPattern(node: FigmaNode): boolean {
if (!node.children || node.children.length < 4) return false;
const structure = this.analyzeGridStructure(node);
return structure.columns > 1 && structure.rows > 1;
}
private hasAbsolutePositioning(node: FigmaNode): boolean {
if (!node.children || node.children.length === 0) return false;
// Check if children overlap significantly (indicating absolute positioning)
const boxes = node.children
.map(child => child.absoluteBoundingBox)
.filter(box => box !== undefined);
if (boxes.length < 2) return false;
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const box1 = boxes[i]!;
const box2 = boxes[j]!;
const overlapX = Math.max(0, Math.min(box1.x + box1.width, box2.x + box2.width) - Math.max(box1.x, box2.x));
const overlapY = Math.max(0, Math.min(box1.y + box1.height, box2.y + box2.height) - Math.max(box1.y, box2.y));
const overlapArea = overlapX * overlapY;
const box1Area = box1.width * box1.height;
const box2Area = box2.width * box2.height;
const minArea = Math.min(box1Area, box2Area);
// Significant overlap indicates absolute positioning
if (overlapArea > minArea * 0.25) {
return true;
}
}
}
return false;
}
private hasStackingPattern(node: FigmaNode): boolean {
if (!node.children || node.children.length < 2) return false;
// Check if elements are stacked with similar positions (like z-index stacking)
const centerPoints = node.children.map(child => {
const box = child.absoluteBoundingBox;
return box ? {
x: box.x + box.width / 2,
y: box.y + box.height / 2
} : null;
}).filter(point => point !== null);
if (centerPoints.length < 2) return false;
// Check if center points are close together
const [first, ...rest] = centerPoints;
const threshold = 50; // pixels
return rest.every(point =>
Math.abs(point!.x - first!.x) < threshold &&
Math.abs(point!.y - first!.y) < threshold
);
}
private analyzeNodeStructure(node: FigmaNode): any {
return {
type: node.type,
hasText: node.type === 'TEXT' || (node.children && node.children.some(child => child.type === 'TEXT')),
hasImage: node.fills && node.fills.some(fill => fill.type === 'IMAGE'),
childCount: node.children ? node.children.length : 0,
hasBackground: node.fills && node.fills.length > 0,
hasCornerRadius: node.cornerRadius && node.cornerRadius > 0
};
}
private matchesStructurePattern(node: FigmaNode, pattern: any): boolean {
const structure = this.analyzeNodeStructure(node);
return structure.type === pattern.type &&
structure.hasText === pattern.hasText &&
structure.hasImage === pattern.hasImage &&
Math.abs(structure.childCount - pattern.childCount) <= 1 &&
structure.hasBackground === pattern.hasBackground;
}
private detectResponsiveBehavior(node: FigmaNode): boolean {
// Analyze layout properties to detect responsive behavior intentions
if (!node.children || node.children.length === 0) return false;
// Check for auto layout (indicates responsive design)
const hasAutoLayout = node.layoutMode !== undefined && node.layoutMode !== 'NONE';
// Check for flexible sizing
const hasFlexibleSizing = node.children.some(child =>
child.layoutSizingHorizontal === 'FILL' ||
child.layoutSizingVertical === 'FILL' ||
(child.layoutGrow !== undefined && child.layoutGrow > 0)
);
// Check for constraints that suggest responsive behavior
const hasResponsiveConstraints = node.children.some(child =>
child.constraints?.horizontal === 'LEFT_RIGHT' ||
child.constraints?.horizontal === 'SCALE' ||
child.constraints?.vertical === 'TOP_BOTTOM' ||
child.constraints?.vertical === 'SCALE'
);
return hasAutoLayout || hasFlexibleSizing || hasResponsiveConstraints;
}
private generateGradientCSS(fill: any): string {
if (!fill.gradientStops || fill.gradientStops.length === 0) {
return '';
}
const stops = fill.gradientStops
.map((stop: any) => `${this.colorToCSS(stop.color)} ${Math.round(stop.position * 100)}%`)
.join(', ');
switch (fill.type) {
case 'GRADIENT_LINEAR':
// Calculate angle from gradient handle positions if available
let angle = '180deg'; // Default to top-to-bottom
if (fill.gradientHandlePositions && fill.gradientHandlePositions.length >= 2) {
const start = fill.gradientHandlePositions[0];
const end = fill.gradientHandlePositions[1];
if (start && end) {
const deltaX = end.x - start.x;
const deltaY = end.y - start.y;
const angleRad = Math.atan2(deltaY, deltaX);
angle = `${Math.round((angleRad * 180) / Math.PI + 90)}deg`;
}
}
return `linear-gradient(${angle}, ${stops})`;
case 'GRADIENT_RADIAL':
return `radial-gradient(circle, ${stops})`;
case 'GRADIENT_ANGULAR':
return `conic-gradient(${stops})`;
case 'GRADIENT_DIAMOND':
return `radial-gradient(ellipse, ${stops})`;
default:
return `linear-gradient(${stops})`;
}
}
private mapScaleMode(scaleMode: string): string {
switch (scaleMode) {
case 'FILL':
return 'cover';
case 'FIT':
return 'contain';
case 'TILE':
return 'repeat';
case 'STRETCH':
return '100% 100%';
default:
return 'cover';
}
}
}