import type { SimplifiedNode, SimplifiedDesign, CSSStyle } from "~/types/index.js";
import { sanitizeNameForId } from "~/utils/file.js";
import { analyzeGapConsistency, roundToCommonGap } from "~/utils/css.js";
import {
detectGridLayout,
toElementRect,
filterHomogeneousForGrid,
detectOverlappingElements,
detectBackgroundElement,
type ElementRect,
type GridAnalysisResult,
} from "./detector.js";
/**
* Layout optimizer - optimizes UI layout structures
*/
export class LayoutOptimizer {
/** Container ID counter, reset on every optimizeDesign call */
private static containerIdCounter = 0;
/**
* Generate a unique container ID
*/
private static generateContainerId(name: string): string {
this.containerIdCounter++;
const sanitizedName = sanitizeNameForId(name);
return `layout-container-${this.containerIdCounter}-${sanitizedName}`;
}
/**
* Optimize the design layout structure
*
* @param design Original simplified design
* @returns Optimized design
*/
static optimizeDesign(design: SimplifiedDesign): SimplifiedDesign {
// Reset the counter to avoid accumulating across calls
this.containerIdCounter = 0;
// If no node data is present, return as is
if (!design.nodes) {
return design;
}
// Recursively optimize the node tree
const optimizedNodes = design.nodes.map((node) => this.optimizeNodeTree(node));
// Update the design
return {
...design,
nodes: optimizedNodes,
};
}
/**
* Recursively optimize the node tree
*
* @param node Node
* @returns Optimized node
*/
static optimizeNodeTree(node: SimplifiedNode): SimplifiedNode {
// Return immediately if there are no children
if (!node.children || node.children.length === 0) {
return node;
}
// Recursively process each child node
const optimizedChildren = node.children.map((child) => this.optimizeNodeTree(child));
// Analyze row/column layout for container nodes
return this.optimizeContainer({
...node,
children: optimizedChildren,
});
}
/**
* Optimize container layout
*
* Enhanced algorithm flow:
* 1. Detect overlapping elements (IoU > 0.1 → keep absolute positioning)
* 2. Run Grid detection on flow elements
* 3. Run Flex detection on flow elements
* 4. Clean child styles (remove position:absolute, left/top from flow children)
*
* @param node Container node
* @returns Optimized container node
*/
static optimizeContainer(node: SimplifiedNode): SimplifiedNode {
// Return immediately when zero or one child exists
if (!node.children || node.children.length <= 1) {
return node;
}
// Check whether this is a FRAME or GROUP container
const isContainer = node.type === "FRAME" || node.type === "GROUP";
// ===== STEP 1: OVERLAP DETECTION =====
// Detect overlapping elements that need absolute positioning
const elementRects = this.nodesToElementRects(node.children);
const overlapResult = detectOverlappingElements(elementRects, 0.1);
// Track which children need to keep absolute positioning
const stackedIndices = overlapResult.stackedIndices;
// If all elements overlap, skip layout optimization
if (overlapResult.flowElements.length < 2) {
return node;
}
// ===== STEP 1.5: BACKGROUND ELEMENT DETECTION =====
// Detect and merge background elements into parent container
const parentWidth = parseFloat(String(node.cssStyles?.width || "0").replace("px", ""));
const parentHeight = parseFloat(String(node.cssStyles?.height || "0").replace("px", ""));
let mergedBackgroundStyles: CSSStyle = {};
let filteredChildren = node.children;
let backgroundDetected = false;
if (parentWidth > 0 && parentHeight > 0) {
const bgResult = detectBackgroundElement(elementRects, parentWidth, parentHeight);
if (bgResult.hasBackground && bgResult.backgroundIndex >= 0) {
const bgChild = node.children[bgResult.backgroundIndex];
// Only merge if it's a valid background element with visual styles
if (this.isBackgroundElement(bgResult.backgroundIndex, bgResult.backgroundIndex, bgChild)) {
// Extract styles from background element
mergedBackgroundStyles = this.extractBackgroundStyles(bgChild);
// Remove background element from children
filteredChildren = node.children.filter((_, idx) => idx !== bgResult.backgroundIndex);
backgroundDetected = true;
// Update stackedIndices to account for removed element
const newStackedIndices = new Set<number>();
for (const idx of stackedIndices) {
if (idx < bgResult.backgroundIndex) {
newStackedIndices.add(idx);
} else if (idx > bgResult.backgroundIndex) {
newStackedIndices.add(idx - 1);
}
// Skip the background index itself
}
stackedIndices.clear();
for (const idx of newStackedIndices) {
stackedIndices.add(idx);
}
}
}
}
// If background was removed and only 1 child remains, return with merged styles
if (backgroundDetected && filteredChildren.length <= 1) {
return {
...node,
cssStyles: {
...node.cssStyles,
...mergedBackgroundStyles,
},
children: filteredChildren,
};
}
// ===== STEP 2: GRID DETECTION (check first before Flex) =====
// Grid is only applicable for container nodes with enough children
if (isContainer) {
const gridDetection = this.detectGridIfApplicable(filteredChildren);
if (gridDetection) {
const { gridResult, gridIndices } = gridDetection;
// Grid layout detected! Apply CSS Grid styles
const gridStyles = this.generateGridCSS(gridResult);
// Convert absolute positioning to padding/margin
// Only grid elements (gridIndices) will have their position removed
// Non-grid elements (tabs, dividers, etc.) keep their original positioning
const { parentPaddingStyle, convertedChildren } = this.convertAbsoluteToRelative(
node,
filteredChildren,
"grid",
"row", // Grid doesn't have a primary direction, use row as default
stackedIndices,
null,
gridIndices, // Only convert grid elements to flow layout
);
// Build final styles with padding and merged background
const finalStyles: CSSStyle = {
...mergedBackgroundStyles,
...gridStyles,
};
if (parentPaddingStyle) {
finalStyles.padding = parentPaddingStyle;
}
return {
...node,
cssStyles: {
...node.cssStyles,
...finalStyles,
},
children: convertedChildren,
};
}
}
// ===== STEP 3: FLEX DETECTION (fallback) =====
// Analyze child spatial relationships to determine row or column layout
const { isRow, isColumn, rowGap, columnGap, isGapConsistent, justifyContent, alignItems } =
this.analyzeLayoutDirection(filteredChildren);
// When layout is a valid row or column
if (isRow || isColumn) {
// If already a container node, add flex styles directly instead of creating another wrapper
if (isContainer) {
const direction = isRow ? "row" : "column";
const gap = isRow ? rowGap : columnGap;
// Build flex styles (omit defaults) with merged background
const flexStyles: CSSStyle = {
...mergedBackgroundStyles,
display: "flex",
};
// Only set the direction explicitly for column (row is the default)
if (direction === "column") {
flexStyles.flexDirection = direction;
}
// Only add gap when spacing is consistent and greater than zero
if (gap > 0 && isGapConsistent) {
flexStyles.gap = `${gap}px`;
}
if (justifyContent) flexStyles.justifyContent = justifyContent;
if (alignItems) flexStyles.alignItems = alignItems;
// Convert absolute positioning to padding/margin
const { parentPaddingStyle, convertedChildren } = this.convertAbsoluteToRelative(
node,
filteredChildren,
"flex",
direction,
stackedIndices,
alignItems,
);
// Add padding to flex styles if inferred
if (parentPaddingStyle) {
flexStyles.padding = parentPaddingStyle;
}
return {
...node,
cssStyles: {
...node.cssStyles,
...flexStyles,
},
children: convertedChildren,
};
}
// If not a container but children share a clear layout, create a new layout container
else {
// Determine whether the children should be grouped
const groups = this.groupChildrenByLayout(filteredChildren, isRow);
// If grouping yields one group containing all children, return the original node with flex styles
if (groups.length === 1 && groups[0].length === filteredChildren.length) {
const direction = isRow ? "row" : "column";
const gap = isRow ? rowGap : columnGap;
const flexStyles: CSSStyle = {
...mergedBackgroundStyles,
display: "flex",
};
if (direction === "column") {
flexStyles.flexDirection = direction;
}
if (gap > 0 && isGapConsistent) {
flexStyles.gap = `${gap}px`;
}
if (justifyContent) flexStyles.justifyContent = justifyContent;
if (alignItems) flexStyles.alignItems = alignItems;
// Convert absolute positioning to padding/margin
const { parentPaddingStyle, convertedChildren } = this.convertAbsoluteToRelative(
node,
filteredChildren,
"flex",
direction,
stackedIndices,
alignItems,
);
// Add padding to flex styles if inferred
if (parentPaddingStyle) {
flexStyles.padding = parentPaddingStyle;
}
return {
...node,
cssStyles: {
...node.cssStyles,
...flexStyles,
},
children: convertedChildren,
};
}
// Cases where grouping is required
const groupContainers = groups.map((group, index) => {
// Return the element itself when a group has only one member
if (group.length === 1) {
return group[0];
}
// Create a container for groups with multiple elements
const direction = isRow ? "column" : "row";
return this.createLayoutContainer(`group-${index}`, direction, group);
});
// Return the parent containing the grouped containers
const direction = isRow ? "row" : "column";
const flexStyles: CSSStyle = {
...mergedBackgroundStyles,
display: "flex",
};
if (direction === "column") {
flexStyles.flexDirection = direction;
}
if (justifyContent) flexStyles.justifyContent = justifyContent;
if (alignItems) flexStyles.alignItems = alignItems;
return {
...node,
cssStyles: {
...node.cssStyles,
...flexStyles,
},
children: groupContainers,
};
}
}
// If no clear row or column layout is detected, still apply background styles if detected
if (backgroundDetected && Object.keys(mergedBackgroundStyles).length > 0) {
return {
...node,
cssStyles: {
...node.cssStyles,
...mergedBackgroundStyles,
},
children: filteredChildren,
};
}
return node;
}
/**
* Analyze the layout direction of nodes
*/
static analyzeLayoutDirection(nodes: SimplifiedNode[]): {
isRow: boolean;
isColumn: boolean;
rowGap: number;
columnGap: number;
isGapConsistent: boolean;
justifyContent: string | null;
alignItems: string | null;
} {
const rects = nodes
.map((node) => {
if (!node.cssStyles) return null;
const left = parseFloat((node.cssStyles.left as string) || "0");
const top = parseFloat((node.cssStyles.top as string) || "0");
const width = parseFloat((node.cssStyles.width as string) || "0");
const height = parseFloat((node.cssStyles.height as string) || "0");
return { left, top, width, height };
})
.filter(
(rect): rect is { left: number; top: number; width: number; height: number } =>
rect !== null,
);
if (rects.length < 2) {
return {
isRow: false,
isColumn: false,
rowGap: 0,
columnGap: 0,
isGapConsistent: true,
justifyContent: null,
alignItems: null,
};
}
// Analyze horizontal and vertical alignment
const { horizontalAlignment, verticalAlignment, horizontalGap, verticalGap } =
this.analyzeAlignment(rects);
// Calculate confidence scores for row and column layouts
const rowScore = this.calculateRowScore(rects, horizontalAlignment, verticalAlignment);
const columnScore = this.calculateColumnScore(rects, horizontalAlignment, verticalAlignment);
// Lower the detection threshold to identify layouts more readily
const isRow = rowScore > columnScore && rowScore > 0.4;
const isColumn = columnScore > rowScore && columnScore > 0.4;
// Determine alignment (omit flex-start and stretch defaults)
let justifyContent: string | null = null;
let alignItems: string | null = null;
if (isRow) {
const jc = this.getJustifyContent(horizontalAlignment);
justifyContent = jc !== "flex-start" ? jc : null; // Skip default values
const ai = this.getAlignItems(verticalAlignment);
alignItems = ai !== "stretch" ? ai : null; // Skip default values
} else if (isColumn) {
const jc = this.getJustifyContent(verticalAlignment);
justifyContent = jc !== "flex-start" ? jc : null;
const ai = this.getAlignItems(horizontalAlignment);
alignItems = ai !== "stretch" ? ai : null;
}
// Select the gap metrics and consistency for the chosen direction
const selectedGap = isRow ? horizontalGap : verticalGap;
return {
isRow,
isColumn,
rowGap: horizontalGap.gap,
columnGap: verticalGap.gap,
isGapConsistent: selectedGap.isConsistent,
justifyContent,
alignItems,
};
}
/**
* Analyze node alignment
*/
static analyzeAlignment(rects: { left: number; top: number; width: number; height: number }[]): {
horizontalAlignment: string;
verticalAlignment: string;
horizontalGap: { gap: number; isConsistent: boolean };
verticalGap: { gap: number; isConsistent: boolean };
} {
// Calculate positions and spacing on the horizontal axis
const lefts = rects.map((rect) => rect.left);
const rights = rects.map((rect) => rect.left + rect.width);
// Calculate positions and spacing on the vertical axis
const tops = rects.map((rect) => rect.top);
const bottoms = rects.map((rect) => rect.top + rect.height);
// Evaluate horizontal alignment
const leftAligned = this.areValuesAligned(lefts);
const rightAligned = this.areValuesAligned(rights);
const centerHAligned = this.areValuesAligned(rects.map((rect) => rect.left + rect.width / 2));
// Evaluate vertical alignment
const topAligned = this.areValuesAligned(tops);
const bottomAligned = this.areValuesAligned(bottoms);
const centerVAligned = this.areValuesAligned(rects.map((rect) => rect.top + rect.height / 2));
// Determine the horizontal alignment label
let horizontalAlignment = "none";
if (leftAligned) horizontalAlignment = "left";
else if (rightAligned) horizontalAlignment = "right";
else if (centerHAligned) horizontalAlignment = "center";
// Determine the vertical alignment label
let verticalAlignment = "none";
if (topAligned) verticalAlignment = "top";
else if (bottomAligned) verticalAlignment = "bottom";
else if (centerVAligned) verticalAlignment = "center";
// Compute the average gap (with consistency checks)
const horizontalGap = this.calculateAverageGap(rects, "horizontal");
const verticalGap = this.calculateAverageGap(rects, "vertical");
return {
horizontalAlignment,
verticalAlignment,
horizontalGap,
verticalGap,
};
}
/**
* Check whether a set of values aligns within a tolerance
*/
static areValuesAligned(values: number[], tolerance: number = 2): boolean {
if (values.length < 2) return true;
const firstValue = values[0];
return values.every((value) => Math.abs(value - firstValue) <= tolerance);
}
/**
* Compute the average gap (with consistency checks)
* @returns { gap: number, isConsistent: boolean }
*/
static calculateAverageGap(
rects: { left: number; top: number; width: number; height: number }[],
direction: "horizontal" | "vertical",
): { gap: number; isConsistent: boolean } {
if (rects.length < 2) return { gap: 0, isConsistent: true };
// Sort nodes
const sortedRects = [...rects].sort((a, b) => {
if (direction === "horizontal") {
return a.left - b.left;
} else {
return a.top - b.top;
}
});
// Calculate gaps between adjacent nodes
const gaps: number[] = [];
for (let i = 0; i < sortedRects.length - 1; i++) {
const current = sortedRects[i];
const next = sortedRects[i + 1];
if (direction === "horizontal") {
const gap = next.left - (current.left + current.width);
if (gap > 0) gaps.push(gap);
} else {
const gap = next.top - (current.top + current.height);
if (gap > 0) gaps.push(gap);
}
}
// Use gap consistency analysis
if (gaps.length === 0) return { gap: 0, isConsistent: true };
const analysis = analyzeGapConsistency(gaps);
// Round to a common value
const roundedGap = roundToCommonGap(analysis.averageGap);
return {
gap: roundedGap,
isConsistent: analysis.isConsistent,
};
}
/**
* Calculate the confidence score for a row layout
*/
static calculateRowScore(
rects: { left: number; top: number; width: number; height: number }[],
horizontalAlignment: string,
verticalAlignment: string,
): number {
if (rects.length < 2) return 0;
// Sort nodes
const sortedByLeft = [...rects].sort((a, b) => a.left - b.left);
// Calculate horizontal gaps between adjacent nodes
let consecutiveHorizontalGaps = 0;
for (let i = 0; i < sortedByLeft.length - 1; i++) {
const current = sortedByLeft[i];
const next = sortedByLeft[i + 1];
const gap = next.left - (current.left + current.width);
if (gap >= 0 && gap <= 50) consecutiveHorizontalGaps++;
}
// Calculate the uniformity of horizontal distribution
const horizontalDistribution = consecutiveHorizontalGaps / (sortedByLeft.length - 1);
// Vertical alignment increases the score
const verticalAlignmentScore = verticalAlignment !== "none" ? 0.3 : 0;
// Combine into a final score
return horizontalDistribution * 0.7 + verticalAlignmentScore;
}
/**
* Calculate the confidence score for a column layout
*/
static calculateColumnScore(
rects: { left: number; top: number; width: number; height: number }[],
horizontalAlignment: string,
_verticalAlignment: string,
): number {
if (rects.length < 2) return 0;
// Sort nodes
const sortedByTop = [...rects].sort((a, b) => a.top - b.top);
// Calculate vertical gaps between adjacent nodes
let consecutiveVerticalGaps = 0;
for (let i = 0; i < sortedByTop.length - 1; i++) {
const current = sortedByTop[i];
const next = sortedByTop[i + 1];
const gap = next.top - (current.top + current.height);
if (gap >= 0 && gap <= 50) consecutiveVerticalGaps++;
}
// Calculate the uniformity of vertical distribution
const verticalDistribution = consecutiveVerticalGaps / (sortedByTop.length - 1);
// Horizontal alignment increases the score
const horizontalAlignmentScore = horizontalAlignment !== "none" ? 0.3 : 0;
// Combine into a final score
return verticalDistribution * 0.7 + horizontalAlignmentScore;
}
/**
* Group child nodes based on layout characteristics
*/
static groupChildrenByLayout(nodes: SimplifiedNode[], isRow: boolean): SimplifiedNode[][] {
if (nodes.length <= 1) return [nodes];
// Extract positional information for nodes
const rects = nodes
.map((node, index) => {
if (!node.cssStyles) return null;
const left = parseFloat((node.cssStyles.left as string) || "0");
const top = parseFloat((node.cssStyles.top as string) || "0");
const width = parseFloat((node.cssStyles.width as string) || "0");
const height = parseFloat((node.cssStyles.height as string) || "0");
return { index, left, top, width, height };
})
.filter(
(
rect,
): rect is { index: number; left: number; top: number; width: number; height: number } =>
rect !== null,
);
// Sort according to the layout direction
const sortedRects = [...rects].sort((a, b) => {
if (isRow) {
return a.left - b.left;
} else {
return a.top - b.top;
}
});
// Look for potential grouping breakpoints
const groups: SimplifiedNode[][] = [];
let currentGroup: SimplifiedNode[] = [nodes[sortedRects[0].index]];
for (let i = 1; i < sortedRects.length; i++) {
const current = sortedRects[i - 1];
const next = sortedRects[i];
let shouldSplit = false;
if (isRow) {
// In a row layout, look for noticeable vertical shifts
if (Math.abs(next.top - current.top) > 20) {
shouldSplit = true;
}
} else {
// In a column layout, look for noticeable horizontal shifts
if (Math.abs(next.left - current.left) > 20) {
shouldSplit = true;
}
}
if (shouldSplit) {
// End the current group and start a new one
groups.push(currentGroup);
currentGroup = [nodes[next.index]];
} else {
// Keep adding to the current group
currentGroup.push(nodes[next.index]);
}
}
// Add the final group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups;
}
/**
* Convert justifyContent alignment into CSS values
*/
static getJustifyContent(alignment: string): string | null {
switch (alignment) {
case "left":
case "top":
return "flex-start";
case "right":
case "bottom":
return "flex-end";
case "center":
return "center";
default:
return "space-between";
}
}
/**
* Convert alignItems alignment into CSS values
*/
static getAlignItems(alignment: string): string | null {
switch (alignment) {
case "left":
case "top":
return "flex-start";
case "right":
case "bottom":
return "flex-end";
case "center":
return "center";
default:
return null;
}
}
/**
* Create a layout container node
*/
static createLayoutContainer(
name: string,
direction: "row" | "column",
children: SimplifiedNode[],
): SimplifiedNode {
// Calculate the container bounding box
let minLeft = Infinity;
let minTop = Infinity;
let maxRight = -Infinity;
let maxBottom = -Infinity;
// Find the minimal bounding rectangle of all children
children.forEach((child) => {
if (!child.cssStyles) return;
const left = parseFloat((child.cssStyles.left as string) || "0");
const top = parseFloat((child.cssStyles.top as string) || "0");
const width = parseFloat((child.cssStyles.width as string) || "0");
const height = parseFloat((child.cssStyles.height as string) || "0");
minLeft = Math.min(minLeft, left);
minTop = Math.min(minTop, top);
maxRight = Math.max(maxRight, left + width);
maxBottom = Math.max(maxBottom, top + height);
});
// Calculate alignment
const { justifyContent, alignItems } = this.analyzeLayoutDirection(children);
// Return an empty container if no valid child nodes exist
if (
minLeft === Infinity ||
minTop === Infinity ||
maxRight === -Infinity ||
maxBottom === -Infinity
) {
return {
id: this.generateContainerId(name),
name: `Layout Container ${name}`,
type: "FRAME",
cssStyles: {
display: "flex",
flexDirection: direction,
width: "100%",
height: "auto",
},
children,
};
}
// Set container styles and position
return {
id: this.generateContainerId(name),
name: `Layout Container ${name}`,
type: "FRAME",
cssStyles: {
display: "flex",
flexDirection: direction,
position: "absolute",
left: `${minLeft}px`,
top: `${minTop}px`,
width: `${maxRight - minLeft}px`,
height: `${maxBottom - minTop}px`,
...(justifyContent ? { justifyContent } : {}),
...(alignItems ? { alignItems } : {}),
},
children,
};
}
/**
* Calculate variance
*/
static calculateVariance(values: number[]): number {
if (values.length <= 1) return 0;
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const squaredDiffs = values.map((v) => Math.pow(v - mean, 2));
return squaredDiffs.reduce((sum, sq) => sum + sq, 0) / values.length;
}
// The following reimplements the analysisHorizontalLayout and analysisVerticalLayout methods
// These methods now pull geometry directly from the node array instead of using the external extractElementRects helper
/**
* Helper method: extract element geometry
*/
static extractElementRects(elements: SimplifiedNode[]): Array<{
index: number;
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
centerX: number;
centerY: number;
}> {
return elements
.map((element, index) => {
if (!element.cssStyles) return null;
const left = parseFloat((element.cssStyles.left as string) || "0");
const top = parseFloat((element.cssStyles.top as string) || "0");
const width = parseFloat((element.cssStyles.width as string) || "0");
const height = parseFloat((element.cssStyles.height as string) || "0");
const right = left + width;
const bottom = top + height;
const centerX = left + width / 2;
const centerY = top + height / 2;
return { index, left, top, right, bottom, width, height, centerX, centerY };
})
.filter((rect) => rect !== null) as Array<{
index: number;
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
centerX: number;
centerY: number;
}>;
}
/**
* Convert SimplifiedNode[] to ElementRect[] for Grid detection
*/
static nodesToElementRects(nodes: SimplifiedNode[]): ElementRect[] {
return nodes
.map((node, index) => {
if (!node.cssStyles) return null;
const x = parseFloat((node.cssStyles.left as string) || "0");
const y = parseFloat((node.cssStyles.top as string) || "0");
const width = parseFloat((node.cssStyles.width as string) || "0");
const height = parseFloat((node.cssStyles.height as string) || "0");
// Use toElementRect to create proper ElementRect with computed properties
return toElementRect({ x, y, width, height }, index);
})
.filter((rect): rect is ElementRect => rect !== null);
}
/**
* Generate CSS Grid styles from GridAnalysisResult
*/
static generateGridCSS(gridResult: GridAnalysisResult): Record<string, string> {
const css: Record<string, string> = {
display: "grid",
};
// grid-template-columns
if (gridResult.trackWidths.length > 0) {
css.gridTemplateColumns = gridResult.trackWidths.map((w) => `${w}px`).join(" ");
}
// grid-template-rows (only if heights vary significantly)
if (gridResult.trackHeights.length > 0) {
const heights = gridResult.trackHeights;
const avgHeight = heights.reduce((a, b) => a + b, 0) / heights.length;
const allSimilar = heights.every((h) => Math.abs(h - avgHeight) < 5);
// Only set explicit row heights if they vary
if (!allSimilar) {
css.gridTemplateRows = heights.map((h) => `${h}px`).join(" ");
}
}
// Gap handling
if (gridResult.rowGap > 0 || gridResult.columnGap > 0) {
if (gridResult.rowGap === gridResult.columnGap && gridResult.rowGap > 0) {
css.gap = `${gridResult.rowGap}px`;
} else {
if (gridResult.rowGap > 0 && gridResult.columnGap > 0) {
css.gap = `${gridResult.rowGap}px ${gridResult.columnGap}px`;
} else if (gridResult.rowGap > 0) {
css.rowGap = `${gridResult.rowGap}px`;
} else if (gridResult.columnGap > 0) {
css.columnGap = `${gridResult.columnGap}px`;
}
}
}
return css;
}
/**
* Result of grid detection including which elements belong to the grid
*/
static detectGridIfApplicable(nodes: SimplifiedNode[]): {
gridResult: GridAnalysisResult;
gridIndices: Set<number>;
} | null {
// Need at least 4 elements for a meaningful grid (2x2)
if (nodes.length < 4) return null;
const elementRects = this.nodesToElementRects(nodes);
if (elementRects.length < 4) return null;
// Extract node types for homogeneity check
const nodeTypes = nodes.map((n) => n.type);
// Filter to only homogeneous elements (similar size/type)
// This prevents mixed layouts from being detected as grids
const filterResult = filterHomogeneousForGrid(elementRects, nodeTypes);
// If not enough homogeneous elements, skip grid detection
if (filterResult.elements.length < 4) {
return null;
}
// Run grid detection on homogeneous elements only
const gridResult = detectGridLayout(filterResult.elements);
// Only use grid if:
// 1. Grid is detected (isGrid: true)
// 2. Confidence is high enough (>= 0.6)
// 3. Has multiple rows (grid is 2D, not just a single row)
// 4. Has multiple columns
if (
gridResult.isGrid &&
gridResult.confidence >= 0.6 &&
gridResult.rowCount >= 2 &&
gridResult.columnCount >= 2
) {
return {
gridResult,
gridIndices: filterResult.gridIndices,
};
}
return null;
}
/**
* Analyze horizontal layout characteristics
*/
static analyzeHorizontalLayout(
rects: ReturnType<typeof LayoutOptimizer.extractElementRects>,
bounds: {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
},
): {
distributionScore: number;
alignmentScore: number;
leftAligned: boolean;
rightAligned: boolean;
centerAligned: boolean;
averageGap: number;
gapConsistency: number;
gaps: number[];
} {
// Sort by left edges
const sortedByLeft = [...rects].sort((a, b) => a.left - b.left);
// Calculate horizontal gaps
const gaps: number[] = [];
let consecutiveGaps = 0;
let totalGapWidth = 0;
for (let i = 0; i < sortedByLeft.length - 1; i++) {
const current = sortedByLeft[i];
const next = sortedByLeft[i + 1];
const gap = next.left - current.right;
if (gap >= 0) {
gaps.push(gap);
totalGapWidth += gap;
consecutiveGaps++;
}
}
// Calculate the horizontal distribution score
const distributionScore = consecutiveGaps / (sortedByLeft.length - 1);
// Analyze horizontal alignment
const lefts = sortedByLeft.map((r) => r.left);
const rights = sortedByLeft.map((r) => r.right);
const centers = sortedByLeft.map((r) => r.centerX);
// Calculate alignment tolerance relative to the container width
const relativeTolerance = Math.max(5, bounds.width * 0.01); // At least 5px or 1% of the container width
const leftAligned = this.areValuesAligned(lefts, relativeTolerance);
const rightAligned = this.areValuesAligned(rights, relativeTolerance);
const centerAligned = this.areValuesAligned(centers, relativeTolerance);
// Calculate the alignment score
const alignmentScore = leftAligned || rightAligned || centerAligned ? 0.5 : 0;
// Calculate the average gap
const averageGap = gaps.length > 0 ? totalGapWidth / gaps.length : 0;
// Calculate gap consistency: the smaller the variance, the higher the consistency
const gapConsistency =
gaps.length > 1 ? 1 - this.calculateVariance(gaps) / (averageGap * averageGap + 0.1) : 0;
return {
distributionScore,
alignmentScore,
leftAligned,
rightAligned,
centerAligned,
averageGap,
gapConsistency,
gaps,
};
}
/**
* Analyze vertical layout characteristics
*/
static analyzeVerticalLayout(
rects: ReturnType<typeof LayoutOptimizer.extractElementRects>,
bounds: {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
},
): {
distributionScore: number;
alignmentScore: number;
topAligned: boolean;
bottomAligned: boolean;
centerAligned: boolean;
averageGap: number;
gapConsistency: number;
gaps: number[];
} {
// Sort by top edges
const sortedByTop = [...rects].sort((a, b) => a.top - b.top);
// Calculate vertical gaps
const gaps: number[] = [];
let consecutiveGaps = 0;
let totalGapHeight = 0;
for (let i = 0; i < sortedByTop.length - 1; i++) {
const current = sortedByTop[i];
const next = sortedByTop[i + 1];
const gap = next.top - current.bottom;
if (gap >= 0) {
gaps.push(gap);
totalGapHeight += gap;
consecutiveGaps++;
}
}
// Calculate the vertical distribution score
const distributionScore = consecutiveGaps / (sortedByTop.length - 1);
// Analyze vertical alignment
const tops = sortedByTop.map((r) => r.top);
const bottoms = sortedByTop.map((r) => r.bottom);
const centers = sortedByTop.map((r) => r.centerY);
// Calculate alignment tolerance relative to the container height
const relativeTolerance = Math.max(5, bounds.height * 0.01); // At least 5px or 1% of the container height
const topAligned = this.areValuesAligned(tops, relativeTolerance);
const bottomAligned = this.areValuesAligned(bottoms, relativeTolerance);
const centerAligned = this.areValuesAligned(centers, relativeTolerance);
// Calculate the alignment score
const alignmentScore = topAligned || bottomAligned || centerAligned ? 0.5 : 0;
// Calculate the average gap
const averageGap = gaps.length > 0 ? totalGapHeight / gaps.length : 0;
// Calculate gap consistency
const gapConsistency =
gaps.length > 1 ? 1 - this.calculateVariance(gaps) / (averageGap * averageGap + 0.1) : 0;
return {
distributionScore,
alignmentScore,
topAligned,
bottomAligned,
centerAligned,
averageGap,
gapConsistency,
gaps,
};
}
/**
* Calculate bounds
*/
static calculateBounds(rects: ReturnType<typeof LayoutOptimizer.extractElementRects>) {
const left = Math.min(...rects.map((r) => r.left));
const top = Math.min(...rects.map((r) => r.top));
const right = Math.max(...rects.map((r) => r.right));
const bottom = Math.max(...rects.map((r) => r.bottom));
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
};
}
/**
* Generate flex properties based on layout characteristics
*/
static generateFlexProperties(
isRow: boolean,
mainAxisInfo:
| ReturnType<typeof LayoutOptimizer.analyzeHorizontalLayout>
| ReturnType<typeof LayoutOptimizer.analyzeVerticalLayout>,
crossAxisInfo:
| ReturnType<typeof LayoutOptimizer.analyzeHorizontalLayout>
| ReturnType<typeof LayoutOptimizer.analyzeVerticalLayout>,
): Record<string, string> {
const properties: Record<string, string> = {
flexDirection: isRow ? "row" : "column",
};
// Set gap values
if (mainAxisInfo.averageGap > 0) {
properties.gap = `${Math.round(mainAxisInfo.averageGap)}px`;
}
// Set main-axis alignment
let justifyContent = "flex-start";
if (isRow) {
// For row layouts, handle horizontal alignment
const horizontalInfo = mainAxisInfo as ReturnType<
typeof LayoutOptimizer.analyzeHorizontalLayout
>;
if (horizontalInfo.rightAligned) {
justifyContent = "flex-end";
} else if (horizontalInfo.centerAligned) {
justifyContent = "center";
} else if (horizontalInfo.gaps.length > 0 && horizontalInfo.gapConsistency > 0.7) {
justifyContent = "space-between";
}
} else {
// For column layouts, handle vertical alignment
const verticalInfo = mainAxisInfo as ReturnType<typeof LayoutOptimizer.analyzeVerticalLayout>;
if (verticalInfo.bottomAligned) {
justifyContent = "flex-end";
} else if (verticalInfo.centerAligned) {
justifyContent = "center";
} else if (verticalInfo.gaps.length > 0 && verticalInfo.gapConsistency > 0.7) {
justifyContent = "space-between";
}
}
properties.justifyContent = justifyContent;
// Set cross-axis alignment
let alignItems = "flex-start";
if (isRow) {
// For row layouts, handle vertical alignment
const verticalInfo = crossAxisInfo as ReturnType<
typeof LayoutOptimizer.analyzeVerticalLayout
>;
if (verticalInfo.bottomAligned) {
alignItems = "flex-end";
} else if (verticalInfo.centerAligned) {
alignItems = "center";
}
} else {
// For column layouts, handle horizontal alignment
const horizontalInfo = crossAxisInfo as ReturnType<
typeof LayoutOptimizer.analyzeHorizontalLayout
>;
if (horizontalInfo.rightAligned) {
alignItems = "flex-end";
} else if (horizontalInfo.centerAligned) {
alignItems = "center";
}
}
properties.alignItems = alignItems;
return properties;
}
// ==================== Child Style Cleanup ====================
/**
* CSS properties that are default values and can be removed
*/
private static CSS_DEFAULT_VALUES: Record<string, string[]> = {
fontWeight: ["400", "normal"],
textAlign: ["left", "start"],
flexDirection: ["row"],
position: ["static"],
opacity: ["1"],
backgroundColor: ["transparent", "rgba(0, 0, 0, 0)", "rgba(0,0,0,0)"],
borderWidth: ["0", "0px"],
borderStyle: ["none"],
overflow: ["visible"],
visibility: ["visible"],
zIndex: ["auto"],
};
/**
* CSS properties that should be removed from children when parent becomes flex/grid
*/
private static ABSOLUTE_POSITION_PROPERTIES = ["position", "left", "top", "right", "bottom"];
/**
* Clean child element styles when parent becomes a flex/grid container
*
* When a parent container is converted to flex or grid layout:
* 1. Remove position: absolute from children (they now flow naturally)
* 2. Remove left/top positioning (handled by flex/grid)
* 3. Keep width/height (used for flex-basis or explicit sizing)
*
* @param child Child node to clean
* @param layoutType The layout type of the parent ('flex' | 'grid')
* @returns Cleaned child node
*/
static cleanChildStylesForLayout(
child: SimplifiedNode,
layoutType: "flex" | "grid",
): SimplifiedNode {
if (!child.cssStyles) return child;
const cleanedStyles: CSSStyle = { ...child.cssStyles };
// Remove absolute positioning properties when parent is flex/grid
// These are no longer needed as the child now flows in the layout
if (cleanedStyles.position === "absolute") {
delete cleanedStyles.position;
}
// Remove left/top positioning (now handled by flex/grid)
delete cleanedStyles.left;
delete cleanedStyles.top;
// For grid layout, also remove right/bottom as grid handles placement
if (layoutType === "grid") {
delete cleanedStyles.right;
delete cleanedStyles.bottom;
}
return {
...child,
cssStyles: cleanedStyles,
};
}
/**
* Remove CSS default values from styles to reduce output size
*
* @param styles CSS styles object
* @returns Cleaned styles with defaults removed
*/
static removeDefaultValues(styles: CSSStyle): CSSStyle {
const cleaned: CSSStyle = {};
for (const [key, value] of Object.entries(styles)) {
if (value === undefined || value === null || value === "") {
continue; // Skip empty values
}
const defaults = this.CSS_DEFAULT_VALUES[key];
if (defaults && defaults.includes(String(value))) {
continue; // Skip default value
}
// Skip "0px" values for positioning properties (common default)
if (
(key === "left" || key === "top" || key === "right" || key === "bottom") &&
(value === "0px" || value === "0")
) {
continue;
}
cleaned[key] = value;
}
return cleaned;
}
/**
* Clean all children styles when parent becomes flex/grid
*
* @param children Child nodes
* @param layoutType Parent layout type
* @param stackedIndices Indices of children that should remain absolute (overlapping)
* @returns Cleaned children
*/
static cleanChildrenStyles(
children: SimplifiedNode[],
layoutType: "flex" | "grid",
stackedIndices?: Set<number>,
): SimplifiedNode[] {
return children.map((child, index) => {
// Skip cleaning for stacked (overlapping) elements - they keep absolute positioning
if (stackedIndices && stackedIndices.has(index)) {
return child;
}
// Clean styles for flow elements
const cleaned = this.cleanChildStylesForLayout(child, layoutType);
// Also remove default values
if (cleaned.cssStyles) {
cleaned.cssStyles = this.removeDefaultValues(cleaned.cssStyles);
}
return cleaned;
});
}
// ==================== Absolute to Relative Position Conversion ====================
/**
* Collect position offsets from flow children before cleaning
*
* @param children All child nodes
* @param stackedIndices Indices of stacked elements to skip
* @returns Array of offset info for flow children only
*/
static collectFlowChildOffsets(
children: SimplifiedNode[],
stackedIndices: Set<number>,
): Array<{
index: number;
left: number;
top: number;
width: number;
height: number;
right: number;
bottom: number;
}> {
const offsets: Array<{
index: number;
left: number;
top: number;
width: number;
height: number;
right: number;
bottom: number;
}> = [];
children.forEach((child, index) => {
// Skip stacked elements
if (stackedIndices.has(index)) return;
if (!child.cssStyles) return;
const left = parseFloat((child.cssStyles.left as string) || "0");
const top = parseFloat((child.cssStyles.top as string) || "0");
const width = parseFloat((child.cssStyles.width as string) || "0");
const height = parseFloat((child.cssStyles.height as string) || "0");
offsets.push({
index,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
});
});
return offsets;
}
/**
* Infer container padding from flow children offsets
*
* Algorithm:
* - paddingTop = minimum top offset of all flow children
* - paddingLeft = minimum left offset of all flow children
* - paddingRight = parentWidth - maximum right edge of children
* - paddingBottom = parentHeight - maximum bottom edge of children
*
* @param offsets Flow children offset information
* @param parentWidth Parent container width
* @param parentHeight Parent container height
* @param layoutDirection 'row' or 'column'
* @returns Inferred padding values
*/
static inferContainerPadding(
offsets: Array<{
index: number;
left: number;
top: number;
width: number;
height: number;
right: number;
bottom: number;
}>,
parentWidth: number,
parentHeight: number,
_layoutDirection: "row" | "column", // Reserved for future direction-specific logic
): {
paddingTop: number;
paddingRight: number;
paddingBottom: number;
paddingLeft: number;
} {
if (offsets.length === 0) {
return { paddingTop: 0, paddingRight: 0, paddingBottom: 0, paddingLeft: 0 };
}
// Calculate min/max bounds from all flow children
const minLeft = Math.min(...offsets.map((o) => o.left));
const minTop = Math.min(...offsets.map((o) => o.top));
const maxRight = Math.max(...offsets.map((o) => o.right));
const maxBottom = Math.max(...offsets.map((o) => o.bottom));
// Calculate padding (with tolerance for small values)
const paddingLeft = minLeft > 2 ? Math.round(minLeft) : 0;
const paddingTop = minTop > 2 ? Math.round(minTop) : 0;
const paddingRight = parentWidth - maxRight > 2 ? Math.round(parentWidth - maxRight) : 0;
const paddingBottom = parentHeight - maxBottom > 2 ? Math.round(parentHeight - maxBottom) : 0;
return { paddingTop, paddingRight, paddingBottom, paddingLeft };
}
/**
* Calculate individual margin adjustments for children based on their cross-axis position
*
* For row layout: calculate marginTop based on vertical offset from baseline
* For column layout: calculate marginLeft based on horizontal offset from baseline
*
* @param offsets Flow children offset information
* @param padding Inferred container padding
* @param layoutDirection 'row' or 'column'
* @param alignItems The alignItems value ('flex-start', 'center', 'flex-end')
* @returns Map of child index to margin adjustments
*/
static calculateChildMargins(
offsets: Array<{
index: number;
left: number;
top: number;
width: number;
height: number;
right: number;
bottom: number;
}>,
padding: {
paddingTop: number;
paddingRight: number;
paddingBottom: number;
paddingLeft: number;
},
layoutDirection: "row" | "column",
alignItems: string | null,
): Map<number, { marginTop?: number; marginLeft?: number }> {
const margins = new Map<number, { marginTop?: number; marginLeft?: number }>();
if (offsets.length === 0) return margins;
if (layoutDirection === "row") {
// For row layout, check vertical (cross-axis) alignment
// Baseline is paddingTop for flex-start
const baseline = padding.paddingTop;
for (const offset of offsets) {
const verticalOffset = offset.top - baseline;
// Only add margin if there's a meaningful offset (> 2px tolerance)
if (alignItems === "flex-start" && verticalOffset > 2) {
margins.set(offset.index, { marginTop: Math.round(verticalOffset) });
}
}
} else {
// For column layout, check horizontal (cross-axis) alignment
// Baseline is paddingLeft for flex-start
const baseline = padding.paddingLeft;
for (const offset of offsets) {
const horizontalOffset = offset.left - baseline;
// Only add margin if there's a meaningful offset (> 2px tolerance)
if (alignItems === "flex-start" && horizontalOffset > 2) {
margins.set(offset.index, { marginLeft: Math.round(horizontalOffset) });
}
}
}
return margins;
}
/**
* Generate CSS padding string from padding values
*
* Uses shorthand when possible:
* - All same: "10px"
* - Top/bottom same, left/right same: "10px 20px"
* - All different: "10px 20px 30px 40px"
*/
static generatePaddingCSS(padding: {
paddingTop: number;
paddingRight: number;
paddingBottom: number;
paddingLeft: number;
}): string | null {
const { paddingTop, paddingRight, paddingBottom, paddingLeft } = padding;
// If all padding is 0, return null (no padding needed)
if (paddingTop === 0 && paddingRight === 0 && paddingBottom === 0 && paddingLeft === 0) {
return null;
}
// All same
if (
paddingTop === paddingRight &&
paddingRight === paddingBottom &&
paddingBottom === paddingLeft
) {
return `${paddingTop}px`;
}
// Top/bottom same, left/right same
if (paddingTop === paddingBottom && paddingLeft === paddingRight) {
return `${paddingTop}px ${paddingLeft}px`;
}
// Left/right same
if (paddingLeft === paddingRight) {
return `${paddingTop}px ${paddingLeft}px ${paddingBottom}px`;
}
// All different
return `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px`;
}
/**
* Convert absolute positioning to relative positioning with padding/margin
*
* This is the main orchestration function that:
* 1. Collects flow child offsets before cleaning
* 2. Infers container padding from child positions
* 3. Calculates individual child margins for cross-axis alignment
* 4. Returns updated parent styles and cleaned children with margins
*
* @param parent Parent node
* @param children Child nodes
* @param layoutType 'flex' | 'grid'
* @param layoutDirection 'row' | 'column'
* @param stackedIndices Indices of stacked (overlapping) elements
* @param alignItems The alignItems value for cross-axis alignment
* @param flowIndices Optional set of indices that should be converted to flow layout.
* If not provided, all non-stacked elements are converted.
* For grid layouts, only grid elements should be in this set.
* @returns Updated parent styles and children with converted positioning
*/
static convertAbsoluteToRelative(
parent: SimplifiedNode,
children: SimplifiedNode[],
layoutType: "flex" | "grid",
layoutDirection: "row" | "column",
stackedIndices: Set<number>,
alignItems: string | null,
flowIndices?: Set<number>,
): {
parentPaddingStyle: string | null;
convertedChildren: SimplifiedNode[];
} {
// Determine which elements should be converted to flow
// If flowIndices is provided, only those elements are converted
// Otherwise, all non-stacked elements are converted (backward compatible)
const shouldConvertToFlow = (index: number): boolean => {
if (stackedIndices.has(index)) return false;
if (flowIndices) return flowIndices.has(index);
return true;
};
// Step 1: Collect flow child offsets before cleaning (only for flow elements)
const flowOnlyStackedIndices = new Set<number>();
children.forEach((_, index) => {
if (!shouldConvertToFlow(index)) {
flowOnlyStackedIndices.add(index);
}
});
const offsets = this.collectFlowChildOffsets(children, flowOnlyStackedIndices);
if (offsets.length === 0) {
// No flow elements, return children with only flow elements cleaned
const convertedChildren = children.map((child, index) => {
if (!shouldConvertToFlow(index)) {
return child; // Keep non-flow elements unchanged
}
return this.cleanChildStylesForLayout(child, layoutType);
});
return {
parentPaddingStyle: null,
convertedChildren,
};
}
// Get parent dimensions
const parentWidth = parseFloat((parent.cssStyles?.width as string) || "0");
const parentHeight = parseFloat((parent.cssStyles?.height as string) || "0");
// Step 2: Infer container padding (only from flow elements)
const padding = this.inferContainerPadding(offsets, parentWidth, parentHeight, layoutDirection);
// Step 3: Calculate individual child margins for cross-axis alignment
const childMargins = this.calculateChildMargins(offsets, padding, layoutDirection, alignItems);
// Step 4: Generate padding CSS string
const paddingCSS = this.generatePaddingCSS(padding);
// Step 5: Clean children and apply margins
const convertedChildren = children.map((child, index) => {
// Non-flow elements keep their original positioning
if (!shouldConvertToFlow(index)) {
return child;
}
// Clean absolute positioning styles for flow elements
let cleaned = this.cleanChildStylesForLayout(child, layoutType);
// Apply calculated margins if any
const marginAdjustment = childMargins.get(index);
if (marginAdjustment && cleaned.cssStyles) {
const updatedStyles: CSSStyle = { ...cleaned.cssStyles };
if (marginAdjustment.marginTop && marginAdjustment.marginTop > 0) {
updatedStyles.marginTop = `${marginAdjustment.marginTop}px`;
}
if (marginAdjustment.marginLeft && marginAdjustment.marginLeft > 0) {
updatedStyles.marginLeft = `${marginAdjustment.marginLeft}px`;
}
cleaned = { ...cleaned, cssStyles: updatedStyles };
}
// Remove default values
if (cleaned.cssStyles) {
cleaned.cssStyles = this.removeDefaultValues(cleaned.cssStyles);
}
return cleaned;
});
return {
parentPaddingStyle: paddingCSS,
convertedChildren,
};
}
/**
* Extract visual styles from a background element that can be merged into parent
*
* Background-compatible styles: backgroundColor, background, backgroundImage,
* borderRadius, border, boxShadow, opacity
*
* @param bgChild Background element
* @returns Styles to merge into parent
*/
static extractBackgroundStyles(bgChild: SimplifiedNode): CSSStyle {
const mergedStyles: CSSStyle = {};
if (!bgChild.cssStyles) return mergedStyles;
const bgStyles = bgChild.cssStyles;
// Background colors
if (bgStyles.backgroundColor) {
mergedStyles.backgroundColor = bgStyles.backgroundColor;
}
if (bgStyles.background) {
mergedStyles.background = bgStyles.background;
}
if (bgStyles.backgroundImage) {
mergedStyles.backgroundImage = bgStyles.backgroundImage;
}
// Border radius
if (bgStyles.borderRadius) {
mergedStyles.borderRadius = bgStyles.borderRadius;
}
// Border
if (bgStyles.border) {
mergedStyles.border = bgStyles.border;
}
if (bgStyles.borderWidth) {
mergedStyles.borderWidth = bgStyles.borderWidth;
}
if (bgStyles.borderStyle) {
mergedStyles.borderStyle = bgStyles.borderStyle;
}
if (bgStyles.borderColor) {
mergedStyles.borderColor = bgStyles.borderColor;
}
// Box shadow
if (bgStyles.boxShadow) {
mergedStyles.boxShadow = bgStyles.boxShadow;
}
// Opacity (only if not 1)
if (bgStyles.opacity && bgStyles.opacity !== "1") {
mergedStyles.opacity = bgStyles.opacity;
}
return mergedStyles;
}
/**
* Check if a child element is a background element based on detection result
*
* @param childIndex Index of the child
* @param backgroundIndex Index of detected background element (-1 if none)
* @param child The child node to check
* @returns true if this is a background element
*/
static isBackgroundElement(
childIndex: number,
backgroundIndex: number,
child: SimplifiedNode,
): boolean {
// Must match the detected background index
if (childIndex !== backgroundIndex) return false;
// Must be a visual element (RECTANGLE, FRAME, or ELLIPSE)
const bgTypes = ["RECTANGLE", "FRAME", "ELLIPSE"];
if (!bgTypes.includes(child.type)) return false;
// Must have some visual styles to merge
if (!child.cssStyles) return false;
const hasVisualStyles =
child.cssStyles.backgroundColor ||
child.cssStyles.background ||
child.cssStyles.backgroundImage ||
child.cssStyles.borderRadius ||
child.cssStyles.border ||
child.cssStyles.boxShadow;
return Boolean(hasVisualStyles);
}
}