Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand Down
25 changes: 21 additions & 4 deletions src/core/rules/ai-readability/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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`,
};
};

Expand Down
109 changes: 109 additions & 0 deletions src/core/rules/ai-readability/invisible-layer.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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>): 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");
});
});
7 changes: 5 additions & 2 deletions src/core/rules/rule-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,12 @@ export const RULE_CONFIGS: Record<RuleId, RuleConfig> = {
enabled: true,
},
"invisible-layer": {
severity: "blocking",
score: -10,
severity: "suggestion",
score: -1,
enabled: true,
options: {
slotRecommendationThreshold: 3,
},
},
"empty-frame": {
severity: "missing-info",
Expand Down
Loading