diff --git a/CLAUDE.md b/CLAUDE.md index e2a7cbd0..f31c18d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,5 +266,4 @@ Rule logic and score config are intentionally separated so scores can be tuned w Configurable thresholds: - `gridBase` (default: 4) — spacing grid unit for inconsistent-spacing and magic-number-spacing -- `tolerance` (default: 10) — color difference tolerance for multiple-fill-colors -- `no-dev-status` — disabled by default \ No newline at end of file +- `tolerance` (default: 10) — color difference tolerance for multiple-fill-colors \ No newline at end of file diff --git a/README.md b/README.md index d5383e97..104343d7 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,16 @@ ## How It Works -40 rules. 6 categories. Every node in the Figma tree. +32 rules. 6 categories. Every node in the Figma tree. | Category | Rules | What it checks | |----------|-------|----------------| -| Layout | 10 | Auto-layout usage, responsive behavior | +| Layout | 9 | Auto-layout usage, responsive behavior | | Design Token | 7 | Color/font/shadow tokenization, spacing consistency | -| Component | 6 | Component reuse, detached instances, variant coverage | +| Component | 3 | Component reuse, detached instances | | Naming | 5 | Semantic names, default names, naming conventions | | AI Readability | 5 | Structure clarity, z-index reliance, empty frames | -| Handoff Risk | 6 | Hardcoded values, truncation handling, placeholder images, deep nesting | +| Handoff Risk | 3 | Hardcoded values, truncation handling, interaction coverage | Each issue is classified: **Blocking** > **Risk** > **Missing Info** > **Suggestion**. @@ -229,7 +229,7 @@ For architecture details, see [`CLAUDE.md`](CLAUDE.md). For calibration pipeline ## Roadmap -- [x] **Phase 1** — 39 rules, density-based scoring, HTML reports, presets, scoped analysis +- [x] **Phase 1** — 32 rules, density-based scoring, HTML reports, presets, scoped analysis - [x] **Phase 2** — 4-agent calibration pipeline, `/calibrate-loop` debate loop - [x] **Phase 3** — Config overrides, MCP server, Claude Skills - [x] **Phase 4** — Figma comment from report (per-issue "Comment" button in HTML report, posts to Figma node via API) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index d0b9b123..a3a331df 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -92,7 +92,7 @@ Override score, severity, or enable/disable individual rules: ### All Rule IDs -**Layout (11 rules)** +**Layout (9 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| @@ -104,8 +104,6 @@ Override score, severity, or enable/disable individual rules: | `fixed-size-in-auto-layout` | -5 | risk | | `missing-min-width` | -5 | risk | | `missing-max-width` | -4 | risk | -| `overflow-hidden-abuse` | -3 | missing-info | -| `inconsistent-sibling-layout-direction` | -2 | missing-info | **Token (7 rules)** @@ -119,15 +117,12 @@ Override score, severity, or enable/disable individual rules: | `raw-opacity` | -5 | risk | | `multiple-fill-colors` | -3 | missing-info | -**Component (6 rules)** +**Component (3 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `missing-component` | -7 | risk | | `detached-instance` | -5 | risk | -| `variant-not-used` | -3 | suggestion | -| `component-property-unused` | -2 | suggestion | -| `single-use-component` | -2 | suggestion | | `missing-component-description` | -2 | missing-info | **Naming (5 rules)** @@ -150,16 +145,13 @@ Override score, severity, or enable/disable individual rules: | `invisible-layer` | -1 | suggestion | | `empty-frame` | -2 | missing-info | -**Handoff Risk (5 rules)** +**Handoff Risk (3 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `hardcode-risk` | -5 | risk | | `text-truncation-unhandled` | -5 | risk | -| `image-no-placeholder` | -4 | risk | | `prototype-link-in-design` | -2 | suggestion | -| `no-dev-status` | -2 | suggestion | -| `deep-nesting` | -4 | risk | ### Example Configs diff --git a/src/core/adapters/figma-transformer.ts b/src/core/adapters/figma-transformer.ts index 967db7d2..3816bbb2 100644 --- a/src/core/adapters/figma-transformer.ts +++ b/src/core/adapters/figma-transformer.ts @@ -194,6 +194,9 @@ function transformNode(node: Node): AnalysisNode { if ("cornerRadius" in node && typeof node.cornerRadius === "number") { base.cornerRadius = node.cornerRadius; } + if ("opacity" in node && typeof node.opacity === "number" && node.opacity < 1) { + base.opacity = node.opacity; + } // Variable bindings if ("boundVariables" in node && node.boundVariables) { @@ -213,6 +216,11 @@ function transformNode(node: Node): AnalysisNode { base.devStatus = node.devStatus as AnalysisNode["devStatus"]; } + // Prototype interactions + if ("interactions" in node && Array.isArray(node.interactions) && node.interactions.length > 0) { + base.interactions = node.interactions; + } + // Recursively transform children if ("children" in node && Array.isArray(node.children)) { base.children = node.children.map(transformNode); diff --git a/src/core/contracts/figma-node.ts b/src/core/contracts/figma-node.ts index 392cba7b..55ed0deb 100644 --- a/src/core/contracts/figma-node.ts +++ b/src/core/contracts/figma-node.ts @@ -147,6 +147,7 @@ const BaseAnalysisNodeSchema = z.object({ individualStrokeWeights: z.record(z.string(), z.number()).optional(), effects: z.array(z.unknown()).optional(), cornerRadius: z.number().optional(), + opacity: z.number().optional(), // Variable binding analysis (design tokens) boundVariables: z.record(z.string(), z.unknown()).optional(), @@ -163,6 +164,9 @@ const BaseAnalysisNodeSchema = z.object({ }) .optional(), + // Prototype interactions + interactions: z.array(z.unknown()).optional(), + // Naming analysis metadata isAsset: z.boolean().optional(), }); diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index d6189376..3921e1be 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -100,8 +100,6 @@ export type RuleId = | "missing-min-width" | "missing-max-width" | "deep-nesting" - | "overflow-hidden-abuse" - | "inconsistent-sibling-layout-direction" // Token (7) | "raw-color" | "raw-font" @@ -110,12 +108,9 @@ export type RuleId = | "raw-shadow" | "raw-opacity" | "multiple-fill-colors" - // Component (6) + // Component (4) | "missing-component" | "detached-instance" - | "variant-not-used" - | "component-property-unused" - | "single-use-component" | "missing-component-description" // Naming (5) | "default-name" @@ -132,9 +127,7 @@ export type RuleId = // Handoff Risk (5) | "hardcode-risk" | "text-truncation-unhandled" - | "image-no-placeholder" - | "prototype-link-in-design" - | "no-dev-status"; + | "prototype-link-in-design"; /** * Categories that support depthWeight diff --git a/src/core/engine/rule-engine.test.ts b/src/core/engine/rule-engine.test.ts index 8d79315a..1f257cbf 100644 --- a/src/core/engine/rule-engine.test.ts +++ b/src/core/engine/rule-engine.test.ts @@ -243,31 +243,34 @@ describe("RuleEngine.analyze — rule filtering", () => { }); it("skips rules disabled in config (enabled: false)", () => { + // Frame without auto-layout triggers no-auto-layout rule when enabled const doc = makeNode({ id: "0:1", name: "Document", type: "DOCUMENT", children: [ - makeNode({ id: "f:1", name: "Frame", type: "FRAME" }), + makeNode({ id: "f:1", name: "Frame", type: "FRAME", children: [makeNode({ id: "c:1" })] }), ], }); const file = makeFile({ document: doc }); - // Positive control: explicitly enable no-dev-status to prove it can fire - const enabledConfigs = { ...RULE_CONFIGS }; - enabledConfigs["no-dev-status"] = { ...enabledConfigs["no-dev-status"], enabled: true }; - const resultEnabled = analyzeFile(file, { configs: enabledConfigs }); - const devStatusEnabled = resultEnabled.issues.filter( - (i) => i.violation.ruleId === "no-dev-status" + // Positive control: no-auto-layout fires when enabled (default) + const resultEnabled = analyzeFile(file); + const enabledIssues = resultEnabled.issues.filter( + (i) => i.violation.ruleId === "no-auto-layout" ); - expect(devStatusEnabled.length).toBeGreaterThan(0); - - // Default config: no-dev-status is disabled → no issues - const result = analyzeFile(file); - const devStatusIssues = result.issues.filter( - (i) => i.violation.ruleId === "no-dev-status" + expect(enabledIssues.length).toBeGreaterThan(0); + + // Disable the rule → no issues for that rule + const disabledConfigs = { ...RULE_CONFIGS }; + const baseConfig = disabledConfigs["no-auto-layout"]; + expect(baseConfig).toBeDefined(); + disabledConfigs["no-auto-layout"] = { ...baseConfig!, enabled: false }; + const result = analyzeFile(file, { configs: disabledConfigs }); + const disabledIssues = result.issues.filter( + (i) => i.violation.ruleId === "no-auto-layout" ); - expect(devStatusIssues.length).toBe(0); + expect(disabledIssues.length).toBe(0); }); }); diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 7da43739..2314cada 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -162,7 +162,6 @@ describe("calculateScores", () => { "no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior", "absolute-position-in-auto-layout", "fixed-width-in-responsive-context", "missing-min-width", "missing-max-width", - "overflow-hidden-abuse", "inconsistent-sibling-layout-direction", ] as const; const issues: AnalysisIssue[] = []; @@ -276,10 +275,10 @@ describe("calculateGrade (via calculateScores)", () => { const rulesPerCat: Record = { layout: ["no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior"], token: ["raw-color", "raw-font", "inconsistent-spacing", "magic-number-spacing", "raw-shadow"], - component: ["missing-component", "detached-instance", "variant-not-used", "component-property-unused", "single-use-component"], + component: ["missing-component", "detached-instance", "missing-component-description"], naming: ["default-name", "non-semantic-name", "inconsistent-naming-convention", "numeric-suffix-name", "too-long-name"], "ai-readability": ["ambiguous-structure", "z-index-dependent-layout", "missing-layout-hint", "invisible-layer", "empty-frame"], - "handoff-risk": ["hardcode-risk", "text-truncation-unhandled", "image-no-placeholder", "prototype-link-in-design", "no-dev-status"], + "handoff-risk": ["hardcode-risk", "text-truncation-unhandled", "prototype-link-in-design"], }; for (const cat of categories) { diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index 58021be9..64c7a909 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -73,12 +73,12 @@ const SEVERITY_DENSITY_WEIGHT: Record = { * Must be updated when rules are added/removed from a category. */ const TOTAL_RULES_PER_CATEGORY: Record = { - layout: 11, + layout: 9, token: 7, - component: 6, + component: 3, naming: 5, "ai-readability": 5, - "handoff-risk": 5, + "handoff-risk": 3, }; /** diff --git a/src/core/rules/component/component-property-unused.test.ts b/src/core/rules/component/component-property-unused.test.ts deleted file mode 100644 index c1a64b5a..00000000 --- a/src/core/rules/component/component-property-unused.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { componentPropertyUnused } from "./index.js"; - -describe("component-property-unused", () => { - it("has correct rule definition metadata", () => { - expect(componentPropertyUnused.definition.id).toBe("component-property-unused"); - expect(componentPropertyUnused.definition.category).toBe("component"); - }); - - it("returns null for non-component nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); - }); - - it("returns null for components without property definitions", () => { - const node = makeNode({ type: "COMPONENT" }); - expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); - }); - - it("returns null for components with empty property definitions", () => { - const node = makeNode({ type: "COMPONENT", componentPropertyDefinitions: {} }); - expect(componentPropertyUnused.check(node, makeContext())).toBeNull(); - }); - - it.todo("flags component with unused property bindings (binding check not yet implemented)"); -}); diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 5ce9ff99..b182111a 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -8,9 +8,6 @@ import { getRuleOption } from "../rule-config.js"; // Helper functions // ============================================ -function isComponent(node: AnalysisNode): boolean { - return node.type === "COMPONENT" || node.type === "COMPONENT_SET"; -} /** Style properties to compare between master and instance. */ const STYLE_COMPARE_KEYS = ["fills", "strokes", "effects", "cornerRadius", "strokeWeight", "individualStrokeWeights"] as const; @@ -282,8 +279,8 @@ const detachedInstanceDef: RuleDefinition = { id: "detached-instance", name: "Detached Instance", category: "component", - why: "Detached instances lose their connection to the source component", - impact: "Updates to the component won't propagate to this instance", + why: "Detached instances lose component relationship — AI sees a one-off frame instead of a reusable component reference", + impact: "AI generates duplicate code instead of reusing the component, inflating output and causing inconsistencies", fix: "Reset the instance or create a new variant if customization is needed", }; @@ -317,112 +314,6 @@ export const detachedInstance = defineRule({ check: detachedInstanceCheck, }); -// ============================================ -// variant-not-used -// ============================================ - -const variantNotUsedDef: RuleDefinition = { - id: "variant-not-used", - name: "Variant Not Used", - category: "component", - why: "Using instances but not leveraging variants defeats their purpose", - impact: "Manual changes instead of using designed variants", - fix: "Use the appropriate variant instead of overriding the default", -}; - -const variantNotUsedCheck: RuleCheckFn = (_node, _context) => { - // This would require checking if an instance is using default variant - // when other variants exist that better match the current state - // Needs more context from component definitions - return null; -}; - -export const variantNotUsed = defineRule({ - definition: variantNotUsedDef, - check: variantNotUsedCheck, -}); - -// ============================================ -// component-property-unused -// ============================================ - -const componentPropertyUnusedDef: RuleDefinition = { - id: "component-property-unused", - name: "Component Property Unused", - category: "component", - why: "Component properties should be utilized to expose customization", - impact: "Hardcoded values that should be configurable", - fix: "Connect the value to a component property", -}; - -const componentPropertyUnusedCheck: RuleCheckFn = (node, _context) => { - if (!isComponent(node)) return null; - - // Check if component has property definitions but children don't use them - if (!node.componentPropertyDefinitions) return null; - - const definedProps = Object.keys(node.componentPropertyDefinitions); - if (definedProps.length === 0) return null; - - // This would require checking if properties are actually bound - // Simplified for now - return null; -}; - -export const componentPropertyUnused = defineRule({ - definition: componentPropertyUnusedDef, - check: componentPropertyUnusedCheck, -}); - -// ============================================ -// single-use-component -// ============================================ - -const singleUseComponentDef: RuleDefinition = { - id: "single-use-component", - name: "Single Use Component", - category: "component", - why: "Components used only once add complexity without reuse benefit", - impact: "Unnecessary abstraction increases maintenance overhead", - fix: "Consider inlining if this component won't be reused", -}; - -const singleUseComponentCheck: RuleCheckFn = (node, context) => { - if (!isComponent(node)) return null; - - // Count instances of this component in the file - let instanceCount = 0; - - function countInstances(n: AnalysisNode): void { - if (n.type === "INSTANCE" && n.componentId === node.id) { - instanceCount++; - } - if (n.children) { - for (const child of n.children) { - countInstances(child); - } - } - } - - countInstances(context.file.document); - - if (instanceCount === 1) { - return { - ruleId: singleUseComponentDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `Component "${node.name}" is only used once`, - }; - } - - return null; -}; - -export const singleUseComponent = defineRule({ - definition: singleUseComponentDef, - check: singleUseComponentCheck, -}); - // ============================================ // missing-component-description // ============================================ diff --git a/src/core/rules/component/single-use-component.test.ts b/src/core/rules/component/single-use-component.test.ts deleted file mode 100644 index 90bb9130..00000000 --- a/src/core/rules/component/single-use-component.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { singleUseComponent } from "./index.js"; - -describe("single-use-component", () => { - it("has correct rule definition metadata", () => { - expect(singleUseComponent.definition.id).toBe("single-use-component"); - expect(singleUseComponent.definition.category).toBe("component"); - }); - - it("flags component used only once", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Badge" }); - const instance = makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component, instance], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - const result = singleUseComponent.check(component, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("single-use-component"); - expect(result!.message).toContain("Badge"); - }); - - it("returns null for component used multiple times", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Badge" }); - const inst1 = makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }); - const inst2 = makeNode({ id: "inst:2", type: "INSTANCE", componentId: "comp:1" }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component, inst1, inst2], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - expect(singleUseComponent.check(component, ctx)).toBeNull(); - }); - - it("flags single-use component when instance is deeply nested", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Badge" }); - const instance = makeNode({ id: "inst:1", type: "INSTANCE", componentId: "comp:1" }); - const innerFrame = makeNode({ id: "inner:1", type: "FRAME", children: [instance] }); - const outerFrame = makeNode({ id: "outer:1", type: "FRAME", children: [innerFrame] }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component, outerFrame], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - const result = singleUseComponent.check(component, ctx); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("single-use-component"); - expect(result!.message).toContain("Badge"); - }); - - it("returns null for non-component nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(singleUseComponent.check(node, makeContext())).toBeNull(); - }); - - it("returns null for component with zero instances", () => { - const component = makeNode({ id: "comp:1", type: "COMPONENT", name: "Unused" }); - const document = makeNode({ - id: "0:1", - name: "Document", - type: "DOCUMENT", - children: [component], - }); - - const ctx = makeContext({ - file: { - fileKey: "f", - name: "F", - lastModified: "", - version: "1", - document, - components: {}, - styles: {}, - }, - }); - - expect(singleUseComponent.check(component, ctx)).toBeNull(); - }); -}); diff --git a/src/core/rules/component/variant-not-used.test.ts b/src/core/rules/component/variant-not-used.test.ts deleted file mode 100644 index ba2c971c..00000000 --- a/src/core/rules/component/variant-not-used.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { variantNotUsed } from "./index.js"; - -describe("variant-not-used", () => { - it("has correct rule definition metadata", () => { - expect(variantNotUsed.definition.id).toBe("variant-not-used"); - expect(variantNotUsed.definition.category).toBe("component"); - }); - - it.todo("flags instances not using available variants (requires component variant context)"); - it.todo("returns null when all available variants are used"); -}); diff --git a/src/core/rules/handoff-risk/image-no-placeholder.test.ts b/src/core/rules/handoff-risk/image-no-placeholder.test.ts deleted file mode 100644 index f4b617f7..00000000 --- a/src/core/rules/handoff-risk/image-no-placeholder.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { imageNoPlaceholder } from "./index.js"; - -describe("image-no-placeholder", () => { - it("has correct rule definition metadata", () => { - expect(imageNoPlaceholder.definition.id).toBe("image-no-placeholder"); - expect(imageNoPlaceholder.definition.category).toBe("handoff-risk"); - }); - - it("flags RECTANGLE with only IMAGE fill (no placeholder)", () => { - const node = makeNode({ - type: "RECTANGLE", - name: "Hero Image", - fills: [{ type: "IMAGE", imageRef: "abc123" }], - }); - const result = imageNoPlaceholder.check(node, makeContext()); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("image-no-placeholder"); - expect(result!.message).toContain("Hero Image"); - }); - - it("returns null for RECTANGLE with multiple fills (has placeholder)", () => { - const node = makeNode({ - type: "RECTANGLE", - name: "Image", - fills: [ - { type: "SOLID", color: { r: 0.9, g: 0.9, b: 0.9 } }, - { type: "IMAGE", imageRef: "abc123" }, - ], - }); - expect(imageNoPlaceholder.check(node, makeContext())).toBeNull(); - }); - - it("returns null for non-image nodes", () => { - const node = makeNode({ type: "FRAME" }); - expect(imageNoPlaceholder.check(node, makeContext())).toBeNull(); - }); - - it("returns null for RECTANGLE with SOLID fill only", () => { - const node = makeNode({ - type: "RECTANGLE", - fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0 } }], - }); - expect(imageNoPlaceholder.check(node, makeContext())).toBeNull(); - }); - - it("returns null for non-RECTANGLE with image fill", () => { - const node = makeNode({ - type: "FRAME", - fills: [{ type: "IMAGE" }], - }); - expect(imageNoPlaceholder.check(node, makeContext())).toBeNull(); - }); -}); diff --git a/src/core/rules/handoff-risk/index.ts b/src/core/rules/handoff-risk/index.ts index 50dca594..55ed240d 100644 --- a/src/core/rules/handoff-risk/index.ts +++ b/src/core/rules/handoff-risk/index.ts @@ -18,17 +18,6 @@ function isTextNode(node: AnalysisNode): boolean { return node.type === "TEXT"; } -function isImageNode(node: AnalysisNode): boolean { - // Images are often rectangles with image fills - if (node.type === "RECTANGLE" && node.fills) { - for (const fill of node.fills) { - const fillObj = fill as Record; - if (fillObj["type"] === "IMAGE") return true; - } - } - return false; -} - // ============================================ // hardcode-risk // ============================================ @@ -37,9 +26,9 @@ const hardcodeRiskDef: RuleDefinition = { id: "hardcode-risk", name: "Hardcode Risk", category: "handoff-risk", - why: "Absolute positioning with fixed values creates inflexible layouts", - impact: "Layout will break when content changes or on different screens", - fix: "Use Auto Layout with relative positioning", + why: "Hardcoded position/size values force AI to use magic numbers instead of computed layouts", + impact: "Generated code is brittle — any content change (longer text, different image) breaks the layout", + fix: "Use Auto Layout with relative positioning so AI generates flexible CSS", }; const hardcodeRiskCheck: RuleCheckFn = (node, context) => { @@ -74,9 +63,9 @@ const textTruncationUnhandledDef: RuleDefinition = { id: "text-truncation-unhandled", name: "Text Truncation Unhandled", category: "handoff-risk", - why: "Text nodes without truncation handling may overflow", - impact: "Long text will break the layout", - fix: "Set text truncation (ellipsis) or ensure container can grow", + why: "Long text in a narrow container without truncation rules — AI doesn't know if it should clip, ellipsis, or grow", + impact: "AI may generate code where text overflows the container, breaking the visual layout", + fix: "Set text truncation (ellipsis) or ensure the container uses 'Hug' so the intent is explicit", }; const textTruncationUnhandledCheck: RuleCheckFn = (node, context) => { @@ -114,97 +103,89 @@ export const textTruncationUnhandled = defineRule({ }); // ============================================ -// image-no-placeholder +// prototype-link-in-design // ============================================ -const imageNoPlaceholderDef: RuleDefinition = { - id: "image-no-placeholder", - name: "Image No Placeholder", +const prototypeLinkInDesignDef: RuleDefinition = { + id: "prototype-link-in-design", + name: "Missing Prototype Interaction", category: "handoff-risk", - why: "Images without placeholder state may cause layout shifts", - impact: "Poor user experience during image loading", - fix: "Define a placeholder state or background color", + why: "Interactive-looking elements without prototype interactions force developers to guess behavior", + impact: "Developers cannot know the intended interaction (hover state, navigation, etc.)", + fix: "Add prototype interactions to interactive elements, or use naming to clarify non-interactive intent", }; -const imageNoPlaceholderCheck: RuleCheckFn = (node, context) => { - if (!isImageNode(node)) return null; +/** Name patterns that suggest an interactive element */ +const INTERACTIVE_NAME_PATTERNS = [ + /\bbtn\b/i, /\bbutton\b/i, /\blink\b/i, /\btab\b/i, + /\bcta\b/i, /\btoggle\b/i, /\bswitch\b/i, /\bcheckbox\b/i, + /\bradio\b/i, /\bdropdown\b/i, /\bselect\b/i, /\bmenu\b/i, + /\bnav\b/i, /\bclickable\b/i, /\btappable\b/i, +]; + +/** Variant names that imply interactive states */ +const STATE_VARIANT_PATTERNS = [ + /\bhover\b/i, /\bpressed\b/i, /\bactive\b/i, /\bfocused\b/i, + /\bdisabled\b/i, /\bselected\b/i, +]; + +function looksInteractive(node: AnalysisNode): boolean { + // Check name patterns + if (node.name && INTERACTIVE_NAME_PATTERNS.some((p) => p.test(node.name))) { + return true; + } - // Check if there's a background color or placeholder indicator - // This is a heuristic - images should have fallback fills - if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) { - const fill = node.fills[0] as Record; - if (fill["type"] === "IMAGE") { - return { - ruleId: imageNoPlaceholderDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" image has no placeholder fill`, - }; + // Check if component has state variants (hover, pressed, etc.) + if (node.componentPropertyDefinitions) { + const propValues = Object.values(node.componentPropertyDefinitions); + for (const prop of propValues) { + const p = prop as Record; + // VARIANT type properties with state-like values + if (p["type"] === "VARIANT" && p["variantOptions"]) { + const options = p["variantOptions"]; + if (Array.isArray(options) && options.some((opt) => typeof opt === "string" && STATE_VARIANT_PATTERNS.some((pat) => pat.test(opt)))) { + return true; + } + } } } - return null; -}; - -export const imageNoPlaceholder = defineRule({ - definition: imageNoPlaceholderDef, - check: imageNoPlaceholderCheck, -}); - -// ============================================ -// prototype-link-in-design -// ============================================ - -const prototypeLinkInDesignDef: RuleDefinition = { - id: "prototype-link-in-design", - name: "Prototype Link in Design", - category: "handoff-risk", - why: "Prototype connections may affect how the design is interpreted", - impact: "Developers may misunderstand which elements should be interactive", - fix: "Document interactions separately or use clear naming", -}; - -const prototypeLinkInDesignCheck: RuleCheckFn = (_node, _context) => { - // This would require checking prototype/interaction data - // Not available in basic node structure - needs more Figma API data - return null; -}; + return false; +} -export const prototypeLinkInDesign = defineRule({ - definition: prototypeLinkInDesignDef, - check: prototypeLinkInDesignCheck, -}); +/** Check if any descendant has interactions defined */ +function hasDescendantInteractions(node: AnalysisNode): boolean { + if (node.interactions && node.interactions.length > 0) return true; + for (const child of node.children ?? []) { + if (hasDescendantInteractions(child)) return true; + } + return false; +} -// ============================================ -// no-dev-status -// ============================================ +const prototypeLinkInDesignCheck: RuleCheckFn = (node, context) => { + // Only check components and instances (interactive elements are typically components) + if (node.type !== "COMPONENT" && node.type !== "INSTANCE" && node.type !== "FRAME") return null; -const noDevStatusDef: RuleDefinition = { - id: "no-dev-status", - name: "No Dev Status", - category: "handoff-risk", - why: "Without dev status, developers cannot know if a design is ready", - impact: "May implement designs that are still in progress", - fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate", -}; + if (!looksInteractive(node)) return null; -const noDevStatusCheck: RuleCheckFn = (node, context) => { - // Only check top-level frames (likely screens/pages) - if (node.type !== "FRAME") return null; - if (context.depth > 1) return null; + // If interactions exist on this node, it's covered + if (node.interactions && node.interactions.length > 0) return null; - // Check for devStatus - if (node.devStatus) return null; + // Skip container frames whose children already have interactions (e.g., "Button Group" wrapping interactive buttons) + if (node.type === "FRAME" && node.children && node.children.length > 0) { + if (hasDescendantInteractions(node)) return null; + } return { - ruleId: noDevStatusDef.id, + ruleId: prototypeLinkInDesignDef.id, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${node.name}" has no dev status set`, + message: `"${node.name}" looks interactive but has no prototype interactions defined`, }; }; -export const noDevStatus = defineRule({ - definition: noDevStatusDef, - check: noDevStatusCheck, +export const prototypeLinkInDesign = defineRule({ + definition: prototypeLinkInDesignDef, + check: prototypeLinkInDesignCheck, }); + diff --git a/src/core/rules/handoff-risk/no-dev-status.test.ts b/src/core/rules/handoff-risk/no-dev-status.test.ts deleted file mode 100644 index 0abcf8c4..00000000 --- a/src/core/rules/handoff-risk/no-dev-status.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { noDevStatus } from "./index.js"; - -describe("no-dev-status", () => { - it("has correct rule definition metadata", () => { - expect(noDevStatus.definition.id).toBe("no-dev-status"); - expect(noDevStatus.definition.category).toBe("handoff-risk"); - }); - - it("flags top-level frame without devStatus", () => { - const node = makeNode({ type: "FRAME", name: "LoginScreen" }); - const result = noDevStatus.check(node, makeContext({ depth: 1 })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("no-dev-status"); - expect(result!.message).toContain("LoginScreen"); - }); - - it("returns null when devStatus is set", () => { - const node = makeNode({ - type: "FRAME", - name: "LoginScreen", - devStatus: { type: "READY_FOR_DEV" }, - }); - expect(noDevStatus.check(node, makeContext({ depth: 1 }))).toBeNull(); - }); - - it("returns null for nested frames (depth > 1)", () => { - const node = makeNode({ type: "FRAME", name: "Card" }); - expect(noDevStatus.check(node, makeContext({ depth: 2 }))).toBeNull(); - }); - - it("returns null for non-FRAME nodes", () => { - const node = makeNode({ type: "GROUP" }); - expect(noDevStatus.check(node, makeContext({ depth: 1 }))).toBeNull(); - }); -}); diff --git a/src/core/rules/handoff-risk/prototype-link-in-design.test.ts b/src/core/rules/handoff-risk/prototype-link-in-design.test.ts index 1fc5247b..1ac24f94 100644 --- a/src/core/rules/handoff-risk/prototype-link-in-design.test.ts +++ b/src/core/rules/handoff-risk/prototype-link-in-design.test.ts @@ -1,11 +1,114 @@ import { makeNode, makeFile, makeContext } from "../test-helpers.js"; import { prototypeLinkInDesign } from "./index.js"; -describe("prototype-link-in-design", () => { +describe("prototype-link-in-design (missing prototype interaction)", () => { it("has correct rule definition metadata", () => { expect(prototypeLinkInDesign.definition.id).toBe("prototype-link-in-design"); expect(prototypeLinkInDesign.definition.category).toBe("handoff-risk"); }); - it.todo("flags nodes with prototype/interaction links (AnalysisNode does not yet model interaction data)"); + it("flags button-named element without interactions", () => { + const node = makeNode({ type: "COMPONENT", name: "Button Primary" }); + const result = prototypeLinkInDesign.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.ruleId).toBe("prototype-link-in-design"); + expect(result!.message).toContain("looks interactive"); + }); + + it("flags element with interactive name patterns (FRAME and INSTANCE)", () => { + const testCases = [ + { type: "FRAME" as const, name: "Submit Btn" }, + { type: "FRAME" as const, name: "Nav Link" }, + { type: "INSTANCE" as const, name: "Tab Item" }, + { type: "FRAME" as const, name: "CTA" }, + { type: "INSTANCE" as const, name: "Toggle Switch" }, + ]; + for (const { type, name } of testCases) { + const node = makeNode({ type, name }); + expect(prototypeLinkInDesign.check(node, makeContext())).not.toBeNull(); + } + }); + + it("returns null for button with interactions defined", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Button", + interactions: [{ trigger: { type: "ON_CLICK" }, actions: [{ type: "NAVIGATE" }] }], + }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-interactive names", () => { + const node = makeNode({ type: "FRAME", name: "Card Header" }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("returns null for non-container/non-component nodes", () => { + const node = makeNode({ type: "TEXT", name: "Button Label" }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("flags component with state variants but no interactions", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Chip", + componentPropertyDefinitions: { + State: { type: "VARIANT", variantOptions: ["default", "hover", "pressed"] }, + }, + }); + const result = prototypeLinkInDesign.check(node, makeContext()); + expect(result).not.toBeNull(); + expect(result!.message).toContain("looks interactive"); + }); + + it("returns null for container frame whose children have interactions", () => { + const child = makeNode({ + id: "c:1", + type: "COMPONENT", + name: "Button", + interactions: [{ trigger: { type: "ON_CLICK" }, actions: [{ type: "NAVIGATE" }] }], + }); + const container = makeNode({ + type: "FRAME", + name: "Button Group", + children: [child], + }); + expect(prototypeLinkInDesign.check(container, makeContext())).toBeNull(); + }); + + it("does not throw on malformed variantOptions (not an array)", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Card", + componentPropertyDefinitions: { + State: { type: "VARIANT", variantOptions: "not-an-array" }, + }, + }); + // "Card" is not an interactive name, malformed variantOptions should not match + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); + + it("does not throw on variantOptions with non-string entries", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Button", + componentPropertyDefinitions: { + State: { type: "VARIANT", variantOptions: [123, null, "hover"] }, + }, + }); + const result = prototypeLinkInDesign.check(node, makeContext()); + // "hover" matches STATE_VARIANT_PATTERNS, so should flag (no interactions) + expect(result).not.toBeNull(); + }); + + it("returns null for component with non-state variants", () => { + const node = makeNode({ + type: "COMPONENT", + name: "Icon", + componentPropertyDefinitions: { + Size: { type: "VARIANT", variantOptions: ["small", "medium", "large"] }, + }, + }); + expect(prototypeLinkInDesign.check(node, makeContext())).toBeNull(); + }); }); diff --git a/src/core/rules/layout/inconsistent-sibling-layout-direction.test.ts b/src/core/rules/layout/inconsistent-sibling-layout-direction.test.ts deleted file mode 100644 index 064796cc..00000000 --- a/src/core/rules/layout/inconsistent-sibling-layout-direction.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { inconsistentSiblingLayoutDirection } from "./index.js"; - -describe("inconsistent-sibling-layout-direction", () => { - it("has correct rule definition metadata", () => { - expect(inconsistentSiblingLayoutDirection.definition.id).toBe("inconsistent-sibling-layout-direction"); - expect(inconsistentSiblingLayoutDirection.definition.category).toBe("layout"); - }); - - it("flags node with different direction from siblings", () => { - const siblingA = makeNode({ id: "2:1", type: "FRAME", name: "SibA", layoutMode: "HORIZONTAL" }); - const siblingB = makeNode({ id: "2:2", type: "FRAME", name: "SibB", layoutMode: "HORIZONTAL" }); - const node = makeNode({ id: "1:1", type: "FRAME", name: "Outlier", layoutMode: "VERTICAL" }); - const siblings = [node, siblingA, siblingB]; - - const result = inconsistentSiblingLayoutDirection.check(node, makeContext({ siblings })); - expect(result).not.toBeNull(); - expect(result!.ruleId).toBe("inconsistent-sibling-layout-direction"); - expect(result!.message).toContain("VERTICAL"); - expect(result!.message).toContain("HORIZONTAL"); - }); - - it("returns null for non-container nodes", () => { - const node = makeNode({ type: "TEXT" }); - expect(inconsistentSiblingLayoutDirection.check(node, makeContext())).toBeNull(); - }); - - it("returns null when no siblings", () => { - const node = makeNode({ type: "FRAME", layoutMode: "VERTICAL" }); - expect(inconsistentSiblingLayoutDirection.check(node, makeContext())).toBeNull(); - }); - - it("returns null when all siblings have the same direction", () => { - const siblingA = makeNode({ id: "2:1", type: "FRAME", name: "SibA", layoutMode: "VERTICAL" }); - const node = makeNode({ id: "1:1", type: "FRAME", name: "Card", layoutMode: "VERTICAL" }); - const siblings = [node, siblingA]; - - expect(inconsistentSiblingLayoutDirection.check(node, makeContext({ siblings }))).toBeNull(); - }); - - it("returns null when node has no layout mode", () => { - const siblingA = makeNode({ id: "2:1", type: "FRAME", name: "SibA", layoutMode: "HORIZONTAL" }); - const node = makeNode({ id: "1:1", type: "FRAME", name: "Plain" }); - const siblings = [node, siblingA]; - - expect(inconsistentSiblingLayoutDirection.check(node, makeContext({ siblings }))).toBeNull(); - }); - - it("returns null when only one sibling (< 2 siblings)", () => { - const node = makeNode({ id: "1:1", type: "FRAME", name: "Solo", layoutMode: "VERTICAL" }); - expect(inconsistentSiblingLayoutDirection.check(node, makeContext({ siblings: [node] }))).toBeNull(); - }); - - it("allows card-in-row pattern (parent HORIZONTAL, child VERTICAL)", () => { - const parent = makeNode({ layoutMode: "HORIZONTAL" }); - const siblingA = makeNode({ id: "2:1", type: "FRAME", name: "SibA", layoutMode: "HORIZONTAL" }); - const siblingB = makeNode({ id: "2:2", type: "FRAME", name: "SibB", layoutMode: "HORIZONTAL" }); - const node = makeNode({ id: "1:1", type: "FRAME", name: "Card", layoutMode: "VERTICAL" }); - const siblings = [node, siblingA, siblingB]; - - expect(inconsistentSiblingLayoutDirection.check(node, makeContext({ parent, siblings }))).toBeNull(); - }); -}); diff --git a/src/core/rules/layout/index.ts b/src/core/rules/layout/index.ts index 25922ddf..6584404b 100644 --- a/src/core/rules/layout/index.ts +++ b/src/core/rules/layout/index.ts @@ -27,9 +27,9 @@ const noAutoLayoutDef: RuleDefinition = { id: "no-auto-layout", name: "No Auto Layout", category: "layout", - why: "Frames without Auto Layout require manual positioning for every element", - impact: "Layout breaks on content changes, harder to maintain and scale", - fix: "Apply Auto Layout to the frame with appropriate direction and spacing", + why: "Without Auto Layout, AI must guess positioning from absolute coordinates instead of reading explicit layout rules", + impact: "Generated code uses hardcoded positions that break on any content or screen size change", + fix: "Apply Auto Layout so AI can generate flexbox/grid instead of absolute positioning", }; const noAutoLayoutCheck: RuleCheckFn = (node, context) => { @@ -59,8 +59,8 @@ const absolutePositionInAutoLayoutDef: RuleDefinition = { id: "absolute-position-in-auto-layout", name: "Absolute Position in Auto Layout", category: "layout", - why: "Absolute positioning inside Auto Layout breaks the automatic flow", - impact: "Element will not respond to sibling changes, may overlap unexpectedly", + why: "Absolute positioning inside Auto Layout contradicts the parent's layout rules — AI sees conflicting instructions", + impact: "AI must decide whether to follow the parent's flexbox or the child's absolute position — often gets it wrong", fix: "Remove absolute positioning or use proper Auto Layout alignment", }; @@ -119,9 +119,9 @@ const fixedWidthInResponsiveContextDef: RuleDefinition = { id: "fixed-width-in-responsive-context", name: "Fixed Width in Responsive Context", category: "layout", - why: "Fixed width inside Auto Layout prevents responsive behavior", - impact: "Content will not adapt to container size changes", - fix: "Use 'Fill' or 'Hug' instead of fixed width", + why: "Fixed width inside Auto Layout sends mixed signals — AI sees flexbox parent but hardcoded child width", + impact: "Generated code may fight between flex sizing and explicit width, causing layout mismatches", + fix: "Use 'Fill' or 'Hug' so the intent is clear to AI", }; const fixedWidthInResponsiveContextCheck: RuleCheckFn = (node, context) => { @@ -163,9 +163,9 @@ const missingResponsiveBehaviorDef: RuleDefinition = { id: "missing-responsive-behavior", name: "Missing Responsive Behavior", category: "layout", - why: "Elements without constraints won't adapt to different screen sizes", - impact: "Layout will break or look wrong on different devices", - fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)", + why: "Without constraints, AI has no information about how elements should behave when the container resizes", + impact: "AI generates static layouts that break on any screen size other than the one in the design", + fix: "Set appropriate constraints so AI can generate responsive CSS (min/max-width, flex-grow, etc.)", }; const missingResponsiveBehaviorCheck: RuleCheckFn = (node, context) => { @@ -201,9 +201,9 @@ const groupUsageDef: RuleDefinition = { id: "group-usage", name: "Group Usage", category: "layout", - why: "Groups don't support Auto Layout and have limited layout control", - impact: "Harder to maintain consistent spacing and alignment", - fix: "Convert Group to Frame and apply Auto Layout", + why: "Groups have no layout rules — AI sees children with absolute coordinates but no container logic", + impact: "AI wraps grouped elements in a plain div with no spacing/alignment, producing fragile layouts", + fix: "Convert Group to Frame with Auto Layout so AI can generate proper flex/grid containers", }; const groupUsageCheck: RuleCheckFn = (node, context) => { @@ -230,9 +230,9 @@ const fixedSizeInAutoLayoutDef: RuleDefinition = { id: "fixed-size-in-auto-layout", name: "Fixed Size in Auto Layout", category: "layout", - why: "Fixed sizes inside Auto Layout limit flexibility", - impact: "Element won't adapt to content or container changes", - fix: "Consider using 'Hug' for content-driven sizing", + why: "Both axes fixed inside Auto Layout contradicts the flexible layout intent", + impact: "AI generates a rigid element inside a flex container — the layout won't respond to content changes", + fix: "Use 'Hug' or 'Fill' for at least one axis so AI generates responsive sizing", }; const fixedSizeInAutoLayoutCheck: RuleCheckFn = (node, context) => { @@ -277,9 +277,9 @@ const missingMinWidthDef: RuleDefinition = { id: "missing-min-width", name: "Missing Min Width", category: "layout", - why: "Without min-width, containers can collapse to unusable sizes", - impact: "Text truncation or layout collapse on narrow screens", - fix: "Set a minimum width constraint on the container", + why: "Without min-width, AI has no lower bound — generated code may collapse the container to zero on narrow screens", + impact: "Content becomes unreadable or invisible when the layout shrinks", + fix: "Set a minimum width so AI can generate a proper min-width constraint", }; const missingMinWidthCheck: RuleCheckFn = (node, context) => { @@ -320,9 +320,9 @@ const missingMaxWidthDef: RuleDefinition = { id: "missing-max-width", name: "Missing Max Width", category: "layout", - why: "Without max-width, content can stretch too wide on large screens", - impact: "Poor readability and layout on wide screens", - fix: "Set a maximum width constraint, especially for text containers", + why: "Without max-width, AI has no upper bound — text lines stretch infinitely on wide screens", + impact: "Unreadable text with 200+ character lines, broken layout proportions", + fix: "Set a maximum width so AI can generate a proper max-width constraint", }; const missingMaxWidthCheck: RuleCheckFn = (node, context) => { @@ -363,8 +363,8 @@ const deepNestingDef: RuleDefinition = { id: "deep-nesting", name: "Deep Nesting", category: "handoff-risk", - why: "Deep nesting within a single component makes the structure hard to understand for developers during handoff", - impact: "Developers must trace through many layers to understand layout intent, increasing implementation time", + why: "Deep nesting consumes AI context exponentially — each level adds indentation and structural overhead", + impact: "AI may lose track of parent-child relationships in deeply nested trees, producing wrong layout hierarchy", fix: "Flatten the structure by extracting deeply nested groups into sub-components", }; @@ -387,88 +387,3 @@ export const deepNesting = defineRule({ check: deepNestingCheck, }); -// ============================================ -// overflow-hidden-abuse -// ============================================ - -const overflowHiddenAbuseDef: RuleDefinition = { - id: "overflow-hidden-abuse", - name: "Overflow Hidden Abuse", - category: "layout", - why: "Using clip content to hide layout problems masks underlying issues", - impact: "Content may be unintentionally cut off, problems harder to diagnose", - fix: "Fix the underlying layout issue instead of hiding overflow", -}; - -const overflowHiddenAbuseCheck: RuleCheckFn = (_node, _context) => { - // This would check for clipsContent property - // Simplified for now - needs more Figma API data - return null; -}; - -export const overflowHiddenAbuse = defineRule({ - definition: overflowHiddenAbuseDef, - check: overflowHiddenAbuseCheck, -}); - -// ============================================ -// inconsistent-sibling-layout-direction -// ============================================ - -const inconsistentSiblingLayoutDirectionDef: RuleDefinition = { - id: "inconsistent-sibling-layout-direction", - name: "Inconsistent Sibling Layout Direction", - category: "layout", - why: "Sibling containers with mixed layout directions without clear reason create confusion", - impact: "Harder to understand and maintain the design structure", - fix: "Use consistent layout direction for similar sibling elements", -}; - -const inconsistentSiblingLayoutDirectionCheck: RuleCheckFn = (node, context) => { - // Only check container nodes with siblings - if (!isContainerNode(node)) return null; - if (!context.siblings || context.siblings.length < 2) return null; - - // Get layout directions of sibling containers - const siblingContainers = context.siblings.filter( - (s) => isContainerNode(s) && s.id !== node.id - ); - - if (siblingContainers.length === 0) return null; - - const myDirection = node.layoutMode; - if (!myDirection || myDirection === "NONE") return null; - - // Check if siblings have different directions - const siblingDirections = siblingContainers - .map((s) => s.layoutMode) - .filter((d) => d && d !== "NONE"); - - if (siblingDirections.length === 0) return null; - - // If all siblings have the same direction, but this node is different - const allSameSiblingDirection = siblingDirections.every( - (d) => d === siblingDirections[0] - ); - - if (allSameSiblingDirection && siblingDirections[0] !== myDirection) { - // Check for valid patterns: parent row -> child column (card layout) - if (context.parent?.layoutMode === "HORIZONTAL" && myDirection === "VERTICAL") { - return null; // This is a valid card-in-row pattern - } - - return { - ruleId: inconsistentSiblingLayoutDirectionDef.id, - nodeId: node.id, - nodePath: context.path.join(" > "), - message: `"${node.name}" has ${myDirection} layout while siblings use ${siblingDirections[0]}`, - }; - } - - return null; -}; - -export const inconsistentSiblingLayoutDirection = defineRule({ - definition: inconsistentSiblingLayoutDirectionDef, - check: inconsistentSiblingLayoutDirectionCheck, -}); diff --git a/src/core/rules/layout/overflow-hidden-abuse.test.ts b/src/core/rules/layout/overflow-hidden-abuse.test.ts deleted file mode 100644 index 4b4db4bc..00000000 --- a/src/core/rules/layout/overflow-hidden-abuse.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { makeNode, makeFile, makeContext } from "../test-helpers.js"; -import { overflowHiddenAbuse } from "./index.js"; - -describe("overflow-hidden-abuse", () => { - it("has correct rule definition metadata", () => { - expect(overflowHiddenAbuse.definition.id).toBe("overflow-hidden-abuse"); - expect(overflowHiddenAbuse.definition.category).toBe("layout"); - }); - - it.todo("flags frames with clipsContent hiding overflow instead of fixing layout (detection logic not yet implemented)"); -}); diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 1a0eb87a..2143f903 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -65,9 +65,9 @@ const defaultNameDef: RuleDefinition = { id: "default-name", name: "Default Name", category: "naming", - why: "Default names like 'Frame 123' provide no context about the element's purpose", - impact: "Designers and developers cannot understand the structure", - fix: "Rename with a descriptive, semantic name (e.g., 'Header', 'ProductCard')", + why: "Default names like 'Frame 123' give AI no semantic context to choose appropriate HTML tags or class names", + impact: "AI generates generic
wrappers instead of semantic elements like
,