diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index c5d7249d..d0b9b123 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -147,7 +147,7 @@ Override score, severity, or enable/disable individual rules: | `ambiguous-structure` | -10 | blocking | | `z-index-dependent-layout` | -5 | risk | | `missing-layout-hint` | -3 | missing-info | -| `invisible-layer` | -10 | blocking | +| `invisible-layer` | -1 | suggestion | | `empty-frame` | -2 | missing-info | **Handoff Risk (5 rules)** diff --git a/src/core/rules/ai-readability/index.ts b/src/core/rules/ai-readability/index.ts index 53956c0b..26e17d0b 100644 --- a/src/core/rules/ai-readability/index.ts +++ b/src/core/rules/ai-readability/index.ts @@ -1,6 +1,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"; // ============================================ // Helper functions @@ -199,9 +200,9 @@ const invisibleLayerDef: RuleDefinition = { id: "invisible-layer", name: "Invisible Layer", category: "ai-readability", - why: "Hidden layers add noise and may confuse analysis tools", - impact: "Exported code may include unnecessary elements", - fix: "Delete hidden layers or move them to a separate 'archive' page", + why: "Hidden layers increase API response size and node count but are skipped during code generation. They are a normal part of the Figma workflow (version history, A/B options, state layers) and do not block implementation.", + impact: "Minor token overhead from larger API responses. No impact on code generation accuracy since hidden nodes are excluded from the design tree.", + fix: "No action required if hidden layers are intentional. Clean up unused hidden layers to reduce file size. If a frame has many hidden children representing states, consider using Figma's Slot feature for cleaner state management.", }; const invisibleLayerCheck: RuleCheckFn = (node, context) => { @@ -210,11 +211,27 @@ const invisibleLayerCheck: RuleCheckFn = (node, context) => { // Skip if parent is also invisible (only report top-level invisible) if (context.parent?.visible === false) return null; + // Check if parent has many hidden children — suggest Slot + const slotThreshold = + getRuleOption("invisible-layer", "slotRecommendationThreshold", 3); + const hiddenSiblingCount = context.siblings + ? context.siblings.filter((s) => s.visible === false).length + : 0; + + if (hiddenSiblingCount >= slotThreshold) { + return { + ruleId: invisibleLayerDef.id, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: `"${node.name}" is hidden (${hiddenSiblingCount} hidden siblings) — if these represent states, consider using Figma Slots instead`, + }; + } + return { ruleId: invisibleLayerDef.id, nodeId: node.id, nodePath: context.path.join(" > "), - message: `"${node.name}" is hidden - consider removing if not needed`, + message: `"${node.name}" is hidden — no impact on code generation, clean up if unused`, }; }; diff --git a/src/core/rules/ai-readability/invisible-layer.test.ts b/src/core/rules/ai-readability/invisible-layer.test.ts new file mode 100644 index 00000000..ff6d8529 --- /dev/null +++ b/src/core/rules/ai-readability/invisible-layer.test.ts @@ -0,0 +1,109 @@ +import type { RuleContext } from "../../contracts/rule.js"; +import type { AnalysisFile, AnalysisNode } from "../../contracts/figma-node.js"; +import { invisibleLayer } from "./index.js"; + +function makeNode(overrides?: Partial): AnalysisNode { + return { + id: "1:1", + name: "TestNode", + type: "FRAME", + visible: true, + ...overrides, + }; +} + +function makeFile(): AnalysisFile { + return { + fileKey: "test-file", + name: "Test File", + lastModified: "2026-01-01T00:00:00Z", + version: "1", + document: makeNode({ id: "0:1", name: "Document", type: "DOCUMENT" }), + components: {}, + styles: {}, + }; +} + +function makeContext(overrides?: Partial): RuleContext { + return { + file: makeFile(), + depth: 2, + componentDepth: 0, + maxDepth: 10, + path: ["Page", "Section"], + ...overrides, + }; +} + +describe("invisible-layer", () => { + it("has correct rule definition metadata", () => { + const def = invisibleLayer.definition; + expect(def.id).toBe("invisible-layer"); + expect(def.category).toBe("ai-readability"); + expect(def.why).toContain("Hidden layers"); + expect(def.fix).toContain("Slot"); + }); + + it("returns null for visible nodes", () => { + const node = makeNode({ visible: true }); + const ctx = makeContext(); + expect(invisibleLayer.check(node, ctx)).toBeNull(); + }); + + it("flags hidden node with basic message", () => { + const node = makeNode({ visible: false, name: "OldVersion" }); + const ctx = makeContext({ siblings: [node] }); + + const result = invisibleLayer.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.message).toContain("OldVersion"); + expect(result!.message).toContain("hidden"); + expect(result!.message).toContain("clean up if unused"); + }); + + it("skips when parent is also invisible", () => { + const node = makeNode({ visible: false }); + const parent = makeNode({ visible: false, name: "HiddenParent" }); + const ctx = makeContext({ parent }); + + expect(invisibleLayer.check(node, ctx)).toBeNull(); + }); + + it("suggests Slot when 3+ hidden siblings", () => { + const hidden1 = makeNode({ id: "h:1", visible: false, name: "StateA" }); + const hidden2 = makeNode({ id: "h:2", visible: false, name: "StateB" }); + const hidden3 = makeNode({ id: "h:3", visible: false, name: "StateC" }); + const visible1 = makeNode({ id: "v:1", visible: true, name: "Active" }); + + const siblings = [hidden1, hidden2, hidden3, visible1]; + const ctx = makeContext({ siblings }); + + const result = invisibleLayer.check(hidden1, ctx); + expect(result).not.toBeNull(); + expect(result!.message).toContain("3 hidden siblings"); + expect(result!.message).toContain("Slot"); + }); + + it("does not suggest Slot when fewer than 3 hidden siblings", () => { + const hidden1 = makeNode({ id: "h:1", visible: false, name: "StateA" }); + const hidden2 = makeNode({ id: "h:2", visible: false, name: "StateB" }); + const visible1 = makeNode({ id: "v:1", visible: true, name: "Active" }); + + const siblings = [hidden1, hidden2, visible1]; + const ctx = makeContext({ siblings }); + + const result = invisibleLayer.check(hidden1, ctx); + expect(result).not.toBeNull(); + expect(result!.message).not.toContain("Slot"); + expect(result!.message).toContain("clean up if unused"); + }); + + it("handles undefined siblings gracefully", () => { + const node = makeNode({ visible: false, name: "Hidden" }); + const ctx = makeContext({ siblings: undefined }); + + const result = invisibleLayer.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.message).toContain("clean up if unused"); + }); +}); diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index f53702d2..5459f597 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -212,9 +212,12 @@ export const RULE_CONFIGS: Record = { enabled: true, }, "invisible-layer": { - severity: "blocking", - score: -10, + severity: "suggestion", + score: -1, enabled: true, + options: { + slotRecommendationThreshold: 3, + }, }, "empty-frame": { severity: "missing-info",