""" Element Schema for UI Comparison Standardized structure for elements extracted from both Figma and Website DOM. Designed for checkout page comparison but extensible to other page types. """ from typing import Dict, List, Optional, Any from dataclasses import dataclass, field, asdict from enum import Enum import json class ElementType(str, Enum): """Normalized element types across Figma and DOM.""" BUTTON = "button" INPUT = "input" TEXT = "text" HEADING = "heading" LABEL = "label" IMAGE = "image" ICON = "icon" CONTAINER = "container" LINK = "link" CHECKBOX = "checkbox" RADIO = "radio" SELECT = "select" CARD = "card" DIVIDER = "divider" PRICE = "price" BADGE = "badge" FORM = "form" UNKNOWN = "unknown" # Mapping Figma node types to our normalized types FIGMA_TYPE_MAP = { "TEXT": ElementType.TEXT, "RECTANGLE": ElementType.CONTAINER, "ELLIPSE": ElementType.ICON, "FRAME": ElementType.CONTAINER, "GROUP": ElementType.CONTAINER, "COMPONENT": ElementType.CONTAINER, "INSTANCE": ElementType.CONTAINER, "VECTOR": ElementType.ICON, "LINE": ElementType.DIVIDER, "IMAGE": ElementType.IMAGE, } # Mapping DOM elements to our normalized types DOM_TYPE_MAP = { "button": ElementType.BUTTON, "input": ElementType.INPUT, "textarea": ElementType.INPUT, "select": ElementType.SELECT, "a": ElementType.LINK, "img": ElementType.IMAGE, "svg": ElementType.ICON, "h1": ElementType.HEADING, "h2": ElementType.HEADING, "h3": ElementType.HEADING, "h4": ElementType.HEADING, "h5": ElementType.HEADING, "h6": ElementType.HEADING, "p": ElementType.TEXT, "span": ElementType.TEXT, "label": ElementType.LABEL, "div": ElementType.CONTAINER, "section": ElementType.CONTAINER, "article": ElementType.CONTAINER, "form": ElementType.FORM, "hr": ElementType.DIVIDER, } @dataclass class ElementBounds: """Position and dimensions of an element.""" x: float y: float width: float height: float def to_dict(self) -> Dict: return asdict(self) def area(self) -> float: return self.width * self.height def center(self) -> tuple: return (self.x + self.width / 2, self.y + self.height / 2) def overlaps(self, other: 'ElementBounds', threshold: float = 0.5) -> bool: """Check if this bounds overlaps with another by at least threshold amount.""" x_overlap = max(0, min(self.x + self.width, other.x + other.width) - max(self.x, other.x)) y_overlap = max(0, min(self.y + self.height, other.y + other.height) - max(self.y, other.y)) overlap_area = x_overlap * y_overlap min_area = min(self.area(), other.area()) if min_area == 0: return False return (overlap_area / min_area) >= threshold @dataclass class ElementStyles: """Visual styles of an element.""" # Colors (stored as hex strings like "#FFFFFF") background_color: Optional[str] = None text_color: Optional[str] = None border_color: Optional[str] = None # Typography font_family: Optional[str] = None font_size: Optional[float] = None font_weight: Optional[int] = None line_height: Optional[float] = None text_align: Optional[str] = None letter_spacing: Optional[float] = None # Borders border_width: Optional[float] = None border_radius: Optional[float] = None border_style: Optional[str] = None # Spacing (padding) padding_top: Optional[float] = None padding_right: Optional[float] = None padding_bottom: Optional[float] = None padding_left: Optional[float] = None # Effects opacity: Optional[float] = None box_shadow: Optional[str] = None def to_dict(self) -> Dict: return {k: v for k, v in asdict(self).items() if v is not None} @dataclass class UIElement: """ Unified element representation for comparison. Works for both Figma nodes and DOM elements. """ id: str element_type: ElementType name: str bounds: ElementBounds styles: ElementStyles # Content text_content: Optional[str] = None placeholder: Optional[str] = None # Hierarchy parent_id: Optional[str] = None children_ids: List[str] = field(default_factory=list) depth: int = 0 # Source info source: str = "" # "figma" or "website" original_type: str = "" # Original type before normalization # For checkout-specific detection is_interactive: bool = False input_type: Optional[str] = None # "text", "email", "tel", "number", etc. # Matching (populated during comparison phase) matched_element_id: Optional[str] = None match_confidence: float = 0.0 def to_dict(self) -> Dict: return { "id": self.id, "element_type": self.element_type.value, "name": self.name, "bounds": self.bounds.to_dict(), "styles": self.styles.to_dict(), "text_content": self.text_content, "placeholder": self.placeholder, "parent_id": self.parent_id, "children_ids": self.children_ids, "depth": self.depth, "source": self.source, "original_type": self.original_type, "is_interactive": self.is_interactive, "input_type": self.input_type, "matched_element_id": self.matched_element_id, "match_confidence": self.match_confidence } @classmethod def from_dict(cls, data: Dict) -> 'UIElement': """Create UIElement from dictionary.""" bounds = ElementBounds(**data["bounds"]) styles = ElementStyles(**data.get("styles", {})) return cls( id=data["id"], element_type=ElementType(data["element_type"]), name=data["name"], bounds=bounds, styles=styles, text_content=data.get("text_content"), placeholder=data.get("placeholder"), parent_id=data.get("parent_id"), children_ids=data.get("children_ids", []), depth=data.get("depth", 0), source=data.get("source", ""), original_type=data.get("original_type", ""), is_interactive=data.get("is_interactive", False), input_type=data.get("input_type"), matched_element_id=data.get("matched_element_id"), match_confidence=data.get("match_confidence", 0.0) ) @dataclass class ElementDifference: """Represents a difference between Figma and Website elements.""" category: str # "typography", "color", "spacing", "size", "missing", "extra", "position" severity: str # "high", "medium", "low" property_name: str figma_value: Any website_value: Any element_name: str element_type: str description: str # Location info figma_element_id: Optional[str] = None website_element_id: Optional[str] = None viewport: str = "desktop" def to_dict(self) -> Dict: return asdict(self) @dataclass class ComparisonResult: """Complete comparison result between Figma and Website.""" viewport: str overall_score: float # 0-100 # Element counts figma_element_count: int website_element_count: int matched_count: int missing_in_website: int extra_in_website: int # Differences by category differences: List[ElementDifference] = field(default_factory=list) # Score breakdown layout_score: float = 100.0 typography_score: float = 100.0 color_score: float = 100.0 spacing_score: float = 100.0 # AI analysis (populated by Agent 5) ai_insights: Optional[str] = None ai_priority_issues: List[str] = field(default_factory=list) def to_dict(self) -> Dict: return { "viewport": self.viewport, "overall_score": self.overall_score, "figma_element_count": self.figma_element_count, "website_element_count": self.website_element_count, "matched_count": self.matched_count, "missing_in_website": self.missing_in_website, "extra_in_website": self.extra_in_website, "differences": [d.to_dict() for d in self.differences], "layout_score": self.layout_score, "typography_score": self.typography_score, "color_score": self.color_score, "spacing_score": self.spacing_score, "ai_insights": self.ai_insights, "ai_priority_issues": self.ai_priority_issues } def rgb_to_hex(r: float, g: float, b: float, normalized: bool = True) -> str: """ Convert RGB values to hex string. Args: r, g, b: RGB values normalized: If True, values are 0-1 range (Figma). If False, 0-255 range. """ if normalized: r, g, b = int(r * 255), int(g * 255), int(b * 255) else: r, g, b = int(r), int(g), int(b) return f"#{r:02x}{g:02x}{b:02x}".upper() def hex_to_rgb(hex_color: str) -> tuple: """Convert hex color to RGB tuple (0-255 range).""" hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def color_distance(color1: str, color2: str) -> float: """ Calculate the perceptual distance between two hex colors. Returns a value 0-100 (0 = identical, 100 = maximum difference). """ if not color1 or not color2: return 100.0 try: rgb1 = hex_to_rgb(color1) rgb2 = hex_to_rgb(color2) # Simple Euclidean distance in RGB space distance = ((rgb1[0] - rgb2[0])**2 + (rgb1[1] - rgb2[1])**2 + (rgb1[2] - rgb2[2])**2) ** 0.5 # Normalize to 0-100 (max distance is sqrt(3 * 255^2) ≈ 441) return (distance / 441.67) * 100 except: return 100.0 def serialize_elements(elements: List[UIElement]) -> str: """Serialize a list of UIElements to JSON string.""" return json.dumps([e.to_dict() for e in elements], indent=2) def deserialize_elements(json_str: str) -> List[UIElement]: """Deserialize JSON string to list of UIElements.""" data = json.loads(json_str) return [UIElement.from_dict(d) for d in data]