diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index f97dd6d2..fb18597a 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -146,7 +146,7 @@ const ELIGIBLE_NODE_TYPES: Set = new Set([ "INSTANCE", ]); -import { isExcludedName } from "../core/rules/excluded-names.js"; +import { isExcludedName } from "../core/rules/node-semantics.js"; /** * Filter node summaries to meaningful conversion candidates. diff --git a/src/core/rules/excluded-names.ts b/src/core/rules/excluded-names.ts deleted file mode 100644 index 5a2aae23..00000000 --- a/src/core/rules/excluded-names.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Name patterns for nodes that should be excluded from certain rule checks. - * These are typically decorative, structural, or overlay elements where - * issues like naming, absolute positioning, etc. are intentional. - * - * Matches if the name contains any of these words (case-insensitive). - */ -export const EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i; - -/** - * Check if a node name matches excluded patterns. - */ -export function isExcludedName(name: string): boolean { - return EXCLUDED_NAME_PATTERN.test(name); -} diff --git a/src/core/rules/interaction/index.ts b/src/core/rules/interaction/index.ts index 89861e80..8b53764f 100644 --- a/src/core/rules/interaction/index.ts +++ b/src/core/rules/interaction/index.ts @@ -4,33 +4,10 @@ import type { AnalysisNode } from "../../contracts/figma-node.js"; import { defineRule } from "../rule-registry.js"; import type { MissingInteractionStateSubType, MissingPrototypeSubType } from "../rule-messages.js"; import { missingInteractionStateMsg, missingPrototypeMsg } from "../rule-messages.js"; - -// ============================================ -// Interactive component classification -// ============================================ - -type InteractiveType = "button" | "link" | "tab" | "input" | "toggle"; - -const INTERACTIVE_PATTERNS: Array<{ pattern: RegExp; type: InteractiveType }> = [ - { pattern: /\b(btn|button|cta)\b/i, type: "button" }, - { pattern: /\b(link|anchor)\b/i, type: "link" }, - { pattern: /\b(tab|tabs)\b/i, type: "tab" }, - { pattern: /\b(nav|navigation|menu|navbar)\b/i, type: "tab" }, - { pattern: /\b(input|text-?field|search-?bar|textarea)\b/i, type: "input" }, - { pattern: /\b(select|dropdown|combo-?box)\b/i, type: "input" }, - { pattern: /\b(toggle|switch|checkbox|radio)\b/i, type: "toggle" }, -]; - -function getInteractiveType(node: AnalysisNode): InteractiveType | null { - if (!node.name) return null; - for (const entry of INTERACTIVE_PATTERNS) { - if (entry.pattern.test(node.name)) return entry.type; - } - return null; -} +import { getStatefulComponentType, isOverlayNode, isCarouselNode, type StatefulComponentType } from "../node-semantics.js"; /** Expected state variants by interactive type */ -const EXPECTED_STATES: Record = { +const EXPECTED_STATES: Record = { button: ["hover", "active", "disabled"], link: ["hover"], tab: ["hover", "active"], @@ -110,7 +87,7 @@ const missingInteractionStateCheck: RuleCheckFn = (node, context) => { // Only check component instances and components if (node.type !== "INSTANCE" && node.type !== "COMPONENT") return null; - const interactiveType = getInteractiveType(node); + const interactiveType = getStatefulComponentType(node); if (!interactiveType) return null; const expectedStates = EXPECTED_STATES[interactiveType]; @@ -155,7 +132,7 @@ export const missingInteractionState = defineRule({ // ============================================ /** Interactive types that need click prototype */ -const PROTOTYPE_TYPES: Record = { +const PROTOTYPE_TYPES: Record = { button: "button", link: "navigation", tab: "tab", @@ -163,18 +140,12 @@ const PROTOTYPE_TYPES: Record = { toggle: "toggle", }; -/** Name patterns for overlay elements (open on top of current view) */ -const OVERLAY_PATTERN = /\b(dropdown|select|combo-?box|popover|accordion|drawer|modal|bottom-?sheet|sheet|sidebar|panel|dialog|popup|toast)\b/i; - -/** Name patterns for carousel elements (swipe/slide between items) */ -const CAROUSEL_PATTERN = /\b(carousel|slider|swiper|slide-?show|gallery)\b/i; - function getPrototypeSubType(node: AnalysisNode): MissingPrototypeSubType | null { - // Check dropdown pattern first — select/dropdown are classified as "input" in - // INTERACTIVE_PATTERNS but need "dropdown" subType for prototype checks - if (node.name && OVERLAY_PATTERN.test(node.name)) return "overlay"; - if (node.name && CAROUSEL_PATTERN.test(node.name)) return "carousel"; - const interactiveType = getInteractiveType(node); + // Check overlay/carousel first — select/dropdown are classified as "input" in + // STATEFUL_PATTERNS but need "overlay" subType for prototype checks + if (isOverlayNode(node)) return "overlay"; + if (isCarouselNode(node)) return "carousel"; + const interactiveType = getStatefulComponentType(node); if (interactiveType) { const mapped = PROTOTYPE_TYPES[interactiveType]; if (mapped) return mapped; @@ -190,24 +161,37 @@ function hasInteractionTrigger(node: AnalysisNode, triggerType: string): boolean }); } -/** Check if node (or its component master) has ON_CLICK prototype interaction */ -function hasClickInteraction(node: AnalysisNode, context: RuleContext): boolean { - if (hasInteractionTrigger(node, "ON_CLICK")) return true; +/** Check if node (or its component master) has any of the given trigger types */ +function hasAnyInteraction(node: AnalysisNode, context: RuleContext, triggers: string[]): boolean { + for (const trigger of triggers) { + if (hasInteractionTrigger(node, trigger)) return true; + } // INSTANCE nodes don't inherit interactions from master — check master fallback if (node.componentId && context.file.componentDefinitions) { const master = context.file.componentDefinitions[node.componentId]; - if (master && hasInteractionTrigger(master, "ON_CLICK")) return true; + if (master) { + for (const trigger of triggers) { + if (hasInteractionTrigger(master, trigger)) return true; + } + } } return false; } +/** Trigger types to check per subType */ +const PROTOTYPE_TRIGGERS: Record = { + carousel: ["ON_CLICK", "ON_DRAG"], +}; + +const DEFAULT_TRIGGERS = ["ON_CLICK"]; + const missingPrototypeDef: RuleDefinition = { id: "missing-prototype", name: "Missing Prototype", category: "interaction", - why: "Interactive elements without click prototypes give AI no information about navigation or behavior on click", - impact: "AI cannot generate click handlers, routing, or state changes — interactive elements become static", - fix: "Add ON_CLICK prototype interactions to define navigation targets or state changes", + why: "Interactive elements without prototype interactions give AI no information about navigation or behavior", + impact: "AI cannot generate interaction handlers, routing, or state changes — interactive elements become static", + fix: "Add prototype interactions (ON_CLICK, ON_DRAG) to define navigation targets or state changes", }; const SEEN_PROTO_KEY = "missing-prototype:seen"; @@ -222,8 +206,9 @@ const missingPrototypeCheck: RuleCheckFn = (node, context) => { const subType = getPrototypeSubType(node); if (!subType) return null; - // Already has click interaction (check instance + master) - if (hasClickInteraction(node, context)) return null; + // Already has relevant interaction (click, or drag for carousel) + const triggers = PROTOTYPE_TRIGGERS[subType] ?? DEFAULT_TRIGGERS; + if (hasAnyInteraction(node, context, triggers)) return null; // Dedup per componentId + subType const seen = getSeenProto(context); diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index f6a8bb33..244bbe17 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -1,47 +1,7 @@ import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; import { defineRule } from "../rule-registry.js"; -import { isExcludedName } from "../excluded-names.js"; import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg } from "../rule-messages.js"; - -// ============================================ -// Helper functions -// ============================================ - -const DEFAULT_NAME_PATTERNS = [ - /^Frame\s*\d*$/i, - /^Group\s*\d*$/i, - /^Rectangle\s*\d*$/i, - /^Ellipse\s*\d*$/i, - /^Vector\s*\d*$/i, - /^Line\s*\d*$/i, - /^Text\s*\d*$/i, - /^Image\s*\d*$/i, - /^Component\s*\d*$/i, - /^Instance\s*\d*$/i, -]; - -const NON_SEMANTIC_NAMES = [ - "rectangle", - "ellipse", - "vector", - "line", - "polygon", - "star", - "path", - "shape", - "image", - "fill", - "stroke", -]; - -function isDefaultName(name: string): boolean { - return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name)); -} - -function isNonSemanticName(name: string): boolean { - const normalized = name.toLowerCase().trim(); - return NON_SEMANTIC_NAMES.includes(normalized); -} +import { isExcludedName, isDefaultName, isNonSemanticName } from "../node-semantics.js"; function detectNamingConvention(name: string): string | null { if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case"; diff --git a/src/core/rules/node-semantics.ts b/src/core/rules/node-semantics.ts new file mode 100644 index 00000000..f8daad83 --- /dev/null +++ b/src/core/rules/node-semantics.ts @@ -0,0 +1,168 @@ +/** + * Centralized node semantic classification. + * All "what is this node?" logic lives here so rules share the same predicates. + * + * Categories: + * - Container: layout containers (frame, group, component, instance) + * - Visual: decorative/graphic elements (vector, shape, image) + * - Interactive: user-interactable elements (button, link, tab, input, toggle) + * - Overlay: elements that open on top (modal, drawer, dropdown) + * - Carousel: elements that slide/swipe (carousel, slider, gallery) + * - Token: style/variable binding checks + * - Naming: name pattern classification + */ + +import type { AnalysisNode } from "../contracts/figma-node.js"; + +// ── Container classification ───────────────────────────────────────────────── + +export function isContainerNode(node: AnalysisNode): boolean { + return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT" || node.type === "INSTANCE"; +} + +export function hasAutoLayout(node: AnalysisNode): boolean { + return node.layoutMode !== undefined && node.layoutMode !== "NONE"; +} + +export function hasTextContent(node: AnalysisNode): boolean { + return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false); +} + +export function hasOverlappingBounds(a: AnalysisNode, b: AnalysisNode): boolean { + const boxA = a.absoluteBoundingBox; + const boxB = b.absoluteBoundingBox; + + if (!boxA || !boxB) return false; + + return !( + boxA.x + boxA.width <= boxB.x || + boxB.x + boxB.width <= boxA.x || + boxA.y + boxA.height <= boxB.y || + boxB.y + boxB.height <= boxA.y + ); +} + +// ── Visual classification ──────────────────────────────────────────────────── + +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); +} + +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", + ); +} + +export function isVisualOnlyNode(node: AnalysisNode): boolean { + if (VISUAL_LEAF_TYPES.has(node.type)) return true; + const hasOnlyVisualChildren = + node.children !== undefined && + node.children.length > 0 && + node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type)); + // Image fill only counts as visual-only when there are no content children + if (hasImageFill(node) && (!node.children || node.children.length === 0 || hasOnlyVisualChildren)) { + return true; + } + if (hasOnlyVisualChildren) return true; + return false; +} + +// ── Interactive classification ─────────────────────────────────────────────── + +export type StatefulComponentType = "button" | "link" | "tab" | "input" | "toggle"; + +/** Name patterns → interactive type mapping */ +export const STATEFUL_PATTERNS: Array<{ pattern: RegExp; type: StatefulComponentType }> = [ + { pattern: /\b(btn|button|cta)\b/i, type: "button" }, + { pattern: /\b(link|anchor)\b/i, type: "link" }, + { pattern: /\b(tab|tabs)\b/i, type: "tab" }, + { pattern: /\b(nav|navigation|menu|navbar)\b/i, type: "tab" }, + { pattern: /\b(input|text-?field|search-?bar|textarea)\b/i, type: "input" }, + { pattern: /\b(select|dropdown|combo-?box)\b/i, type: "input" }, + { pattern: /\b(toggle|switch|checkbox|radio)\b/i, type: "toggle" }, +]; + +export function getStatefulComponentType(node: AnalysisNode): StatefulComponentType | null { + if (!node.name) return null; + for (const entry of STATEFUL_PATTERNS) { + if (entry.pattern.test(node.name)) return entry.type; + } + return null; +} + +export function isStatefulComponent(node: AnalysisNode): boolean { + return getStatefulComponentType(node) !== null; +} + +// ── Overlay / Carousel patterns ────────────────────────────────────────────── + +/** Elements that open on top of current view */ +export const OVERLAY_PATTERN = /\b(dropdown|select|combo-?box|popover|accordion|drawer|modal|bottom-?sheet|sheet|sidebar|panel|dialog|popup|toast)\b/i; + +/** Elements that swipe/slide between items */ +export const CAROUSEL_PATTERN = /\b(carousel|slider|swiper|slide-?show|gallery)\b/i; + +export function isOverlayNode(node: AnalysisNode): boolean { + return node.name !== undefined && OVERLAY_PATTERN.test(node.name); +} + +export function isCarouselNode(node: AnalysisNode): boolean { + return node.name !== undefined && CAROUSEL_PATTERN.test(node.name); +} + +// ── Token classification ───────────────────────────────────────────────────── + +export function hasStyleReference(node: AnalysisNode, styleType: string): boolean { + return node.styles !== undefined && styleType in node.styles; +} + +export function hasBoundVariable(node: AnalysisNode, key: string): boolean { + return node.boundVariables !== undefined && key in node.boundVariables; +} + +// ── Naming patterns ────────────────────────────────────────────────────────── + +/** Figma default name patterns (Frame 1, Group 2, etc.) */ +export const DEFAULT_NAME_PATTERNS = [ + /^Frame\s*\d*$/i, + /^Group\s*\d*$/i, + /^Rectangle\s*\d*$/i, + /^Ellipse\s*\d*$/i, + /^Vector\s*\d*$/i, + /^Line\s*\d*$/i, + /^Text\s*\d*$/i, + /^Image\s*\d*$/i, + /^Component\s*\d*$/i, + /^Instance\s*\d*$/i, +]; + +export function isDefaultName(name: string): boolean { + return DEFAULT_NAME_PATTERNS.some((pattern) => pattern.test(name)); +} + +/** Shape-only non-semantic names */ +export const NON_SEMANTIC_NAMES = [ + "rectangle", "ellipse", "vector", "line", "polygon", + "star", "path", "shape", "image", "fill", "stroke", +]; + +export function isNonSemanticName(name: string): boolean { + return NON_SEMANTIC_NAMES.includes(name.toLowerCase().trim()); +} + +// ── Exclusion ──────────────────────────────────────────────────────────────── + +export const EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i; + +export function isExcludedName(name: string): boolean { + return EXCLUDED_NAME_PATTERN.test(name); +} diff --git a/src/core/rules/rule-exceptions.test.ts b/src/core/rules/rule-exceptions.test.ts index b48def15..c70ca472 100644 --- a/src/core/rules/rule-exceptions.test.ts +++ b/src/core/rules/rule-exceptions.test.ts @@ -5,8 +5,8 @@ import { isAbsolutePositionExempt, isSizeConstraintExempt, isFixedSizeExempt, - isVisualOnlyNode, } from "./rule-exceptions.js"; +import { isVisualOnlyNode } from "./node-semantics.js"; function makeNode(overrides: Partial = {}): AnalysisNode { return { @@ -36,11 +36,27 @@ describe("isVisualOnlyNode", () => { expect(isVisualOnlyNode(makeNode({ type: "ELLIPSE" as any }))).toBe(true); }); - it("true for nodes with image fills", () => { + it("true for nodes with image fills and no children", () => { const node = makeNode({ fills: [{ type: "IMAGE" }] }); expect(isVisualOnlyNode(node)).toBe(true); }); + it("false for image fill frame with content children", () => { + const node = makeNode({ + fills: [{ type: "IMAGE" }], + children: [makeNode({ type: "TEXT" as any, name: "Title" })], + }); + expect(isVisualOnlyNode(node)).toBe(false); + }); + + it("true for image fill frame with only visual leaf children", () => { + const node = makeNode({ + fills: [{ type: "IMAGE" }], + children: [makeNode({ type: "VECTOR" as any })], + }); + 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 })], diff --git a/src/core/rules/rule-exceptions.ts b/src/core/rules/rule-exceptions.ts index c4fcbb66..5b1f94da 100644 --- a/src/core/rules/rule-exceptions.ts +++ b/src/core/rules/rule-exceptions.ts @@ -1,43 +1,6 @@ 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; -} - - - +import { isVisualLeafType, isVisualOnlyNode, isExcludedName } from "./node-semantics.js"; // ============================================ // Auto-layout exceptions @@ -48,7 +11,7 @@ export function isAutoLayoutExempt(node: AnalysisNode): boolean { if ( node.children && node.children.length > 0 && - node.children.every((c) => VISUAL_LEAF_TYPES.has(c.type)) + node.children.every((c) => isVisualLeafType(c.type)) ) return true; return false; diff --git a/src/core/rules/rule-messages.ts b/src/core/rules/rule-messages.ts index dae04d45..29970772 100644 --- a/src/core/rules/rule-messages.ts +++ b/src/core/rules/rule-messages.ts @@ -172,7 +172,7 @@ export const missingPrototypeMsg = { overlay: (name: string) => `"${name}" looks like an overlay trigger but has no click prototype — add an ON_CLICK interaction to define open/close behavior`, carousel: (name: string) => - `"${name}" looks like a carousel but has no click prototype — add an ON_CLICK interaction to define slide navigation`, + `"${name}" looks like a carousel but has no interaction prototype — add an ON_CLICK or ON_DRAG interaction to define slide navigation`, input: (name: string) => `"${name}" looks like an input but has no click prototype — add an ON_CLICK interaction to define focus/interaction behavior`, toggle: (name: string) => diff --git a/src/core/rules/structure/index.ts b/src/core/rules/structure/index.ts index d0d65e0a..177651c3 100644 --- a/src/core/rules/structure/index.ts +++ b/src/core/rules/structure/index.ts @@ -1,39 +1,9 @@ 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 { isAutoLayoutExempt, isAbsolutePositionExempt, isSizeConstraintExempt, isFixedSizeExempt } from "../rule-exceptions.js"; import { noAutoLayoutMsg, absolutePositionMsg, fixedSizeMsg, missingSizeConstraintMsg, nonLayoutContainerMsg, deepNestingMsg } from "../rule-messages.js"; - -// ============================================ -// Helper functions -// ============================================ - -function isContainerNode(node: AnalysisNode): boolean { - return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT" || node.type === "INSTANCE"; -} - -function hasAutoLayout(node: AnalysisNode): boolean { - return node.layoutMode !== undefined && node.layoutMode !== "NONE"; -} - -function hasTextContent(node: AnalysisNode): boolean { - return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false); -} - -function hasOverlappingBounds(a: AnalysisNode, b: AnalysisNode): boolean { - const boxA = a.absoluteBoundingBox; - const boxB = b.absoluteBoundingBox; - - if (!boxA || !boxB) return false; - - return !( - boxA.x + boxA.width <= boxB.x || - boxB.x + boxB.width <= boxA.x || - boxA.y + boxA.height <= boxB.y || - boxB.y + boxB.height <= boxA.y - ); -} +import { isContainerNode, hasAutoLayout, hasTextContent, hasOverlappingBounds } from "../node-semantics.js"; // ============================================ // no-auto-layout (merged: absorbs ambiguous-structure + missing-layout-hint) diff --git a/src/core/rules/token/index.ts b/src/core/rules/token/index.ts index 318f47fc..3f9885cf 100644 --- a/src/core/rules/token/index.ts +++ b/src/core/rules/token/index.ts @@ -1,20 +1,8 @@ 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 { rawValueMsg, irregularSpacingMsg } from "../rule-messages.js"; - -// ============================================ -// Helper functions -// ============================================ - -function hasStyleReference(node: AnalysisNode, styleType: string): boolean { - return node.styles !== undefined && styleType in node.styles; -} - -function hasBoundVariable(node: AnalysisNode, key: string): boolean { - return node.boundVariables !== undefined && key in node.boundVariables; -} +import { hasStyleReference, hasBoundVariable } from "../node-semantics.js"; function isOnGrid(value: number, gridBase: number): boolean { return value % gridBase === 0;