-
Notifications
You must be signed in to change notification settings - Fork 0
refactor: centralize node semantic classification (#157) #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a22f5b6
e41650b
3d84b3a
b6f56b8
801e794
d263656
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<InteractiveType, MissingInteractionStateSubType[]> = { | ||
| const EXPECTED_STATES: Record<StatefulComponentType, MissingInteractionStateSubType[]> = { | ||
| 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,26 +132,20 @@ export const missingInteractionState = defineRule({ | |
| // ============================================ | ||
|
|
||
| /** Interactive types that need click prototype */ | ||
| const PROTOTYPE_TYPES: Record<InteractiveType, MissingPrototypeSubType> = { | ||
| const PROTOTYPE_TYPES: Record<StatefulComponentType, MissingPrototypeSubType> = { | ||
| button: "button", | ||
| link: "navigation", | ||
| tab: "tab", | ||
| input: "input", | ||
| 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<string, string[]> = { | ||
| carousel: ["ON_CLICK", "ON_DRAG"], | ||
| }; | ||
|
|
||
| const DEFAULT_TRIGGERS = ["ON_CLICK"]; | ||
|
Comment on lines
+181
to
+186
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the missing-prototype guidance for drag-supported carousels.
📝 Suggested wording update-/** Interactive types that need click prototype */
+/** Interactive types that need prototype interactions */
const PROTOTYPE_TYPES: Record<InteractiveType, MissingPrototypeSubType> = {
button: "button",
link: "navigation",
tab: "tab",
input: "input",
toggle: "toggle",
};
@@
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 navigation, gesture handlers, or state changes — interactive elements become static",
+ fix: "Add prototype interactions (for example ON_CLICK, or ON_DRAG for carousels) to define navigation targets or state changes",
};As per coding guidelines, Also applies to: 188-195, 209-211 🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // ── 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); | ||
|
Comment on lines
+164
to
+167
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't use the shared exclusion regex to hide interactive controls. This pattern is no longer naming-only:
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keeping as-is.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: Length of output: 4553
It is not referenced anywhere in ✏️ Learnings added
🧠 Learnings used |
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.