Spaces:
Sleeping
Sleeping
| """ | |
| 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, | |
| } | |
| 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 | |
| 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} | |
| 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 | |
| } | |
| 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) | |
| ) | |
| 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) | |
| 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] | |