diff --git a/src/core/engine/rule-engine.test.ts b/src/core/engine/rule-engine.test.ts index bffc485c..074942fb 100644 --- a/src/core/engine/rule-engine.test.ts +++ b/src/core/engine/rule-engine.test.ts @@ -279,7 +279,7 @@ describe("RuleEngine.analyze — rule filtering", () => { describe("RuleEngine.analyze — depth weight calculation", () => { it("applies higher weight at root level (depthWeight interpolation)", () => { // no-auto-layout requires FRAME with children to trigger, so every level needs a child - const leaf = makeNode({ id: "leaf:1", name: "Leaf", type: "RECTANGLE" }); + const leaf = makeNode({ id: "leaf:1", name: "Leaf", type: "TEXT" }); const grandchild = makeNode({ id: "gc:1", name: "GC Frame", type: "FRAME", children: [leaf] }); const child = makeNode({ id: "c:1", name: "Child Frame", type: "FRAME", children: [grandchild] }); const root = makeNode({ diff --git a/src/core/rules/rule-exceptions.test.ts b/src/core/rules/rule-exceptions.test.ts new file mode 100644 index 00000000..b48def15 --- /dev/null +++ b/src/core/rules/rule-exceptions.test.ts @@ -0,0 +1,210 @@ +import type { AnalysisNode } from "../contracts/figma-node.js"; +import type { RuleContext } from "../contracts/rule.js"; +import { + isAutoLayoutExempt, + isAbsolutePositionExempt, + isSizeConstraintExempt, + isFixedSizeExempt, + isVisualOnlyNode, +} from "./rule-exceptions.js"; + +function makeNode(overrides: Partial = {}): AnalysisNode { + return { + id: "test", + name: "Test", + type: "FRAME", + visible: true, + ...overrides, + } as AnalysisNode; +} + +function makeContext(overrides: Partial = {}): RuleContext { + return { + file: {} as RuleContext["file"], + depth: 2, + componentDepth: 0, + maxDepth: 10, + path: ["Root", "Test"], + analysisState: new Map(), + ...overrides, + }; +} + +describe("isVisualOnlyNode", () => { + it("true for vector/shape types", () => { + expect(isVisualOnlyNode(makeNode({ type: "VECTOR" as any }))).toBe(true); + expect(isVisualOnlyNode(makeNode({ type: "ELLIPSE" as any }))).toBe(true); + }); + + it("true for nodes with image fills", () => { + const node = makeNode({ fills: [{ type: "IMAGE" }] }); + expect(isVisualOnlyNode(node)).toBe(true); + }); + + it("true for frame with only visual leaf children", () => { + const node = makeNode({ + children: [makeNode({ type: "VECTOR" as any }), makeNode({ type: "RECTANGLE" as any })], + }); + expect(isVisualOnlyNode(node)).toBe(true); + }); + + it("false for frame with mixed children", () => { + const node = makeNode({ + children: [makeNode({ type: "VECTOR" as any }), makeNode({ type: "TEXT" as any })], + }); + expect(isVisualOnlyNode(node)).toBe(false); + }); + + it("false for plain frame without image fills", () => { + const node = makeNode({ fills: [{ type: "SOLID" }] }); + expect(isVisualOnlyNode(node)).toBe(false); + }); +}); + +describe("isAutoLayoutExempt", () => { + it("exempts frames with only visual leaf children", () => { + const node = makeNode({ + children: [ + makeNode({ type: "VECTOR" as any }), + makeNode({ type: "ELLIPSE" as any }), + ], + }); + expect(isAutoLayoutExempt(node)).toBe(true); + }); + + it("does not exempt image-filled frames with content children", () => { + const node = makeNode({ fills: [{ type: "IMAGE" }], children: [makeNode({ type: "TEXT" as any })] }); + expect(isAutoLayoutExempt(node)).toBe(false); + }); + + it("does not exempt INSTANCE nodes", () => { + const node = makeNode({ type: "INSTANCE" as any, children: [makeNode()] }); + expect(isAutoLayoutExempt(node)).toBe(false); + }); + + it("does not exempt frames with mixed children", () => { + const node = makeNode({ + children: [ + makeNode({ type: "VECTOR" as any }), + makeNode({ type: "TEXT" as any }), + ], + }); + expect(isAutoLayoutExempt(node)).toBe(false); + }); +}); + +describe("isAbsolutePositionExempt", () => { + it("exempts nodes with image fills", () => { + const node = makeNode({ + fills: [{ type: "IMAGE", scaleMode: "FILL" }], + }); + expect(isAbsolutePositionExempt(node)).toBe(true); + }); + + it("exempts vector nodes", () => { + const node = makeNode({ type: "VECTOR" as any }); + expect(isAbsolutePositionExempt(node)).toBe(true); + }); + + it("does not exempt plain frame", () => { + const node = makeNode({ fills: [{ type: "SOLID" }] }); + expect(isAbsolutePositionExempt(node)).toBe(false); + }); +}); + +describe("isSizeConstraintExempt", () => { + it("exempts when node has maxWidth", () => { + const node = makeNode({ maxWidth: 800 }); + expect(isSizeConstraintExempt(node, makeContext())).toBe(true); + }); + + it("exempts small elements (width <= 200)", () => { + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 150, height: 40 }, + }); + expect(isSizeConstraintExempt(node, makeContext())).toBe(true); + }); + + it("exempts when parent has maxWidth", () => { + const parent = makeNode({ maxWidth: 1200 }); + const node = makeNode({}); + expect(isSizeConstraintExempt(node, makeContext({ parent }))).toBe(true); + }); + + it("exempts root-level frames (depth <= 1)", () => { + const node = makeNode({}); + expect(isSizeConstraintExempt(node, makeContext({ depth: 1 }))).toBe(true); + }); + + it("exempts when all siblings are FILL", () => { + const node = makeNode({ layoutSizingHorizontal: "FILL" as any }); + const sibling = makeNode({ layoutSizingHorizontal: "FILL" as any }); + const ctx = makeContext({ siblings: [node, sibling] }); + expect(isSizeConstraintExempt(node, ctx)).toBe(true); + }); + + it("does not exempt when siblings have mixed sizing", () => { + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 }, + layoutSizingHorizontal: "FILL" as any, + }); + const sibling = makeNode({ layoutSizingHorizontal: "FIXED" as any }); + const ctx = makeContext({ siblings: [node, sibling] }); + expect(isSizeConstraintExempt(node, ctx)).toBe(false); + }); + + it("exempts inside GRID layout", () => { + const parent = makeNode({ layoutMode: "GRID" as any }); + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 }, + layoutSizingHorizontal: "FILL" as any, + }); + const sibling = makeNode({ layoutSizingHorizontal: "FIXED" as any }); + expect(isSizeConstraintExempt(node, makeContext({ parent, siblings: [node, sibling] }))).toBe(true); + }); + + it("exempts inside flex wrap", () => { + const parent = makeNode({ layoutWrap: "WRAP" as any }); + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 100 }, + layoutSizingHorizontal: "FILL" as any, + }); + const sibling = makeNode({ layoutSizingHorizontal: "FIXED" as any }); + expect(isSizeConstraintExempt(node, makeContext({ parent, siblings: [node, sibling] }))).toBe(true); + }); + + it("exempts TEXT nodes", () => { + const parent = makeNode({ layoutMode: "HORIZONTAL" as any }); + const node = makeNode({ type: "TEXT" as any, layoutSizingHorizontal: "FILL" as any }); + const ctx = makeContext({ + parent, + siblings: [node, makeNode({ layoutSizingHorizontal: "FIXED" as any })], + }); + expect(isSizeConstraintExempt(node, ctx)).toBe(true); + }); +}); + +describe("isFixedSizeExempt", () => { + it("exempts small elements (icons)", () => { + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, + }); + expect(isFixedSizeExempt(node)).toBe(true); + }); + + it("exempts nodes with image fills", () => { + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 }, + fills: [{ type: "IMAGE", scaleMode: "FILL" }], + }); + expect(isFixedSizeExempt(node)).toBe(true); + }); + + it("does not exempt large nodes without image fills", () => { + const node = makeNode({ + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 }, + fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 } }], + }); + expect(isFixedSizeExempt(node)).toBe(false); + }); +}); diff --git a/src/core/rules/rule-exceptions.ts b/src/core/rules/rule-exceptions.ts new file mode 100644 index 00000000..c4fcbb66 --- /dev/null +++ b/src/core/rules/rule-exceptions.ts @@ -0,0 +1,124 @@ +import type { AnalysisNode } from "../contracts/figma-node.js"; +import type { RuleContext } from "../contracts/rule.js"; +import { isExcludedName } from "./excluded-names.js"; + +// ============================================ +// Shared node type helpers +// ============================================ + +const VISUAL_LEAF_TYPES = new Set([ + "VECTOR", "BOOLEAN_OPERATION", "ELLIPSE", "LINE", "STAR", "REGULAR_POLYGON", "RECTANGLE", +]); + +export function isVisualLeafType(type: string): boolean { + return VISUAL_LEAF_TYPES.has(type); +} + +/** Node has an IMAGE type fill */ +export function hasImageFill(node: AnalysisNode): boolean { + if (!Array.isArray(node.fills)) return false; + return node.fills.some( + (fill) => + typeof fill === "object" && + fill !== null && + (fill as { type?: unknown }).type === "IMAGE", + ); +} + +/** + * Node is purely visual — not a layout container. + * True when: vector/shape type, has image fill, or frame with only visual leaf children. + */ +export function isVisualOnlyNode(node: AnalysisNode): boolean { + if (VISUAL_LEAF_TYPES.has(node.type)) return true; + if (hasImageFill(node)) return true; + if (node.children && node.children.length > 0 && node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type))) return true; + return false; +} + + + + +// ============================================ +// Auto-layout exceptions +// ============================================ + +/** Frames that don't need auto-layout (only visual-leaf children like icon paths) */ +export function isAutoLayoutExempt(node: AnalysisNode): boolean { + if ( + node.children && + node.children.length > 0 && + node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type)) + ) return true; + + return false; +} + +// ============================================ +// Absolute-position exceptions +// ============================================ + +/** Nodes that are allowed to use absolute positioning inside auto-layout */ +export function isAbsolutePositionExempt(node: AnalysisNode): boolean { + if (isVisualOnlyNode(node)) return true; + + // Intentional name patterns (badge, close, overlay, etc.) + if (isExcludedName(node.name)) return true; + + return false; +} + +// ============================================ +// Size-constraint exceptions +// ============================================ + +/** Nodes that don't need maxWidth even with FILL sizing */ +export function isSizeConstraintExempt(node: AnalysisNode, context: RuleContext): boolean { + // Already has maxWidth + if (node.maxWidth !== undefined) return true; + + // Small elements — won't stretch problematically + if (node.absoluteBoundingBox && node.absoluteBoundingBox.width <= 200) return true; + + // Parent already has maxWidth — parent constrains the stretch + if (context.parent?.maxWidth !== undefined) return true; + + // Root-level frames — they represent the screen itself + if (context.depth <= 1) return true; + + // All siblings are FILL (e.g. single item or list view) — parent controls the width + if (context.siblings && context.siblings.length > 0) { + if (context.siblings.every((s) => s.layoutSizingHorizontal === "FILL")) return true; + } + + // Inside grid layout — grid controls sizing + if (context.parent?.layoutMode === "GRID") return true; + + // Inside flex wrap — wrap layout controls sizing per row + if (context.parent?.layoutWrap === "WRAP") return true; + + // Text nodes — content length provides natural sizing + if (node.type === "TEXT") return true; + + return false; +} + +// ============================================ +// Fixed-size exceptions +// ============================================ + +/** Nodes that are allowed to use fixed sizing inside auto-layout */ +export function isFixedSizeExempt(node: AnalysisNode): boolean { + // Small fixed elements (icons, avatars) — intentionally fixed + if (node.absoluteBoundingBox) { + const { width, height } = node.absoluteBoundingBox; + if (width <= 48 && height <= 48) return true; + } + + if (isVisualOnlyNode(node)) return true; + + // Excluded names (nav, header, etc.) + if (isExcludedName(node.name)) return true; + + return false; +} diff --git a/src/core/rules/structure/index.ts b/src/core/rules/structure/index.ts index 986f4d78..e103abb8 100644 --- a/src/core/rules/structure/index.ts +++ b/src/core/rules/structure/index.ts @@ -2,7 +2,7 @@ import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; import type { AnalysisNode } from "../../contracts/figma-node.js"; import { defineRule } from "../rule-registry.js"; import { getRuleOption } from "../rule-config.js"; -import { isExcludedName } from "../excluded-names.js"; +import { isAutoLayoutExempt, isAbsolutePositionExempt, isSizeConstraintExempt, isFixedSizeExempt } from "../rule-exceptions.js"; // ============================================ // Helper functions @@ -48,10 +48,12 @@ const noAutoLayoutDef: RuleDefinition = { }; const noAutoLayoutCheck: RuleCheckFn = (node, context) => { - if (node.type !== "FRAME" && !isContainerNode(node)) return null; + if (!isContainerNode(node)) return null; if (hasAutoLayout(node)) return null; if (!node.children || node.children.length === 0) return null; + if (isAutoLayoutExempt(node)) return null; + // Priority 1: Check for overlapping visible children (ambiguous-structure) if (node.children.length >= 2) { for (let i = 0; i < node.children.length; i++) { @@ -134,37 +136,12 @@ const absolutePositionInAutoLayoutDef: RuleDefinition = { fix: "Remove absolute positioning or use proper Auto Layout alignment", }; -/** - * Check if a node is small relative to its parent (decoration/badge pattern). - * Returns true if the node is less than 25% of the parent's width AND height. - */ -function isSmallRelativeToParent(node: AnalysisNode, parent: AnalysisNode): boolean { - const nodeBB = node.absoluteBoundingBox; - const parentBB = parent.absoluteBoundingBox; - if (!nodeBB || !parentBB) return false; - if (parentBB.width === 0 || parentBB.height === 0) return false; - - const widthRatio = nodeBB.width / parentBB.width; - const heightRatio = nodeBB.height / parentBB.height; - return widthRatio < 0.25 && heightRatio < 0.25; -} - const absolutePositionInAutoLayoutCheck: RuleCheckFn = (node, context) => { if (!context.parent) return null; if (!hasAutoLayout(context.parent)) return null; if (node.layoutPositioning !== "ABSOLUTE") return null; - // Exception: vector/graphic nodes (icons, illustrations — absolute positioning is expected) - if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null; - - // Exception: intentional name patterns (badge, close, overlay, etc.) - if (isExcludedName(node.name)) return null; - - // Exception: small decoration relative to parent (< 25% size) - if (isSmallRelativeToParent(node, context.parent)) return null; - - // Exception: inside a component definition (designer's intentional layout) - if (context.parent.type === "COMPONENT") return null; + if (isAbsolutePositionExempt(node)) return null; return { ruleId: absolutePositionInAutoLayoutDef.id, @@ -198,9 +175,9 @@ const fixedSizeInAutoLayoutCheck: RuleCheckFn = (node, context) => { if (!isContainerNode(node)) return null; if (!node.absoluteBoundingBox) return null; - // Skip if it's intentionally a small fixed element (icon, avatar, etc.) + if (isFixedSizeExempt(node)) return null; + const { width, height } = node.absoluteBoundingBox; - if (width <= 48 && height <= 48) return null; // Check both axes FIXED (stronger case) const hFixed = @@ -231,9 +208,6 @@ const fixedSizeInAutoLayoutCheck: RuleCheckFn = (node, context) => { if (node.layoutAlign !== "INHERIT") return null; } - // Excluded names (nav, header, etc.) - if (isExcludedName(node.name)) return null; - return { ruleId: fixedSizeInAutoLayoutDef.id, nodeId: node.id, @@ -271,56 +245,16 @@ const missingSizeConstraintCheck: RuleCheckFn = (node, context) => { // Only flag FILL containers — FIXED/HUG don't need min/max-width if (node.layoutSizingHorizontal !== "FILL") return null; - const missingMin = node.minWidth === undefined; - const missingMax = node.maxWidth === undefined; - - // Skip small fixed elements (icons, dividers) for min-width check - let skipMinCheck = false; - if (node.absoluteBoundingBox) { - const { width, height } = node.absoluteBoundingBox; - if (width <= 48 && height <= 24) skipMinCheck = true; - } - - // Skip small elements for max-width check - let skipMaxCheck = false; - if (node.absoluteBoundingBox) { - const { width } = node.absoluteBoundingBox; - if (width <= 200) skipMaxCheck = true; - } - - const effectiveMissingMin = missingMin && !skipMinCheck; - const effectiveMissingMax = missingMax && !skipMaxCheck; + if (isSizeConstraintExempt(node, context)) return null; const currentWidth = node.absoluteBoundingBox ? `${node.absoluteBoundingBox.width}px` : "unknown"; - if (effectiveMissingMin && effectiveMissingMax) { - return { - ruleId: missingSizeConstraintDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses FILL width (currently ${currentWidth}) without min or max constraints — add minWidth and/or maxWidth`, - }; - } - - if (effectiveMissingMin) { - return { - ruleId: missingSizeConstraintDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses FILL width (currently ${currentWidth}) without min-width — add minWidth to prevent collapse on narrow screens`, - }; - } - - if (effectiveMissingMax) { - return { - ruleId: missingSizeConstraintDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" uses FILL width (currently ${currentWidth}) without max-width — add maxWidth to prevent stretching on large screens`, - }; - } - - return null; + return { + ruleId: missingSizeConstraintDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" uses FILL width (currently ${currentWidth}) without max-width — add maxWidth to prevent stretching on large screens`, + }; }; export const missingSizeConstraint = defineRule({ diff --git a/src/core/rules/structure/no-auto-layout.test.ts b/src/core/rules/structure/no-auto-layout.test.ts index 128e60ff..38b63150 100644 --- a/src/core/rules/structure/no-auto-layout.test.ts +++ b/src/core/rules/structure/no-auto-layout.test.ts @@ -37,6 +37,33 @@ describe("no-auto-layout", () => { expect(noAutoLayout.check(node, ctx)).toBeNull(); }); + it("returns null for frame with single vector child", () => { + const vector = makeNode({ id: "v:1", type: "VECTOR" as any, name: "Path" }); + const node = makeNode({ name: "IconArrow", children: [vector] }); + expect(noAutoLayout.check(node, makeContext())).toBeNull(); + }); + + it("returns null for frame with multiple vector/shape children", () => { + const v1 = makeNode({ id: "v:1", type: "VECTOR" as any, name: "Path1" }); + const v2 = makeNode({ id: "v:2", type: "BOOLEAN_OPERATION" as any, name: "Union" }); + const node = makeNode({ name: "IconComplex", children: [v1, v2] }); + expect(noAutoLayout.check(node, makeContext())).toBeNull(); + }); + + it("returns null for frame with rectangle and ellipse children", () => { + const rect = makeNode({ id: "r:1", type: "RECTANGLE" as any, name: "Bg" }); + const ellipse = makeNode({ id: "e:1", type: "ELLIPSE" as any, name: "Circle" }); + const node = makeNode({ name: "ShapeGroup", children: [rect, ellipse] }); + expect(noAutoLayout.check(node, makeContext())).toBeNull(); + }); + + it("flags frame with mixed visual and non-visual children", () => { + const vector = makeNode({ id: "v:1", type: "VECTOR" as any, name: "Path" }); + const text = makeNode({ id: "t:1", type: "TEXT" as any, name: "Label" }); + const node = makeNode({ name: "BadgeWithIcon", children: [vector, text] }); + expect(noAutoLayout.check(node, makeContext())).not.toBeNull(); + }); + it("flags frame without auto layout that has children", () => { const child = makeNode({ id: "c:1", name: "Child" }); const node = makeNode({ name: "Container", children: [child] }); diff --git a/src/core/rules/structure/responsive-fields.test.ts b/src/core/rules/structure/responsive-fields.test.ts index 0cda5296..9f4f9fab 100644 --- a/src/core/rules/structure/responsive-fields.test.ts +++ b/src/core/rules/structure/responsive-fields.test.ts @@ -102,18 +102,30 @@ describe("fixed-size-in-auto-layout", () => { }); describe("missing-size-constraint", () => { - it("flags FILL container without any size constraints", () => { + it("flags FILL container when siblings have mixed sizing", () => { const file = makeFile( makeNode({ - name: "Root", + name: "Page", type: "FRAME", - layoutMode: "HORIZONTAL", children: [ makeNode({ - name: "Content", + name: "Root", type: "FRAME", - layoutSizingHorizontal: "FILL", - absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Sidebar", + type: "FRAME", + layoutSizingHorizontal: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + }), + makeNode({ + name: "Content", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 200, y: 0, width: 400, height: 100 }, + }), + ], }), ], }), @@ -123,22 +135,68 @@ describe("missing-size-constraint", () => { (i) => i.rule.definition.id === "missing-size-constraint", ); expect(issues.length).toBeGreaterThanOrEqual(1); - expect(issues.at(0)?.violation.message).toContain("min or max"); }); - it("flags missing maxWidth when minWidth is set and container is wide", () => { + it("does not flag when maxWidth is set", () => { const file = makeFile( makeNode({ - name: "Root", + name: "Page", type: "FRAME", - layoutMode: "HORIZONTAL", children: [ makeNode({ - name: "Content", + name: "Root", type: "FRAME", - layoutSizingHorizontal: "FILL", - minWidth: 120, - absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + layoutMode: "HORIZONTAL", + children: [ + makeNode({ + name: "Left", + type: "FRAME", + layoutSizingHorizontal: "FILL", + maxWidth: 800, + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + }), + makeNode({ + name: "Right", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 300, y: 0, width: 300, height: 100 }, + }), + ], + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-size-constraint" && i.violation.nodeId === "Left", + ); + expect(issues).toHaveLength(0); + }); + + it("does not flag when all siblings are FILL (e.g. list view)", () => { + const file = makeFile( + makeNode({ + name: "Page", + type: "FRAME", + children: [ + makeNode({ + name: "Root", + type: "FRAME", + layoutMode: "VERTICAL", + children: [ + makeNode({ + name: "Item1", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 50 }, + }), + makeNode({ + name: "Item2", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 50, width: 600, height: 50 }, + }), + ], }), ], }), @@ -147,22 +205,34 @@ describe("missing-size-constraint", () => { const issues = result.issues.filter( (i) => i.rule.definition.id === "missing-size-constraint", ); - expect(issues.length).toBeGreaterThanOrEqual(1); - expect(issues.at(0)?.violation.message).toContain("max-width"); + expect(issues).toHaveLength(0); }); - it("does not flag FIXED container", () => { + it("does not flag when parent has maxWidth", () => { const file = makeFile( makeNode({ - name: "Root", + name: "Page", type: "FRAME", - layoutMode: "HORIZONTAL", children: [ makeNode({ - name: "Sidebar", + name: "Root", type: "FRAME", - layoutSizingHorizontal: "FIXED", - absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + layoutMode: "HORIZONTAL", + maxWidth: 1200, + children: [ + makeNode({ + name: "Left", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + }), + makeNode({ + name: "Right", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 300, y: 0, width: 300, height: 100 }, + }), + ], }), ], }), @@ -174,21 +244,23 @@ describe("missing-size-constraint", () => { expect(issues).toHaveLength(0); }); - it("flags missing maxWidth when only minWidth is set on wide container", () => { + it("does not flag inside grid layout", () => { const file = makeFile( makeNode({ - name: "Root", + name: "Page", type: "FRAME", - layoutMode: "HORIZONTAL", children: [ makeNode({ - name: "TextBlock", + name: "Grid", type: "FRAME", - layoutSizingHorizontal: "FILL", - minWidth: 100, - absoluteBoundingBox: { x: 0, y: 0, width: 600, height: 100 }, + layoutMode: "GRID", children: [ - makeNode({ name: "Label", type: "TEXT", characters: "Hello" }), + makeNode({ + name: "Cell", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, + }), ], }), ], @@ -198,11 +270,46 @@ describe("missing-size-constraint", () => { const issues = result.issues.filter( (i) => i.rule.definition.id === "missing-size-constraint", ); - expect(issues.length).toBeGreaterThanOrEqual(1); - expect(issues.at(0)?.violation.message).toContain("max-width"); + expect(issues).toHaveLength(0); + }); + + it("does not flag inside flex wrap", () => { + const file = makeFile( + makeNode({ + name: "Page", + type: "FRAME", + children: [ + makeNode({ + name: "WrapContainer", + type: "FRAME", + layoutMode: "HORIZONTAL", + layoutWrap: "WRAP", + children: [ + makeNode({ + name: "Tag1", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 40 }, + }), + makeNode({ + name: "Tag2", + type: "FRAME", + layoutSizingHorizontal: "FILL", + absoluteBoundingBox: { x: 300, y: 0, width: 300, height: 40 }, + }), + ], + }), + ], + }), + ); + const result = analyzeFile(file); + const issues = result.issues.filter( + (i) => i.rule.definition.id === "missing-size-constraint", + ); + expect(issues).toHaveLength(0); }); - it("flags missing minWidth when only maxWidth is set", () => { + it("does not flag FIXED container", () => { const file = makeFile( makeNode({ name: "Root", @@ -210,10 +317,9 @@ describe("missing-size-constraint", () => { layoutMode: "HORIZONTAL", children: [ makeNode({ - name: "Content", + name: "Sidebar", type: "FRAME", - layoutSizingHorizontal: "FILL", - maxWidth: 800, + layoutSizingHorizontal: "FIXED", absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 100 }, }), ], @@ -223,8 +329,7 @@ describe("missing-size-constraint", () => { const issues = result.issues.filter( (i) => i.rule.definition.id === "missing-size-constraint", ); - expect(issues.length).toBeGreaterThanOrEqual(1); - expect(issues.at(0)?.violation.message).toContain("min-width"); + expect(issues).toHaveLength(0); }); it("does not flag FILL container outside auto-layout parent", () => {