diff --git a/app/figma-plugin/src/main.ts b/app/figma-plugin/src/main.ts index 12b37221..31c20596 100644 --- a/app/figma-plugin/src/main.ts +++ b/app/figma-plugin/src/main.ts @@ -75,6 +75,40 @@ interface AnalysisNode { style?: Record; devStatus?: { type: string; description?: string }; isAsset?: boolean; + + // Responsive / size constraints + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + layoutGrow?: 0 | 1; + constraints?: { horizontal: string; vertical: string }; + + // Wrap + layoutWrap?: string; + counterAxisSpacing?: number; + counterAxisAlignContent?: string; + + // Grid layout (container) + gridRowCount?: number; + gridColumnCount?: number; + gridRowGap?: number; + gridColumnGap?: number; + gridColumnsSizing?: string; + gridRowsSizing?: string; + + // Grid layout (child) + gridChildHorizontalAlign?: string; + gridChildVerticalAlign?: string; + gridRowSpan?: number; + gridColumnSpan?: number; + gridRowAnchorIndex?: number; + gridColumnAnchorIndex?: number; + + // Overflow / clip + clipsContent?: boolean; + overflowDirection?: string; + children?: AnalysisNode[]; } @@ -94,6 +128,24 @@ interface AnalysisFile { >; } +// ---- Plugin → REST API constraint enum conversion ---- + +const HORIZONTAL_CONSTRAINT_MAP: Record = { + MIN: "LEFT", + CENTER: "CENTER", + MAX: "RIGHT", + STRETCH: "LEFT_RIGHT", + SCALE: "SCALE", +}; + +const VERTICAL_CONSTRAINT_MAP: Record = { + MIN: "TOP", + CENTER: "CENTER", + MAX: "BOTTOM", + STRETCH: "TOP_BOTTOM", + SCALE: "SCALE", +}; + // ---- Transform Figma Plugin nodes to AnalysisNode ---- function transformPluginNode(node: SceneNode): AnalysisNode { @@ -137,6 +189,83 @@ function transformPluginNode(node: SceneNode): AnalysisNode { } } + // Responsive / size constraints + if (hasAutoLayout(node)) { + if ("minWidth" in node && typeof node.minWidth === "number") { + result.minWidth = node.minWidth; + } + if ("maxWidth" in node && typeof node.maxWidth === "number") { + result.maxWidth = node.maxWidth; + } + if ("minHeight" in node && typeof node.minHeight === "number") { + result.minHeight = node.minHeight; + } + if ("maxHeight" in node && typeof node.maxHeight === "number") { + result.maxHeight = node.maxHeight; + } + if ("layoutGrow" in node) { + result.layoutGrow = (node as FrameNode).layoutGrow as 0 | 1; + } + + // Wrap + if ("layoutWrap" in node && node.layoutWrap) { + result.layoutWrap = node.layoutWrap; + } + if ("counterAxisSpacing" in node && typeof node.counterAxisSpacing === "number") { + result.counterAxisSpacing = node.counterAxisSpacing; + } + if ("counterAxisAlignContent" in node) { + result.counterAxisAlignContent = (node as FrameNode).counterAxisAlignContent; + } + + // Grid layout (container) + if (node.layoutMode === "GRID") { + if ("gridRowCount" in node) result.gridRowCount = node.gridRowCount as number; + if ("gridColumnCount" in node) result.gridColumnCount = node.gridColumnCount as number; + if ("gridRowGap" in node) result.gridRowGap = node.gridRowGap as number; + if ("gridColumnGap" in node) result.gridColumnGap = node.gridColumnGap as number; + if ("gridColumnsSizing" in node) result.gridColumnsSizing = node.gridColumnsSizing as string; + if ("gridRowsSizing" in node) result.gridRowsSizing = node.gridRowsSizing as string; + } + } + + // Overflow / clip (applies to all container types, not just auto-layout) + if ("clipsContent" in node && typeof (node as FrameNode).clipsContent === "boolean") { + result.clipsContent = (node as FrameNode).clipsContent; + } + if ("overflowDirection" in node && (node as FrameNode).overflowDirection) { + result.overflowDirection = (node as FrameNode).overflowDirection; + } + + // Constraints (Plugin API MIN/MAX/STRETCH → REST API LEFT/RIGHT/LEFT_RIGHT) + if ("constraints" in node && (node as FrameNode).constraints) { + const c = (node as FrameNode).constraints; + result.constraints = { + horizontal: HORIZONTAL_CONSTRAINT_MAP[c.horizontal] ?? c.horizontal, + vertical: VERTICAL_CONSTRAINT_MAP[c.vertical] ?? c.vertical, + }; + } + + // Grid child properties (on any child of a grid parent) + if ("gridChildHorizontalAlign" in node) { + result.gridChildHorizontalAlign = (node as unknown as { gridChildHorizontalAlign: string }).gridChildHorizontalAlign; + } + if ("gridChildVerticalAlign" in node) { + result.gridChildVerticalAlign = (node as unknown as { gridChildVerticalAlign: string }).gridChildVerticalAlign; + } + if ("gridRowSpan" in node) { + result.gridRowSpan = (node as unknown as { gridRowSpan: number }).gridRowSpan; + } + if ("gridColumnSpan" in node) { + result.gridColumnSpan = (node as unknown as { gridColumnSpan: number }).gridColumnSpan; + } + if ("gridRowAnchorIndex" in node) { + result.gridRowAnchorIndex = (node as unknown as { gridRowAnchorIndex: number }).gridRowAnchorIndex; + } + if ("gridColumnAnchorIndex" in node) { + result.gridColumnAnchorIndex = (node as unknown as { gridColumnAnchorIndex: number }).gridColumnAnchorIndex; + } + // layoutPositioning (for children in auto-layout) if ("layoutPositioning" in node) { const lp = (node as FrameNode).layoutPositioning; diff --git a/src/core/adapters/figma-transformer.test.ts b/src/core/adapters/figma-transformer.test.ts new file mode 100644 index 00000000..08b94b9d --- /dev/null +++ b/src/core/adapters/figma-transformer.test.ts @@ -0,0 +1,150 @@ +import { transformFigmaResponse } from "./figma-transformer.js"; +import type { GetFileResponse } from "@figma/rest-api-spec"; + +function makeFigmaNode(overrides: Record) { + return { + id: "0:1", + name: "Test", + type: "FRAME", + ...overrides, + }; +} + +function makeFigmaResponse(document: Record): GetFileResponse { + return { + name: "TestFile", + lastModified: "2024-01-01", + version: "1", + document: document as GetFileResponse["document"], + components: {}, + styles: {}, + schemaVersion: 0, + role: "owner", + thumbnailUrl: "", + editorType: "figma", + } as GetFileResponse; +} + +describe("figma-transformer responsive fields", () => { + it("maps minWidth/maxWidth/minHeight/maxHeight", () => { + const response = makeFigmaResponse( + makeFigmaNode({ + minWidth: 100, + maxWidth: 800, + minHeight: 50, + maxHeight: 600, + }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.minWidth).toBe(100); + expect(result.document.maxWidth).toBe(800); + expect(result.document.minHeight).toBe(50); + expect(result.document.maxHeight).toBe(600); + }); + + it("maps layoutGrow", () => { + const response = makeFigmaResponse( + makeFigmaNode({ layoutGrow: 1 }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.layoutGrow).toBe(1); + }); + + it("maps constraints", () => { + const response = makeFigmaResponse( + makeFigmaNode({ + constraints: { horizontal: "LEFT_RIGHT", vertical: "TOP_BOTTOM" }, + }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.constraints).toEqual({ + horizontal: "LEFT_RIGHT", + vertical: "TOP_BOTTOM", + }); + }); + + it("maps layoutWrap and counterAxisSpacing", () => { + const response = makeFigmaResponse( + makeFigmaNode({ + layoutWrap: "WRAP", + counterAxisSpacing: 16, + counterAxisAlignContent: "SPACE_BETWEEN", + }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.layoutWrap).toBe("WRAP"); + expect(result.document.counterAxisSpacing).toBe(16); + expect(result.document.counterAxisAlignContent).toBe("SPACE_BETWEEN"); + }); + + it("maps clipsContent and overflowDirection", () => { + const response = makeFigmaResponse( + makeFigmaNode({ + clipsContent: true, + overflowDirection: "VERTICAL_SCROLLING", + }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.clipsContent).toBe(true); + expect(result.document.overflowDirection).toBe("VERTICAL_SCROLLING"); + }); + + it("maps layoutMode GRID", () => { + const response = makeFigmaResponse( + makeFigmaNode({ layoutMode: "GRID" }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.layoutMode).toBe("GRID"); + }); + + it("maps grid container fields", () => { + const response = makeFigmaResponse( + makeFigmaNode({ + layoutMode: "GRID", + gridRowCount: 3, + gridColumnCount: 4, + gridRowGap: 8, + gridColumnGap: 16, + gridColumnsSizing: "1fr 1fr 1fr 1fr", + gridRowsSizing: "auto auto auto", + }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.gridRowCount).toBe(3); + expect(result.document.gridColumnCount).toBe(4); + expect(result.document.gridRowGap).toBe(8); + expect(result.document.gridColumnGap).toBe(16); + expect(result.document.gridColumnsSizing).toBe("1fr 1fr 1fr 1fr"); + expect(result.document.gridRowsSizing).toBe("auto auto auto"); + }); + + it("maps grid child fields", () => { + const response = makeFigmaResponse( + makeFigmaNode({ + gridChildHorizontalAlign: "CENTER", + gridChildVerticalAlign: "MAX", + gridRowSpan: 2, + gridColumnSpan: 3, + gridRowAnchorIndex: 0, + gridColumnAnchorIndex: 1, + }), + ); + const result = transformFigmaResponse("test-key", response); + expect(result.document.gridChildHorizontalAlign).toBe("CENTER"); + expect(result.document.gridChildVerticalAlign).toBe("MAX"); + expect(result.document.gridRowSpan).toBe(2); + expect(result.document.gridColumnSpan).toBe(3); + expect(result.document.gridRowAnchorIndex).toBe(0); + expect(result.document.gridColumnAnchorIndex).toBe(1); + }); + + it("does not set fields when absent", () => { + const response = makeFigmaResponse(makeFigmaNode({})); + const result = transformFigmaResponse("test-key", response); + expect(result.document.minWidth).toBeUndefined(); + expect(result.document.maxWidth).toBeUndefined(); + expect(result.document.layoutWrap).toBeUndefined(); + expect(result.document.clipsContent).toBeUndefined(); + expect(result.document.constraints).toBeUndefined(); + }); +}); diff --git a/src/core/adapters/figma-transformer.ts b/src/core/adapters/figma-transformer.ts index 0ae7fd19..967db7d2 100644 --- a/src/core/adapters/figma-transformer.ts +++ b/src/core/adapters/figma-transformer.ts @@ -69,6 +69,89 @@ function transformNode(node: Node): AnalysisNode { base.paddingBottom = node.paddingBottom as number; } + // Size constraints (responsive) + if ("minWidth" in node && typeof node.minWidth === "number") { + base.minWidth = node.minWidth; + } + if ("maxWidth" in node && typeof node.maxWidth === "number") { + base.maxWidth = node.maxWidth; + } + if ("minHeight" in node && typeof node.minHeight === "number") { + base.minHeight = node.minHeight; + } + if ("maxHeight" in node && typeof node.maxHeight === "number") { + base.maxHeight = node.maxHeight; + } + if ("layoutGrow" in node && node.layoutGrow !== undefined) { + base.layoutGrow = node.layoutGrow as 0 | 1; + } + if ("constraints" in node && node.constraints) { + base.constraints = node.constraints as AnalysisNode["constraints"]; + } + + // Wrap (flex-wrap) + if ("layoutWrap" in node && node.layoutWrap) { + base.layoutWrap = node.layoutWrap as AnalysisNode["layoutWrap"]; + } + if ("counterAxisSpacing" in node && typeof node.counterAxisSpacing === "number") { + base.counterAxisSpacing = node.counterAxisSpacing; + } + if ("counterAxisAlignContent" in node && node.counterAxisAlignContent) { + base.counterAxisAlignContent = + node.counterAxisAlignContent as AnalysisNode["counterAxisAlignContent"]; + } + + // Grid layout (container) + if ("gridRowCount" in node && typeof node.gridRowCount === "number") { + base.gridRowCount = node.gridRowCount; + } + if ("gridColumnCount" in node && typeof node.gridColumnCount === "number") { + base.gridColumnCount = node.gridColumnCount; + } + if ("gridRowGap" in node && typeof node.gridRowGap === "number") { + base.gridRowGap = node.gridRowGap; + } + if ("gridColumnGap" in node && typeof node.gridColumnGap === "number") { + base.gridColumnGap = node.gridColumnGap; + } + if ("gridColumnsSizing" in node && typeof node.gridColumnsSizing === "string") { + base.gridColumnsSizing = node.gridColumnsSizing; + } + if ("gridRowsSizing" in node && typeof node.gridRowsSizing === "string") { + base.gridRowsSizing = node.gridRowsSizing; + } + + // Grid layout (child) + if ("gridChildHorizontalAlign" in node && node.gridChildHorizontalAlign) { + base.gridChildHorizontalAlign = + node.gridChildHorizontalAlign as AnalysisNode["gridChildHorizontalAlign"]; + } + if ("gridChildVerticalAlign" in node && node.gridChildVerticalAlign) { + base.gridChildVerticalAlign = + node.gridChildVerticalAlign as AnalysisNode["gridChildVerticalAlign"]; + } + if ("gridRowSpan" in node && typeof node.gridRowSpan === "number") { + base.gridRowSpan = node.gridRowSpan; + } + if ("gridColumnSpan" in node && typeof node.gridColumnSpan === "number") { + base.gridColumnSpan = node.gridColumnSpan; + } + if ("gridRowAnchorIndex" in node && typeof node.gridRowAnchorIndex === "number") { + base.gridRowAnchorIndex = node.gridRowAnchorIndex; + } + if ("gridColumnAnchorIndex" in node && typeof node.gridColumnAnchorIndex === "number") { + base.gridColumnAnchorIndex = node.gridColumnAnchorIndex; + } + + // Overflow / clip + if ("clipsContent" in node && typeof node.clipsContent === "boolean") { + base.clipsContent = node.clipsContent; + } + if ("overflowDirection" in node && node.overflowDirection) { + base.overflowDirection = + node.overflowDirection as AnalysisNode["overflowDirection"]; + } + // Size/position if ("absoluteBoundingBox" in node && node.absoluteBoundingBox) { base.absoluteBoundingBox = node.absoluteBoundingBox; diff --git a/src/core/adapters/tailwind-parser.test.ts b/src/core/adapters/tailwind-parser.test.ts index 6bec3a81..9e7d2847 100644 --- a/src/core/adapters/tailwind-parser.test.ts +++ b/src/core/adapters/tailwind-parser.test.ts @@ -143,3 +143,89 @@ describe("enrichNodeWithStyles", () => { expect(node.itemSpacing).toBe(16); // newly set }); }); + +describe("extractStylesFromClasses — responsive fields", () => { + it("extracts min-w and max-w", () => { + const styles = extractStylesFromClasses("min-w-[120px] max-w-[800px]"); + expect(styles.minWidth).toBe(120); + expect(styles.maxWidth).toBe(800); + }); + + it("extracts min-w and max-w from scale values", () => { + const styles = extractStylesFromClasses("min-w-16 max-w-96"); + expect(styles.minWidth).toBe(64); + expect(styles.maxWidth).toBe(384); + }); + + it("extracts min-h and max-h", () => { + const styles = extractStylesFromClasses("min-h-[50px] max-h-[600px]"); + expect(styles.minHeight).toBe(50); + expect(styles.maxHeight).toBe(600); + }); + + it("extracts flex-wrap", () => { + expect(extractStylesFromClasses("flex-wrap").layoutWrap).toBe("WRAP"); + expect(extractStylesFromClasses("flex-nowrap").layoutWrap).toBe("NO_WRAP"); + }); + + it("extracts gap-y as counterAxisSpacing in flex-row", () => { + expect(extractStylesFromClasses("gap-y-4").counterAxisSpacing).toBe(16); + }); + + it("extracts gap-y as itemSpacing in flex-col", () => { + const styles = extractStylesFromClasses("flex-col gap-y-4"); + expect(styles.itemSpacing).toBe(16); + expect(styles.counterAxisSpacing).toBeUndefined(); + }); + + it("extracts gap-x as counterAxisSpacing in flex-col", () => { + const styles = extractStylesFromClasses("flex-col gap-x-4"); + expect(styles.counterAxisSpacing).toBe(16); + expect(styles.itemSpacing).toBeUndefined(); + }); + + it("resolves gap-x/gap-y correctly regardless of token order", () => { + const styles = extractStylesFromClasses("gap-y-4 flex-col"); + expect(styles.itemSpacing).toBe(16); + expect(styles.counterAxisSpacing).toBeUndefined(); + }); + + it("extracts overflow-hidden as clipsContent", () => { + expect(extractStylesFromClasses("overflow-hidden").clipsContent).toBe(true); + }); + + it("extracts overflow scroll directions", () => { + expect(extractStylesFromClasses("overflow-x-auto").overflowDirection).toBe("HORIZONTAL_SCROLLING"); + expect(extractStylesFromClasses("overflow-y-scroll").overflowDirection).toBe("VERTICAL_SCROLLING"); + expect(extractStylesFromClasses("overflow-auto").overflowDirection).toBe("HORIZONTAL_AND_VERTICAL_SCROLLING"); + }); + + it("combines overflow-x and overflow-y into HORIZONTAL_AND_VERTICAL_SCROLLING", () => { + expect(extractStylesFromClasses("overflow-x-auto overflow-y-auto").overflowDirection).toBe("HORIZONTAL_AND_VERTICAL_SCROLLING"); + expect(extractStylesFromClasses("overflow-y-scroll overflow-x-scroll").overflowDirection).toBe("HORIZONTAL_AND_VERTICAL_SCROLLING"); + }); + + it("applies generic gap to both axes", () => { + const styles = extractStylesFromClasses("flex-row gap-4"); + expect(styles.itemSpacing).toBe(16); + expect(styles.counterAxisSpacing).toBe(16); + }); + + it("allows directional gap to override generic gap", () => { + const styles = extractStylesFromClasses("flex-row gap-4 gap-x-2"); + expect(styles.itemSpacing).toBe(8); + expect(styles.counterAxisSpacing).toBe(16); + }); + + it("handles overflow-x-hidden with overflow-y-auto", () => { + const styles = extractStylesFromClasses("overflow-x-hidden overflow-y-auto"); + expect(styles.clipsContent).toBe(true); + expect(styles.overflowDirection).toBe("VERTICAL_SCROLLING"); + }); + + it("handles overflow-y-hidden suppressing y-scroll", () => { + const styles = extractStylesFromClasses("overflow-auto overflow-y-hidden"); + expect(styles.clipsContent).toBe(true); + expect(styles.overflowDirection).toBe("HORIZONTAL_SCROLLING"); + }); +}); diff --git a/src/core/adapters/tailwind-parser.ts b/src/core/adapters/tailwind-parser.ts index 2c437c53..1046ae31 100644 --- a/src/core/adapters/tailwind-parser.ts +++ b/src/core/adapters/tailwind-parser.ts @@ -16,6 +16,16 @@ export interface ExtractedStyles { paddingBottom?: number; fills?: unknown[]; effects?: unknown[]; + + // Responsive fields + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + layoutWrap?: "WRAP" | "NO_WRAP"; + counterAxisSpacing?: number; + clipsContent?: boolean; + overflowDirection?: "HORIZONTAL_SCROLLING" | "VERTICAL_SCROLLING" | "HORIZONTAL_AND_VERTICAL_SCROLLING"; } /** @@ -49,6 +59,13 @@ function resolveSpacing(value: string): number | undefined { export function extractStylesFromClasses(classes: string): ExtractedStyles { const styles: ExtractedStyles = {}; const tokens = classes.split(/\s+/); + let gapX: number | undefined; + let gapY: number | undefined; + let gap: number | undefined; + let overflowX = false; + let overflowY = false; + let overflowXHidden = false; + let overflowYHidden = false; for (const token of tokens) { // Layout direction @@ -80,11 +97,15 @@ export function extractStylesFromClasses(classes: string): ExtractedStyles { styles.layoutSizingVertical = "FIXED"; } - // Gap → itemSpacing - else if (token.startsWith("gap-")) { + // Gap → deferred until layoutMode is known (gap-x-*/gap-y-* must be checked before gap-*) + else if (token.startsWith("gap-y-")) { + gapY = resolveSpacing(token.slice(6)); + } else if (token.startsWith("gap-x-")) { + gapX = resolveSpacing(token.slice(6)); + } else if (token.startsWith("gap-")) { const val = token.slice(4); const px = resolveSpacing(val); - if (px !== undefined) styles.itemSpacing = px; + if (px !== undefined) gap = px; } // Padding @@ -123,6 +144,47 @@ export function extractStylesFromClasses(classes: string): ExtractedStyles { if (px !== undefined) styles.paddingBottom = px; } + // Min/max width + else if (token.startsWith("min-w-")) { + const px = resolveSpacing(token.slice(6)); + if (px !== undefined) styles.minWidth = px; + } else if (token.startsWith("max-w-")) { + const px = resolveSpacing(token.slice(6)); + if (px !== undefined) styles.maxWidth = px; + } + + // Min/max height + else if (token.startsWith("min-h-")) { + const px = resolveSpacing(token.slice(6)); + if (px !== undefined) styles.minHeight = px; + } else if (token.startsWith("max-h-")) { + const px = resolveSpacing(token.slice(6)); + if (px !== undefined) styles.maxHeight = px; + } + + // Flex wrap + else if (token === "flex-wrap") { + styles.layoutWrap = "WRAP"; + } else if (token === "flex-nowrap") { + styles.layoutWrap = "NO_WRAP"; + } + + // Overflow + else if (token === "overflow-hidden") { + styles.clipsContent = true; + } else if (token === "overflow-x-hidden") { + overflowXHidden = true; + } else if (token === "overflow-y-hidden") { + overflowYHidden = true; + } else if (token === "overflow-x-auto" || token === "overflow-x-scroll") { + overflowX = true; + } else if (token === "overflow-y-auto" || token === "overflow-y-scroll") { + overflowY = true; + } else if (token === "overflow-auto" || token === "overflow-scroll") { + overflowX = true; + overflowY = true; + } + // Background color → fills else if (token.startsWith("bg-[")) { const colorMatch = token.match(/^bg-\[(.+)\]$/); @@ -146,6 +208,46 @@ export function extractStylesFromClasses(classes: string): ExtractedStyles { } } + // Resolve gap based on final layout direction + // Generic gap-* applies to both axes, directional gap-x-*/gap-y-* override specific axes + // In flex-row (HORIZONTAL): gap-x → itemSpacing (main), gap-y → counterAxisSpacing (cross) + // In flex-col (VERTICAL): gap-y → itemSpacing (main), gap-x → counterAxisSpacing (cross) + const isColumn = styles.layoutMode === "VERTICAL"; + if (gap !== undefined) { + if (gapX === undefined) { + if (isColumn) styles.counterAxisSpacing = gap; + else styles.itemSpacing = gap; + } + if (gapY === undefined) { + if (isColumn) styles.itemSpacing = gap; + else styles.counterAxisSpacing = gap; + } + } + if (gapX !== undefined) { + if (isColumn) styles.counterAxisSpacing = gapX; + else styles.itemSpacing = gapX; + } + if (gapY !== undefined) { + if (isColumn) styles.itemSpacing = gapY; + else styles.counterAxisSpacing = gapY; + } + + // Resolve overflow direction from collected flags + // Hidden flags suppress scrolling on that axis + const effectiveX = overflowX && !overflowXHidden; + const effectiveY = overflowY && !overflowYHidden; + if (effectiveX && effectiveY) { + styles.overflowDirection = "HORIZONTAL_AND_VERTICAL_SCROLLING"; + } else if (effectiveX) { + styles.overflowDirection = "HORIZONTAL_SCROLLING"; + } else if (effectiveY) { + styles.overflowDirection = "VERTICAL_SCROLLING"; + } + // Axis-specific hidden implies clipping on that axis + if (overflowXHidden || overflowYHidden) { + styles.clipsContent = true; + } + return styles; } @@ -283,4 +385,14 @@ export function enrichNodeWithStyles(node: AnalysisNode, styles: ExtractedStyles if (styles.paddingBottom !== undefined && node.paddingBottom === undefined) node.paddingBottom = styles.paddingBottom; if (styles.fills && !node.fills) node.fills = styles.fills; if (styles.effects && !node.effects) node.effects = styles.effects; + + // Responsive fields + if (styles.minWidth !== undefined && node.minWidth === undefined) node.minWidth = styles.minWidth; + if (styles.maxWidth !== undefined && node.maxWidth === undefined) node.maxWidth = styles.maxWidth; + if (styles.minHeight !== undefined && node.minHeight === undefined) node.minHeight = styles.minHeight; + if (styles.maxHeight !== undefined && node.maxHeight === undefined) node.maxHeight = styles.maxHeight; + if (styles.layoutWrap && !node.layoutWrap) node.layoutWrap = styles.layoutWrap; + if (styles.counterAxisSpacing !== undefined && node.counterAxisSpacing === undefined) node.counterAxisSpacing = styles.counterAxisSpacing; + if (styles.clipsContent !== undefined && node.clipsContent === undefined) node.clipsContent = styles.clipsContent; + if (styles.overflowDirection && !node.overflowDirection) node.overflowDirection = styles.overflowDirection; } diff --git a/src/core/contracts/figma-node.ts b/src/core/contracts/figma-node.ts index 0dd279d6..392cba7b 100644 --- a/src/core/contracts/figma-node.ts +++ b/src/core/contracts/figma-node.ts @@ -36,7 +36,7 @@ export const AnalysisNodeTypeSchema = z.enum([ export type AnalysisNodeType = z.infer; -export const LayoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]); +export const LayoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL", "GRID"]); export type LayoutMode = z.infer; export const LayoutAlignSchema = z.enum(["MIN", "CENTER", "MAX", "STRETCH", "INHERIT"]); @@ -45,6 +45,26 @@ export type LayoutAlign = z.infer; export const LayoutPositioningSchema = z.enum(["AUTO", "ABSOLUTE"]); export type LayoutPositioning = z.infer; +export const LayoutConstraintSchema = z.object({ + horizontal: z.enum(["LEFT", "RIGHT", "CENTER", "LEFT_RIGHT", "SCALE"]), + vertical: z.enum(["TOP", "BOTTOM", "CENTER", "TOP_BOTTOM", "SCALE"]), +}); +export type LayoutConstraint = z.infer; + +export const LayoutWrapSchema = z.enum(["NO_WRAP", "WRAP"]); +export type LayoutWrap = z.infer; + +export const OverflowDirectionSchema = z.enum([ + "HORIZONTAL_SCROLLING", + "VERTICAL_SCROLLING", + "HORIZONTAL_AND_VERTICAL_SCROLLING", + "NONE", +]); +export type OverflowDirection = z.infer; + +export const GridChildAlignSchema = z.enum(["AUTO", "MIN", "CENTER", "MAX"]); +export type GridChildAlign = z.infer; + /** * Lightweight FigmaNode type for analysis * Contains only properties needed by rules @@ -70,6 +90,39 @@ const BaseAnalysisNodeSchema = z.object({ paddingTop: z.number().optional(), paddingBottom: z.number().optional(), + // Size constraints (responsive) + minWidth: z.number().optional(), + maxWidth: z.number().optional(), + minHeight: z.number().optional(), + maxHeight: z.number().optional(), + layoutGrow: z.union([z.literal(0), z.literal(1)]).optional(), + constraints: LayoutConstraintSchema.optional(), + + // Wrap (flex-wrap) + layoutWrap: LayoutWrapSchema.optional(), + counterAxisSpacing: z.number().optional(), + counterAxisAlignContent: z.enum(["AUTO", "SPACE_BETWEEN"]).optional(), + + // Grid layout (container) + gridRowCount: z.number().optional(), + gridColumnCount: z.number().optional(), + gridRowGap: z.number().optional(), + gridColumnGap: z.number().optional(), + gridColumnsSizing: z.string().optional(), + gridRowsSizing: z.string().optional(), + + // Grid layout (child) + gridChildHorizontalAlign: GridChildAlignSchema.optional(), + gridChildVerticalAlign: GridChildAlignSchema.optional(), + gridRowSpan: z.number().optional(), + gridColumnSpan: z.number().optional(), + gridRowAnchorIndex: z.number().optional(), + gridColumnAnchorIndex: z.number().optional(), + + // Overflow / clip + clipsContent: z.boolean().optional(), + overflowDirection: OverflowDirectionSchema.optional(), + // Size/position analysis absoluteBoundingBox: z .object({ diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index a7c378e7..be90c55c 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -124,11 +124,37 @@ function renderNode( // Layout if (node.layoutMode && node.layoutMode !== "NONE") { - const dir = node.layoutMode === "VERTICAL" ? "column" : "row"; - styles.push(`display: flex; flex-direction: ${dir}`); - if (node.itemSpacing != null) styles.push(`gap: ${node.itemSpacing}px`); - if (node.primaryAxisAlignItems) styles.push(`justify-content: ${mapAlign(node.primaryAxisAlignItems)}`); - if (node.counterAxisAlignItems) styles.push(`align-items: ${mapAlign(node.counterAxisAlignItems)}`); + if (node.layoutMode === "GRID") { + styles.push(`display: grid`); + if (node.gridColumnsSizing) styles.push(`grid-template-columns: ${node.gridColumnsSizing}`); + if (node.gridRowsSizing) styles.push(`grid-template-rows: ${node.gridRowsSizing}`); + if (node.gridColumnGap != null && node.gridRowGap != null) { + styles.push(`gap: ${node.gridRowGap}px ${node.gridColumnGap}px`); + } else if (node.gridRowGap != null) { + styles.push(`row-gap: ${node.gridRowGap}px`); + } else if (node.gridColumnGap != null) { + styles.push(`column-gap: ${node.gridColumnGap}px`); + } else if (node.itemSpacing != null) { + styles.push(`gap: ${node.itemSpacing}px`); + } + } else { + const dir = node.layoutMode === "VERTICAL" ? "column" : "row"; + styles.push(`display: flex; flex-direction: ${dir}`); + if (node.layoutWrap === "WRAP") styles.push(`flex-wrap: wrap`); + if (node.itemSpacing != null) { + const mainGap = node.layoutMode === "VERTICAL" ? "row-gap" : "column-gap"; + styles.push(`${mainGap}: ${node.itemSpacing}px`); + } + if (node.counterAxisSpacing != null) { + const crossGap = node.layoutMode === "VERTICAL" ? "column-gap" : "row-gap"; + styles.push(`${crossGap}: ${node.counterAxisSpacing}px`); + } + if (node.primaryAxisAlignItems) styles.push(`justify-content: ${mapAlign(node.primaryAxisAlignItems)}`); + if (node.counterAxisAlignItems) styles.push(`align-items: ${mapAlign(node.counterAxisAlignItems)}`); + if (node.counterAxisAlignContent && node.counterAxisAlignContent !== "AUTO") { + styles.push(`align-content: ${mapAlign(node.counterAxisAlignContent)}`); + } + } } // Padding diff --git a/src/core/rules/layout/index.ts b/src/core/rules/layout/index.ts index d665b49e..25922ddf 100644 --- a/src/core/rules/layout/index.ts +++ b/src/core/rules/layout/index.ts @@ -246,9 +246,22 @@ const fixedSizeInAutoLayoutCheck: RuleCheckFn = (node, context) => { const { width, height } = node.absoluteBoundingBox; if (width <= 48 && height <= 48) return null; - // This is a heuristic - in practice you'd check layoutSizingHorizontal/Vertical - // For now, we flag containers that might benefit from flexible sizing - return null; // Disabled for now - needs more context from Figma API + // Both axes must be FIXED for this to be a problem + const hFixed = + node.layoutSizingHorizontal === "FIXED" || node.layoutSizingHorizontal === undefined; + const vFixed = + node.layoutSizingVertical === "FIXED" || node.layoutSizingVertical === undefined; + if (!hFixed || !vFixed) return null; + + // Skip if it has children — only flag leaf-like containers with no auto-layout of their own + if (node.layoutMode && node.layoutMode !== "NONE") return null; + + return { + ruleId: fixedSizeInAutoLayoutDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `Container "${node.name}" (${width}×${height}) uses fixed size on both axes inside Auto Layout. Consider HUG or FILL for at least one axis.`, + }; }; export const fixedSizeInAutoLayout = defineRule({ @@ -280,9 +293,18 @@ const missingMinWidthCheck: RuleCheckFn = (node, context) => { // Skip if not in Auto Layout context if (!context.parent || !hasAutoLayout(context.parent)) return null; - // Check for min-width in boundVariables or explicit constraint - // This is a simplified check - full implementation needs more Figma data - return null; // Needs minWidth property from Figma API + // Only flag FILL containers — FIXED/HUG don't need min-width + if (node.layoutSizingHorizontal !== "FILL") return null; + + // Has minWidth set — no issue + if (node.minWidth !== undefined) return null; + + return { + ruleId: missingMinWidthDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses FILL width without a min-width constraint. It may collapse on narrow screens.`, + }; }; export const missingMinWidth = defineRule({ @@ -303,7 +325,7 @@ const missingMaxWidthDef: RuleDefinition = { fix: "Set a maximum width constraint, especially for text containers", }; -const missingMaxWidthCheck: RuleCheckFn = (node, _context) => { +const missingMaxWidthCheck: RuleCheckFn = (node, context) => { // Only check containers and text-containing nodes if (!isContainerNode(node) && !hasTextContent(node)) return null; // Skip small elements @@ -311,10 +333,21 @@ const missingMaxWidthCheck: RuleCheckFn = (node, _context) => { const { width } = node.absoluteBoundingBox; if (width <= 200) return null; } + // Skip if not in Auto Layout context + if (!context.parent || !hasAutoLayout(context.parent)) return null; + + // Only flag FILL containers — FIXED/HUG don't need max-width + if (node.layoutSizingHorizontal !== "FILL") return null; - // Check for max-width constraint - // This is a simplified check - full implementation needs more Figma data - return null; // Needs maxWidth property from Figma API + // Has maxWidth set — no issue + if (node.maxWidth !== undefined) return null; + + return { + ruleId: missingMaxWidthDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses FILL width without a max-width constraint. Content may stretch too wide on large screens.`, + }; }; export const missingMaxWidth = defineRule({ diff --git a/src/core/rules/layout/responsive-fields.test.ts b/src/core/rules/layout/responsive-fields.test.ts new file mode 100644 index 00000000..cb9b76c4 --- /dev/null +++ b/src/core/rules/layout/responsive-fields.test.ts @@ -0,0 +1,256 @@ +import { analyzeFile } from "../../engine/rule-engine.js"; +import type { AnalysisFile, AnalysisNode } from "../../contracts/figma-node.js"; + +// Import rules to register +import "../index.js"; + +function makeNode( + overrides: Partial & { name: string; type: string }, +): AnalysisNode { + return { + id: overrides.id ?? overrides.name, + visible: true, + ...overrides, + } as AnalysisNode; +} + +function makeFile(document: AnalysisNode): AnalysisFile { + return { + fileKey: "test", + name: "Test", + lastModified: "", + version: "1", + document, + components: {}, + styles: {}, + }; +} + +describe("fixed-size-in-auto-layout", () => { + it("flags container with both axes FIXED inside auto-layout parent", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Card", + type: "FRAME", + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "fixed-size-in-auto-layout", + ); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues.at(0)?.violation.message).toContain("Card"); + }); + + it("does not flag when one axis is FILL", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Card", + type: "FRAME", + layoutSizingHorizontal: "FILL", + layoutSizingVertical: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "fixed-size-in-auto-layout", + ); + expect(issues).toHaveLength(0); + }); + + it("does not flag small elements (icons)", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Icon", + type: "FRAME", + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "fixed-size-in-auto-layout", + ); + expect(issues).toHaveLength(0); + }); +}); + +describe("missing-min-width", () => { + it("flags FILL container without minWidth in auto-layout", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Content", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-min-width", + ); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues.at(0)?.violation.message).toContain("Content"); + }); + + it("does not flag FILL container that has minWidth set", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Content", + type: "FRAME", + layoutSizingHorizontal: "FILL", + minWidth: 120, + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-min-width", + ); + expect(issues).toHaveLength(0); + }); + + it("does not flag FIXED container", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Sidebar", + type: "FRAME", + layoutSizingHorizontal: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-min-width", + ); + expect(issues).toHaveLength(0); + }); +}); + +describe("missing-max-width", () => { + it("flags FILL container without maxWidth", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "TextBlock", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 100 }, + children: [ + makeNode({ name: "Label", type: "TEXT", characters: "Hello" }), + ], + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-max-width", + ); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues.at(0)?.violation.message).toContain("TextBlock"); + }); + + it("does not flag FILL container that has maxWidth set", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "TextBlock", + type: "FRAME", + layoutSizingHorizontal: "FILL", + maxWidth: 800, + absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 100 }, + children: [ + makeNode({ name: "Label", type: "TEXT", characters: "Hello" }), + ], + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-max-width", + ); + expect(issues).toHaveLength(0); + }); + + it("does not flag FILL container outside auto-layout parent", () => { + const file = makeFile( + makeNode({ + name: "Root", + type: "FRAME", + children: [ + makeNode({ + name: "TextBlock", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 100 }, + children: [ + makeNode({ name: "Label", type: "TEXT", characters: "Hello" }), + ], + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-max-width", + ); + expect(issues).toHaveLength(0); + }); +});